Add BlogTruyen.vn (unoriginal) (#686)
* Add BlogTruyen.vn (unoriginal) * refactor a thing * Add final newline * Epic lint fail * Move date format out of constructor arguments * Remove manifest file in override * Don't display genre list if empty * Apply rate limit only to real BlogTruyen
| Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB | 
| Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB | 
| Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB | 
| Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB | 
| Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB | 
| @ -0,0 +1,67 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.vi.blogtruyen | ||||||
|  | 
 | ||||||
|  | import eu.kanade.tachiyomi.multisrc.blogtruyen.BlogTruyen | ||||||
|  | import eu.kanade.tachiyomi.network.interceptor.rateLimit | ||||||
|  | 
 | ||||||
|  | class BlogTruyenMoi : BlogTruyen("BlogTruyen", "https://blogtruyenmoi.com", "vi") { | ||||||
|  |     override val client = super.client.newBuilder() | ||||||
|  |         .rateLimit(2) | ||||||
|  |         .build() | ||||||
|  | 
 | ||||||
|  |     override fun getGenreList() = listOf( | ||||||
|  |         Genre("Action", "1"), | ||||||
|  |         Genre("Adventure", "3"), | ||||||
|  |         Genre("Comedy", "5"), | ||||||
|  |         Genre("Comic", "6"), | ||||||
|  |         Genre("Doujinshi", "7"), | ||||||
|  |         Genre("Drama", "49"), | ||||||
|  |         Genre("Ecchi", "48"), | ||||||
|  |         Genre("Event BT", "60"), | ||||||
|  |         Genre("Fantasy", "50"), | ||||||
|  |         Genre("Full màu", "64"), | ||||||
|  |         Genre("Game", "61"), | ||||||
|  |         Genre("Gender Bender", "51"), | ||||||
|  |         Genre("Harem", "12"), | ||||||
|  |         Genre("Historical", "13"), | ||||||
|  |         Genre("Horror", "14"), | ||||||
|  |         Genre("Isekai/Dị giới/Trọng sinh", "63"), | ||||||
|  |         Genre("Josei", "15"), | ||||||
|  |         Genre("Live action", "16"), | ||||||
|  |         Genre("Magic", "46"), | ||||||
|  |         Genre("manga", "55"), | ||||||
|  |         Genre("Manhua", "17"), | ||||||
|  |         Genre("Manhwa", "18"), | ||||||
|  |         Genre("Martial Arts", "19"), | ||||||
|  |         Genre("Mecha", "21"), | ||||||
|  |         Genre("Mystery", "22"), | ||||||
|  |         Genre("Nấu Ăn", "56"), | ||||||
|  |         Genre("Ngôn Tình", "65"), | ||||||
|  |         Genre("NTR", "62"), | ||||||
|  |         Genre("One shot", "23"), | ||||||
|  |         Genre("Psychological", "24"), | ||||||
|  |         Genre("Romance", "25"), | ||||||
|  |         Genre("School Life", "26"), | ||||||
|  |         Genre("Sci-fi", "27"), | ||||||
|  |         Genre("Seinen", "28"), | ||||||
|  |         Genre("Shoujo", "29"), | ||||||
|  |         Genre("Shoujo Ai", "30"), | ||||||
|  |         Genre("Shounen", "31"), | ||||||
|  |         Genre("Shounen Ai", "32"), | ||||||
|  |         Genre("Slice of life", "33"), | ||||||
|  |         Genre("Smut", "34"), | ||||||
|  |         Genre("Soft Yaoi", "35"), | ||||||
|  |         Genre("Soft Yuri", "36"), | ||||||
|  |         Genre("Sports", "37"), | ||||||
|  |         Genre("Supernatural", "38"), | ||||||
|  |         Genre("Tạp chí truyện tranh", "39"), | ||||||
|  |         Genre("Tragedy", "40"), | ||||||
|  |         Genre("Trap (Crossdressing)", "58"), | ||||||
|  |         Genre("Trinh Thám", "57"), | ||||||
|  |         Genre("Truyện scan", "41"), | ||||||
|  |         Genre("Tu chân - tu tiên", "66"), | ||||||
|  |         Genre("Video Clip", "53"), | ||||||
|  |         Genre("VnComic", "42"), | ||||||
|  |         Genre("Webtoon", "52"), | ||||||
|  |         Genre("Yuri", "59"), | ||||||
|  |     ) | ||||||
|  | } | ||||||
| After Width: | Height: | Size: 2.9 KiB | 
| After Width: | Height: | Size: 1.7 KiB | 
| After Width: | Height: | Size: 3.6 KiB | 
| After Width: | Height: | Size: 6.2 KiB | 
| After Width: | Height: | Size: 8.2 KiB | 
| @ -0,0 +1,56 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.vi.blogtruyenvn | ||||||
|  | 
 | ||||||
|  | import eu.kanade.tachiyomi.multisrc.blogtruyen.BlogTruyen | ||||||
|  | 
 | ||||||
|  | class BlogTruyenVn : BlogTruyen("BlogTruyen.vn (unoriginal)", "https://blogtruyenvn.com", "vi") { | ||||||
|  |     override fun getGenreList() = listOf( | ||||||
|  |         Genre("Action", "1"), | ||||||
|  |         Genre("Adventure", "3"), | ||||||
|  |         Genre("Comedy", "5"), | ||||||
|  |         Genre("Comic", "6"), | ||||||
|  |         Genre("Doujinshi", "7"), | ||||||
|  |         Genre("Drama", "49"), | ||||||
|  |         Genre("Ecchi", "48"), | ||||||
|  |         Genre("Event BT", "60"), | ||||||
|  |         Genre("Fantasy", "50"), | ||||||
|  |         Genre("Full màu", "64"), | ||||||
|  |         Genre("Game", "61"), | ||||||
|  |         Genre("Harem", "12"), | ||||||
|  |         Genre("Historical", "13"), | ||||||
|  |         Genre("Horror", "14"), | ||||||
|  |         Genre("Isekai/Dị giới/Trọng sinh", "63"), | ||||||
|  |         Genre("Josei", "15"), | ||||||
|  |         Genre("Live action", "16"), | ||||||
|  |         Genre("Magic", "46"), | ||||||
|  |         Genre("manga", "55"), | ||||||
|  |         Genre("Manhua", "17"), | ||||||
|  |         Genre("Manhwa", "18"), | ||||||
|  |         Genre("Martial Arts", "19"), | ||||||
|  |         Genre("Mecha", "21"), | ||||||
|  |         Genre("Mystery", "22"), | ||||||
|  |         Genre("Nấu Ăn", "56"), | ||||||
|  |         Genre("Ngôn Tình", "65"), | ||||||
|  |         Genre("NTR", "62"), | ||||||
|  |         Genre("One shot", "23"), | ||||||
|  |         Genre("Psychological", "24"), | ||||||
|  |         Genre("Romance", "25"), | ||||||
|  |         Genre("School Life", "26"), | ||||||
|  |         Genre("Sci-fi", "27"), | ||||||
|  |         Genre("Seinen", "28"), | ||||||
|  |         Genre("Shoujo", "29"), | ||||||
|  |         Genre("Shounen", "31"), | ||||||
|  |         Genre("Shounen Ai", "32"), | ||||||
|  |         Genre("Slice of life", "33"), | ||||||
|  |         Genre("Smut", "34"), | ||||||
|  |         Genre("Sports", "37"), | ||||||
|  |         Genre("Supernatural", "38"), | ||||||
|  |         Genre("Tạp chí truyện tranh", "39"), | ||||||
|  |         Genre("Tragedy", "40"), | ||||||
|  |         Genre("Trinh Thám", "57"), | ||||||
|  |         Genre("Truyện scan", "41"), | ||||||
|  |         Genre("Tu chân - tu tiên", "66"), | ||||||
|  |         Genre("Video Clip", "53"), | ||||||
|  |         Genre("VnComic", "42"), | ||||||
|  |         Genre("Webtoon", "52"), | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @ -1,7 +1,7 @@ | |||||||
| <?xml version="1.0" encoding="utf-8"?> | <?xml version="1.0" encoding="utf-8"?> | ||||||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||||||
|     <application> |     <application> | ||||||
|         <activity android:name=".vi.blogtruyen.BlogTruyenUrlActivity" |         <activity android:name="eu.kanade.tachiyomi.multisrc.blogtruyen.BlogTruyenUrlActivity" | ||||||
|             android:excludeFromRecents="true" |             android:excludeFromRecents="true" | ||||||
|             android:exported="true" |             android:exported="true" | ||||||
|             android:theme="@android:style/Theme.NoDisplay"> |             android:theme="@android:style/Theme.NoDisplay"> | ||||||
| @ -11,9 +11,9 @@ | |||||||
|                 <category android:name="android.intent.category.DEFAULT" /> |                 <category android:name="android.intent.category.DEFAULT" /> | ||||||
|                 <category android:name="android.intent.category.BROWSABLE" /> |                 <category android:name="android.intent.category.BROWSABLE" /> | ||||||
| 
 | 
 | ||||||
|                 <data android:host="blogtruyenmoi.com" /> |                 <data android:host="${SOURCEHOST}" /> | ||||||
|                 <data android:host="m.blogtruyenmoi.com" /> |                 <data android:host="m.${SOURCEHOST}" /> | ||||||
|                 <data android:scheme="https" /> |                 <data android:scheme="${SOURCESCHEME}" /> | ||||||
| 
 | 
 | ||||||
|                 <data android:pathPattern="/tac-gia/..*" /> |                 <data android:pathPattern="/tac-gia/..*" /> | ||||||
|                 <data android:pathPattern="/nhom-dich/..*" /> |                 <data android:pathPattern="/nhom-dich/..*" /> | ||||||
| @ -0,0 +1,386 @@ | |||||||
|  | package eu.kanade.tachiyomi.multisrc.blogtruyen | ||||||
|  | 
 | ||||||
|  | import eu.kanade.tachiyomi.network.GET | ||||||
|  | import eu.kanade.tachiyomi.network.POST | ||||||
|  | import eu.kanade.tachiyomi.source.model.Filter | ||||||
|  | import eu.kanade.tachiyomi.source.model.FilterList | ||||||
|  | import eu.kanade.tachiyomi.source.model.MangasPage | ||||||
|  | import eu.kanade.tachiyomi.source.model.Page | ||||||
|  | import eu.kanade.tachiyomi.source.model.SChapter | ||||||
|  | import eu.kanade.tachiyomi.source.model.SManga | ||||||
|  | import eu.kanade.tachiyomi.source.online.ParsedHttpSource | ||||||
|  | import eu.kanade.tachiyomi.util.asJsoup | ||||||
|  | import kotlinx.serialization.json.Json | ||||||
|  | import kotlinx.serialization.json.jsonArray | ||||||
|  | import kotlinx.serialization.json.jsonObject | ||||||
|  | import kotlinx.serialization.json.jsonPrimitive | ||||||
|  | import okhttp3.FormBody | ||||||
|  | import okhttp3.HttpUrl.Companion.toHttpUrl | ||||||
|  | import okhttp3.Request | ||||||
|  | import okhttp3.Response | ||||||
|  | import org.jsoup.nodes.Document | ||||||
|  | import org.jsoup.nodes.Element | ||||||
|  | import rx.Observable | ||||||
|  | import rx.Single | ||||||
|  | import rx.schedulers.Schedulers | ||||||
|  | import uy.kohesive.injekt.injectLazy | ||||||
|  | import java.text.SimpleDateFormat | ||||||
|  | import java.util.Locale | ||||||
|  | import java.util.TimeZone | ||||||
|  | 
 | ||||||
|  | abstract class BlogTruyen( | ||||||
|  |     override val name: String, | ||||||
|  |     override val baseUrl: String, | ||||||
|  |     override val lang: String, | ||||||
|  | ) : ParsedHttpSource() { | ||||||
|  | 
 | ||||||
|  |     override val supportsLatest = true | ||||||
|  | 
 | ||||||
|  |     override fun headersBuilder() = super.headersBuilder() | ||||||
|  |         .add("Referer", "$baseUrl/") | ||||||
|  | 
 | ||||||
|  |     private val json: Json by injectLazy() | ||||||
|  | 
 | ||||||
|  |     private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.US).apply { | ||||||
|  |         timeZone = TimeZone.getTimeZone("Asia/Ho_Chi_Minh") | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun popularMangaRequest(page: Int) = | ||||||
|  |         GET("$baseUrl/ajax/Search/AjaxLoadListManga?key=tatca&orderBy=3&p=$page", headers) | ||||||
|  | 
 | ||||||
|  |     override fun popularMangaParse(response: Response): MangasPage { | ||||||
|  |         val document = response.asJsoup() | ||||||
|  | 
 | ||||||
|  |         val manga = document.select(popularMangaSelector()).map { | ||||||
|  |             val tiptip = it.attr("data-tiptip") | ||||||
|  | 
 | ||||||
|  |             popularMangaFromElement(it, document.getElementById(tiptip)!!) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         val hasNextPage = document.selectFirst(popularMangaNextPageSelector()) != null | ||||||
|  | 
 | ||||||
|  |         return MangasPage(manga, hasNextPage) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun popularMangaSelector() = ".list .tiptip" | ||||||
|  | 
 | ||||||
|  |     override fun popularMangaFromElement(element: Element) = throw UnsupportedOperationException() | ||||||
|  | 
 | ||||||
|  |     private fun popularMangaFromElement(element: Element, tiptip: Element) = SManga.create().apply { | ||||||
|  |         val anchor = element.selectFirst("a")!! | ||||||
|  | 
 | ||||||
|  |         setUrlWithoutDomain(anchor.attr("href")) | ||||||
|  |         title = anchor.text() | ||||||
|  |         thumbnail_url = tiptip.selectFirst("img")?.absUrl("src") | ||||||
|  |         description = tiptip.selectFirst(".al-j")?.text() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun popularMangaNextPageSelector() = ".paging:last-child:not(.current_page)" | ||||||
|  | 
 | ||||||
|  |     override fun latestUpdatesRequest(page: Int): Request = | ||||||
|  |         GET(baseUrl + if (page > 1) "/page-$page" else "", headers) | ||||||
|  | 
 | ||||||
|  |     override fun latestUpdatesSelector() = ".storyitem .fl-l" | ||||||
|  | 
 | ||||||
|  |     override fun latestUpdatesFromElement(element: Element): SManga = SManga.create().apply { | ||||||
|  |         val anchor = element.selectFirst("a")!! | ||||||
|  | 
 | ||||||
|  |         setUrlWithoutDomain(anchor.absUrl("href")) | ||||||
|  |         title = anchor.attr("title") | ||||||
|  |         thumbnail_url = element.selectFirst("img")?.absUrl("src") | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun latestUpdatesNextPageSelector() = "select.slcPaging option:last-child:not([selected])" | ||||||
|  | 
 | ||||||
|  |     override fun fetchSearchManga( | ||||||
|  |         page: Int, | ||||||
|  |         query: String, | ||||||
|  |         filters: FilterList, | ||||||
|  |     ): Observable<MangasPage> = when { | ||||||
|  |         query.startsWith(PREFIX_ID_SEARCH) -> { | ||||||
|  |             var id = query.removePrefix(PREFIX_ID_SEARCH).trimStart() | ||||||
|  | 
 | ||||||
|  |             // it's a chapter, resolve to manga ID | ||||||
|  |             if (id.startsWith("c")) { | ||||||
|  |                 val document = client.newCall(GET("$baseUrl/$id", headers)).execute().asJsoup() | ||||||
|  | 
 | ||||||
|  |                 id = document.selectFirst(".breadcrumbs a:last-child")!!.attr("href").removePrefix("/") | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             fetchMangaDetails( | ||||||
|  |                 SManga.create().apply { | ||||||
|  |                     url = "/$id" | ||||||
|  |                 }, | ||||||
|  |             ) | ||||||
|  |                 .map { MangasPage(listOf(it), false) } | ||||||
|  |         } | ||||||
|  |         else -> super.fetchSearchManga(page, query, filters) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { | ||||||
|  |         ajaxSearchUrls.keys | ||||||
|  |             .firstOrNull { query.startsWith(it) } | ||||||
|  |             ?.let { | ||||||
|  |                 val id = extractIdFromQuery(it, query) | ||||||
|  |                 val url = "$baseUrl/ajax/${ajaxSearchUrls[it]!!}".toHttpUrl().newBuilder() | ||||||
|  |                     .addQueryParameter("id", id) | ||||||
|  |                     .addQueryParameter("p", page.toString()) | ||||||
|  |                     .build() | ||||||
|  | 
 | ||||||
|  |                 return GET(url, headers) | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |         val url = "$baseUrl/timkiem/nangcao/1".toHttpUrl().newBuilder().apply { | ||||||
|  |             if (query.isNotBlank()) { | ||||||
|  |                 addQueryParameter("txt", query) | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if (page > 1) { | ||||||
|  |                 addQueryParameter("p", page.toString()) | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             val inclGenres = mutableListOf<String>() | ||||||
|  |             val exclGenres = mutableListOf<String>() | ||||||
|  |             var status = 0 | ||||||
|  | 
 | ||||||
|  |             (if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> | ||||||
|  |                 when (filter) { | ||||||
|  |                     is GenreList -> filter.state.forEach { | ||||||
|  |                         when (it.state) { | ||||||
|  |                             Filter.TriState.STATE_INCLUDE -> inclGenres.add(it.id) | ||||||
|  |                             Filter.TriState.STATE_EXCLUDE -> exclGenres.add(it.id) | ||||||
|  |                             else -> {} | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     is Author -> { | ||||||
|  |                         addQueryParameter("aut", filter.state) | ||||||
|  |                     } | ||||||
|  |                     is Scanlator -> { | ||||||
|  |                         addQueryParameter("gr", filter.state) | ||||||
|  |                     } | ||||||
|  |                     is Status -> { | ||||||
|  |                         status = filter.state | ||||||
|  |                     } | ||||||
|  |                     else -> {} | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             addPathSegment(status.toString()) | ||||||
|  |             addPathSegment(inclGenres.joinToString(",").ifEmpty { "-1" }) | ||||||
|  |             addPathSegment(exclGenres.joinToString(",").ifEmpty { "-1" }) | ||||||
|  |         }.build() | ||||||
|  | 
 | ||||||
|  |         return GET(url, headers) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaParse(response: Response): MangasPage { | ||||||
|  |         val document = response.asJsoup() | ||||||
|  | 
 | ||||||
|  |         val manga = document.select(searchMangaSelector()).map { | ||||||
|  |             val tiptip = it.attr("data-tiptip") | ||||||
|  | 
 | ||||||
|  |             searchMangaFromElement(it, document.getElementById(tiptip)!!) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         val hasNextPage = document.selectFirst(searchMangaNextPageSelector()) != null | ||||||
|  | 
 | ||||||
|  |         return MangasPage(manga, hasNextPage) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaSelector() = popularMangaSelector() | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaFromElement(element: Element): SManga = | ||||||
|  |         throw UnsupportedOperationException() | ||||||
|  | 
 | ||||||
|  |     private fun searchMangaFromElement(element: Element, tiptip: Element) = | ||||||
|  |         popularMangaFromElement(element, tiptip) | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaNextPageSelector() = ".pagination .glyphicon-step-forward" | ||||||
|  | 
 | ||||||
|  |     override fun mangaDetailsParse(document: Document) = SManga.create().apply { | ||||||
|  |         val anchor = document.selectFirst(".entry-title a")!! | ||||||
|  |         val descriptionBlock = document.selectFirst("div.description")!! | ||||||
|  | 
 | ||||||
|  |         setUrlWithoutDomain(anchor.absUrl("href")) | ||||||
|  |         title = getMangaTitle(document) | ||||||
|  |         thumbnail_url = document.selectFirst(".thumbnail img")?.absUrl("src") | ||||||
|  |         author = descriptionBlock.select("p:contains(Tác giả) a").joinToString { it.text() } | ||||||
|  |         genre = descriptionBlock.select("span.category").joinToString { it.text() } | ||||||
|  |         status = when (descriptionBlock.selectFirst("p:contains(Trạng thái) span.color-red")?.text()) { | ||||||
|  |             "Đang tiến hành" -> SManga.ONGOING | ||||||
|  |             "Đã hoàn thành" -> SManga.COMPLETED | ||||||
|  |             "Tạm ngưng" -> SManga.ON_HIATUS | ||||||
|  |             else -> SManga.UNKNOWN | ||||||
|  |         } | ||||||
|  |         description = buildString { | ||||||
|  |             document.selectFirst(".manga-detail .detail .content")?.let { | ||||||
|  |                 // replace the facebook blockquote in synopsis with the link (if there is one) | ||||||
|  |                 it.selectFirst(".fb-page, .fb-group")?.let { fb -> | ||||||
|  |                     val link = fb.attr("data-href") | ||||||
|  |                     val node = document.createElement("p") | ||||||
|  | 
 | ||||||
|  |                     node.appendText(link) | ||||||
|  |                     fb.replaceWith(node) | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 appendLine(it.textWithNewlines().trim()) | ||||||
|  |                 appendLine() | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             descriptionBlock.select("p:not(:contains(Thể loại)):not(:contains(Tác giả))") | ||||||
|  |                 .forEach { e -> | ||||||
|  |                     val text = e.text() | ||||||
|  | 
 | ||||||
|  |                     if (text.isBlank()) { | ||||||
|  |                         return@forEach | ||||||
|  |                     } | ||||||
|  | 
 | ||||||
|  |                     // Uploader and status share the same <p> | ||||||
|  |                     if (text.contains("Trạng thái")) { | ||||||
|  |                         appendLine(text.substringBefore("Trạng thái").trim()) | ||||||
|  |                         return@forEach | ||||||
|  |                     } | ||||||
|  | 
 | ||||||
|  |                     // "Source", "Updaters" and "Scanlators" use badges with links | ||||||
|  |                     if (text.contains("Nguồn") || | ||||||
|  |                         text.contains("Tham gia update") || | ||||||
|  |                         text.contains("Nhóm dịch") | ||||||
|  |                     ) { | ||||||
|  |                         val key = text.substringBefore(":") | ||||||
|  |                         val value = e.select("a").joinToString { el -> el.text() } | ||||||
|  |                         appendLine("$key: $value") | ||||||
|  |                         return@forEach | ||||||
|  |                     } | ||||||
|  | 
 | ||||||
|  |                     // Generic paragraphs i.e. view count and follower count for this series | ||||||
|  |                     // Basically the same trick as [Element.textWithNewlines], just applied to | ||||||
|  |                     // different elements. | ||||||
|  |                     e.select("a, span").append("\\n") | ||||||
|  |                     appendLine( | ||||||
|  |                         e.text() | ||||||
|  |                             .replace("\\n", "\n") | ||||||
|  |                             .replace("\n ", "\n") | ||||||
|  |                             .trim(), | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |         }.trim() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun chapterListParse(response: Response): List<SChapter> { | ||||||
|  |         val document = response.asJsoup() | ||||||
|  |         val title = getMangaTitle(document) | ||||||
|  |         return document.select(chapterListSelector()).map { chapterFromElement(it, title) } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun chapterListSelector() = "div.list-wrap > p" | ||||||
|  | 
 | ||||||
|  |     override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException() | ||||||
|  | 
 | ||||||
|  |     private fun chapterFromElement(element: Element, title: String): SChapter = SChapter.create().apply { | ||||||
|  |         val anchor = element.select("span > a").first()!! | ||||||
|  | 
 | ||||||
|  |         setUrlWithoutDomain(anchor.attr("href")) | ||||||
|  |         name = anchor.text().removePrefix("$title ") | ||||||
|  |         date_upload = runCatching { | ||||||
|  |             dateFormat.parse( | ||||||
|  |                 element.selectFirst("span.publishedDate")!!.text(), | ||||||
|  |             )!!.time | ||||||
|  |         }.getOrDefault(0L) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun pageListParse(document: Document): List<Page> { | ||||||
|  |         val pages = mutableListOf<Page>() | ||||||
|  | 
 | ||||||
|  |         document.select("#content > img").forEachIndexed { i, e -> | ||||||
|  |             pages.add(Page(i, imageUrl = e.absUrl("src"))) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Some chapters use js script to render images | ||||||
|  |         document.select("#content > script:containsData(listImageCaption)").lastOrNull() | ||||||
|  |             ?.let { script -> | ||||||
|  |                 val imagesStr = script.data().substringBefore(";").substringAfterLast("=").trim() | ||||||
|  |                 val imageArr = json.parseToJsonElement(imagesStr).jsonArray | ||||||
|  |                 imageArr.forEach { | ||||||
|  |                     val imageUrl = it.jsonObject["url"]!!.jsonPrimitive.content | ||||||
|  |                     pages.add(Page(pages.size, imageUrl = imageUrl)) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |         runCatching { countView(document) } | ||||||
|  |         return pages | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() | ||||||
|  | 
 | ||||||
|  |     override fun getFilterList(): FilterList { | ||||||
|  |         val filters = mutableListOf<Filter<*>>( | ||||||
|  |             Author(), | ||||||
|  |             Scanlator(), | ||||||
|  |             Status(), | ||||||
|  |         ) | ||||||
|  |         val genres = getGenreList() | ||||||
|  | 
 | ||||||
|  |         if (genres.isNotEmpty()) { | ||||||
|  |             filters.add(GenreList(genres)) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return FilterList(filters) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // copy([...document.querySelectorAll(".CategoryFilter li")].map((e) => `Genre("${e.textContent.trim()}", "${e.dataset.id}"),`).join("\n")) | ||||||
|  |     open fun getGenreList(): List<Genre> = emptyList() | ||||||
|  | 
 | ||||||
|  |     private class Status : Filter.Select<String>( | ||||||
|  |         "Status", | ||||||
|  |         arrayOf("Sao cũng được", "Đang tiến hành", "Đã hoàn thành", "Tạm ngưng"), | ||||||
|  |     ) | ||||||
|  |     private class Author : Filter.Text("Tác giả") | ||||||
|  |     private class Scanlator : Filter.Text("Nhóm dịch") | ||||||
|  |     class Genre(name: String, val id: String) : Filter.TriState(name) | ||||||
|  |     private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Thể loại", genres) | ||||||
|  | 
 | ||||||
|  |     private fun getMangaTitle(document: Document) = | ||||||
|  |         document | ||||||
|  |             .selectFirst(".entry-title a")!! | ||||||
|  |             .attr("title") | ||||||
|  |             .removePrefix("truyện tranh ") | ||||||
|  | 
 | ||||||
|  |     private fun Element.textWithNewlines() = run { | ||||||
|  |         select("p, br").prepend("\\n") | ||||||
|  |         text().replace("\\n", "\n").replace("\n ", "\n") | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun extractIdFromQuery(prefix: String, query: String): String = | ||||||
|  |         query.substringAfter(prefix).trimStart().substringAfterLast("-") | ||||||
|  | 
 | ||||||
|  |     private fun countView(document: Document) { | ||||||
|  |         val mangaId = document.getElementById("MangaId")!!.attr("value") | ||||||
|  |         val chapterId = document.getElementById("ChapterId")!!.attr("value") | ||||||
|  |         val request = POST( | ||||||
|  |             "$baseUrl/Chapter/UpdateView", | ||||||
|  |             headers, | ||||||
|  |             FormBody.Builder() | ||||||
|  |                 .add("mangaId", mangaId) | ||||||
|  |                 .add("chapterId", chapterId) | ||||||
|  |                 .build(), | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         Single.fromCallable { | ||||||
|  |             client.newCall(request).execute().close() | ||||||
|  |         } | ||||||
|  |             .subscribeOn(Schedulers.io()) | ||||||
|  |             .observeOn(Schedulers.io()) | ||||||
|  |             .subscribe() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private val ajaxSearchUrls: Map<String, String> = mapOf( | ||||||
|  |         PREFIX_AUTHOR_SEARCH to "Author/AjaxLoadMangaByAuthor?orderBy=3", | ||||||
|  |         PREFIX_TEAM_SEARCH to "TranslateTeam/AjaxLoadMangaByTranslateTeam", | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         internal const val PREFIX_ID_SEARCH = "id:" | ||||||
|  |         internal const val PREFIX_AUTHOR_SEARCH = "author:" | ||||||
|  |         internal const val PREFIX_TEAM_SEARCH = "team:" | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -0,0 +1,25 @@ | |||||||
|  | package eu.kanade.tachiyomi.multisrc.blogtruyen | ||||||
|  | 
 | ||||||
|  | import generator.ThemeSourceData.SingleLang | ||||||
|  | import generator.ThemeSourceGenerator | ||||||
|  | 
 | ||||||
|  | class BlogTruyenGenerator : ThemeSourceGenerator { | ||||||
|  | 
 | ||||||
|  |     override val themePkg = "blogtruyen" | ||||||
|  | 
 | ||||||
|  |     override val themeClass = "BlogTruyen" | ||||||
|  | 
 | ||||||
|  |     override val baseVersionCode = 1 | ||||||
|  | 
 | ||||||
|  |     override val sources = listOf( | ||||||
|  |         SingleLang("BlogTruyen", "https://blogtruyenmoi.com", "vi", className = "BlogTruyenMoi", pkgName = "blogtruyen", overrideVersionCode = 17), | ||||||
|  |         SingleLang("BlogTruyen.vn (unoriginal)", "https://blogtruyenvn.com", "vi", className = "BlogTruyenVn"), | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         @JvmStatic | ||||||
|  |         fun main(args: Array<String>) { | ||||||
|  |             BlogTruyenGenerator().createAll() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -1,4 +1,4 @@ | |||||||
| package eu.kanade.tachiyomi.extension.vi.blogtruyen | package eu.kanade.tachiyomi.multisrc.blogtruyen | ||||||
| 
 | 
 | ||||||
| import android.app.Activity | import android.app.Activity | ||||||
| import android.content.ActivityNotFoundException | import android.content.ActivityNotFoundException | ||||||
| @ -1,8 +0,0 @@ | |||||||
| ext { |  | ||||||
|     extName = 'BlogTruyen' |  | ||||||
|     extClass = '.BlogTruyen' |  | ||||||
|     extVersionCode = 17 |  | ||||||
|     isNsfw = true |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| apply from: "$rootDir/common.gradle" |  | ||||||
| @ -1,432 +0,0 @@ | |||||||
| package eu.kanade.tachiyomi.extension.vi.blogtruyen |  | ||||||
| 
 |  | ||||||
| import eu.kanade.tachiyomi.network.GET |  | ||||||
| import eu.kanade.tachiyomi.network.POST |  | ||||||
| import eu.kanade.tachiyomi.network.interceptor.rateLimit |  | ||||||
| import eu.kanade.tachiyomi.source.model.Filter |  | ||||||
| import eu.kanade.tachiyomi.source.model.FilterList |  | ||||||
| import eu.kanade.tachiyomi.source.model.MangasPage |  | ||||||
| import eu.kanade.tachiyomi.source.model.Page |  | ||||||
| import eu.kanade.tachiyomi.source.model.SChapter |  | ||||||
| import eu.kanade.tachiyomi.source.model.SManga |  | ||||||
| import eu.kanade.tachiyomi.source.online.ParsedHttpSource |  | ||||||
| import eu.kanade.tachiyomi.util.asJsoup |  | ||||||
| import kotlinx.serialization.json.Json |  | ||||||
| import kotlinx.serialization.json.jsonArray |  | ||||||
| import kotlinx.serialization.json.jsonObject |  | ||||||
| import kotlinx.serialization.json.jsonPrimitive |  | ||||||
| import okhttp3.FormBody |  | ||||||
| import okhttp3.Headers |  | ||||||
| import okhttp3.HttpUrl.Companion.toHttpUrl |  | ||||||
| import okhttp3.OkHttpClient |  | ||||||
| import okhttp3.Request |  | ||||||
| import okhttp3.Response |  | ||||||
| import org.jsoup.nodes.Document |  | ||||||
| import org.jsoup.nodes.Element |  | ||||||
| import rx.Observable |  | ||||||
| import uy.kohesive.injekt.injectLazy |  | ||||||
| import java.text.SimpleDateFormat |  | ||||||
| import java.util.Locale |  | ||||||
| 
 |  | ||||||
| class BlogTruyen : ParsedHttpSource() { |  | ||||||
| 
 |  | ||||||
|     override val name = "BlogTruyen" |  | ||||||
| 
 |  | ||||||
|     override val baseUrl = "https://blogtruyenmoi.com" |  | ||||||
| 
 |  | ||||||
|     override val lang = "vi" |  | ||||||
| 
 |  | ||||||
|     override val supportsLatest = true |  | ||||||
| 
 |  | ||||||
|     override val client: OkHttpClient = network.cloudflareClient.newBuilder() |  | ||||||
|         .rateLimit(1) |  | ||||||
|         .build() |  | ||||||
| 
 |  | ||||||
|     private val json: Json by injectLazy() |  | ||||||
| 
 |  | ||||||
|     private val dateFormat: SimpleDateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.ENGLISH) |  | ||||||
| 
 |  | ||||||
|     companion object { |  | ||||||
|         const val PREFIX_ID_SEARCH = "id:" |  | ||||||
|         const val PREFIX_AUTHOR_SEARCH = "author:" |  | ||||||
|         const val PREFIX_TEAM_SEARCH = "team:" |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     override fun headersBuilder(): Headers.Builder = |  | ||||||
|         super.headersBuilder().add("Referer", "$baseUrl/") |  | ||||||
| 
 |  | ||||||
|     override fun popularMangaRequest(page: Int): Request = |  | ||||||
|         GET("$baseUrl/ajax/Search/AjaxLoadListManga?key=tatca&orderBy=3&p=$page", headers) |  | ||||||
| 
 |  | ||||||
|     override fun popularMangaParse(response: Response): MangasPage { |  | ||||||
|         val document = response.asJsoup() |  | ||||||
| 
 |  | ||||||
|         val manga = document.select(popularMangaSelector()).map { |  | ||||||
|             val tiptip = it.attr("data-tiptip") |  | ||||||
|             popularMangaFromElement(it, document.getElementById(tiptip)!!) |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         val hasNextPage = document.selectFirst(popularMangaNextPageSelector()) != null |  | ||||||
| 
 |  | ||||||
|         return MangasPage(manga, hasNextPage) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     override fun popularMangaSelector() = ".list .tiptip" |  | ||||||
| 
 |  | ||||||
|     override fun popularMangaFromElement(element: Element): SManga = |  | ||||||
|         throw UnsupportedOperationException() |  | ||||||
| 
 |  | ||||||
|     private fun popularMangaFromElement(element: Element, tiptip: Element) = SManga.create().apply { |  | ||||||
|         val anchor = element.selectFirst("a")!! |  | ||||||
|         setUrlWithoutDomain(anchor.attr("href")) |  | ||||||
|         title = anchor.attr("title").replace("truyện tranh ", "").trim() |  | ||||||
| 
 |  | ||||||
|         thumbnail_url = tiptip.selectFirst("img")!!.attr("abs:src") |  | ||||||
|         description = tiptip.selectFirst(".al-j")!!.text() |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     override fun popularMangaNextPageSelector() = ".paging:last-child:not(.current_page)" |  | ||||||
| 
 |  | ||||||
|     override fun latestUpdatesRequest(page: Int): Request = |  | ||||||
|         GET(baseUrl + if (page != 1) "/page-$page" else "", headers) |  | ||||||
| 
 |  | ||||||
|     override fun latestUpdatesSelector() = ".storyitem .fl-l" |  | ||||||
| 
 |  | ||||||
|     override fun latestUpdatesFromElement(element: Element): SManga = SManga.create().apply { |  | ||||||
|         setUrlWithoutDomain(element.select("a").attr("href")) |  | ||||||
|         title = element.select("a").attr("title") |  | ||||||
|         thumbnail_url = element.select("img").attr("abs:src") |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     override fun latestUpdatesNextPageSelector() = "select.slcPaging option:last-child:not([selected])" |  | ||||||
| 
 |  | ||||||
|     override fun fetchSearchManga( |  | ||||||
|         page: Int, |  | ||||||
|         query: String, |  | ||||||
|         filters: FilterList, |  | ||||||
|     ): Observable<MangasPage> { |  | ||||||
|         return when { |  | ||||||
|             query.startsWith(PREFIX_ID_SEARCH) -> { |  | ||||||
|                 var id = query.removePrefix(PREFIX_ID_SEARCH).trim() |  | ||||||
| 
 |  | ||||||
|                 // it's a chapter, resolve to manga ID |  | ||||||
|                 if (id.startsWith("c")) { |  | ||||||
|                     val document = client.newCall(GET("$baseUrl/$id", headers)).execute().asJsoup() |  | ||||||
|                     id = document.selectFirst(".breadcrumbs a:last-child")!!.attr("href").removePrefix("/") |  | ||||||
|                 } |  | ||||||
| 
 |  | ||||||
|                 fetchMangaDetails( |  | ||||||
|                     SManga.create().apply { |  | ||||||
|                         url = "/$id" |  | ||||||
|                     }, |  | ||||||
|                 ) |  | ||||||
|                     .map { MangasPage(listOf(it.apply { url = "/$id" }), false) } |  | ||||||
|             } |  | ||||||
|             else -> super.fetchSearchManga(page, query, filters) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private fun extractIdFromQuery(prefix: String, query: String): String { |  | ||||||
|         val q = query.substringAfter(prefix).trim() |  | ||||||
|         return if (q.contains("-")) { |  | ||||||
|             q.substringAfterLast("-") |  | ||||||
|         } else { |  | ||||||
|             q |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private val ajaxSearchUrls: Map<String, String> = mapOf( |  | ||||||
|         PREFIX_AUTHOR_SEARCH to "Author/AjaxLoadMangaByAuthor?orderBy=3", |  | ||||||
|         PREFIX_TEAM_SEARCH to "TranslateTeam/AjaxLoadMangaByTranslateTeam", |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { |  | ||||||
|         ajaxSearchUrls.keys.forEach { |  | ||||||
|             if (!query.startsWith(it)) { |  | ||||||
|                 return@forEach |  | ||||||
|             } |  | ||||||
|             val id = extractIdFromQuery(it, query) |  | ||||||
|             val url = "$baseUrl/ajax/${ajaxSearchUrls[it]}".toHttpUrl().newBuilder() |  | ||||||
|                 .addQueryParameter("id", id) |  | ||||||
|                 .addQueryParameter("p", page.toString()) |  | ||||||
|                 .build() |  | ||||||
|                 .toString() |  | ||||||
|             return GET(url, headers) |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         val url = "$baseUrl/timkiem/nangcao/1".toHttpUrl().newBuilder().apply { |  | ||||||
|             addQueryParameter("txt", query) |  | ||||||
|             addQueryParameter("p", page.toString()) |  | ||||||
| 
 |  | ||||||
|             val genres = mutableListOf<Int>() |  | ||||||
|             val genresEx = mutableListOf<Int>() |  | ||||||
|             var status = 0 |  | ||||||
|             (if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> |  | ||||||
|                 when (filter) { |  | ||||||
|                     is GenreList -> filter.state.forEach { |  | ||||||
|                         when (it.state) { |  | ||||||
|                             Filter.TriState.STATE_INCLUDE -> genres.add(it.id) |  | ||||||
|                             Filter.TriState.STATE_EXCLUDE -> genresEx.add(it.id) |  | ||||||
|                             else -> {} |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                     is Author -> { |  | ||||||
|                         addQueryParameter("aut", filter.state) |  | ||||||
|                     } |  | ||||||
|                     is Scanlator -> { |  | ||||||
|                         addQueryParameter("gr", filter.state) |  | ||||||
|                     } |  | ||||||
|                     is Status -> { |  | ||||||
|                         status = filter.state |  | ||||||
|                     } |  | ||||||
|                     else -> {} |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             addPathSegment(status.toString()) |  | ||||||
|             addPathSegment(genres.joinToString(",")) |  | ||||||
|             addPathSegment(genresEx.joinToString(",")) |  | ||||||
|         }.build().toString() |  | ||||||
|         return GET(url, headers) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     override fun searchMangaParse(response: Response): MangasPage { |  | ||||||
|         val document = response.asJsoup() |  | ||||||
| 
 |  | ||||||
|         val manga = document.select(searchMangaSelector()).map { |  | ||||||
|             val tiptip = it.attr("data-tiptip") |  | ||||||
|             searchMangaFromElement(it, document.getElementById(tiptip)!!) |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         val hasNextPage = document.selectFirst(searchMangaNextPageSelector()) != null |  | ||||||
| 
 |  | ||||||
|         return MangasPage(manga, hasNextPage) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     override fun searchMangaSelector() = popularMangaSelector() |  | ||||||
| 
 |  | ||||||
|     override fun searchMangaFromElement(element: Element): SManga = |  | ||||||
|         throw UnsupportedOperationException() |  | ||||||
| 
 |  | ||||||
|     private fun searchMangaFromElement(element: Element, tiptip: Element) = |  | ||||||
|         popularMangaFromElement(element, tiptip) |  | ||||||
| 
 |  | ||||||
|     override fun searchMangaNextPageSelector() = ".pagination .glyphicon-step-forward" |  | ||||||
| 
 |  | ||||||
|     private fun getMangaTitle(document: Document) = document.selectFirst(".entry-title a")!! |  | ||||||
|         .attr("title") |  | ||||||
|         .replaceFirst("truyện tranh", "", false) |  | ||||||
|         .trim() |  | ||||||
| 
 |  | ||||||
|     override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { |  | ||||||
|         val anchor = document.selectFirst(".entry-title a")!! |  | ||||||
|         setUrlWithoutDomain(anchor.attr("href")) |  | ||||||
|         title = getMangaTitle(document) |  | ||||||
| 
 |  | ||||||
|         thumbnail_url = document.select(".thumbnail img").attr("abs:src") |  | ||||||
|         author = document.select("a[href*=tac-gia]").joinToString { it.text() } |  | ||||||
|         genre = document.select("span.category a").joinToString { it.text() } |  | ||||||
|         status = parseStatus( |  | ||||||
|             document.select("span.color-red:not(.bold)").text(), |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         description = StringBuilder().apply { |  | ||||||
|             // the actual synopsis |  | ||||||
|             val synopsisBlock = document.selectFirst(".manga-detail .detail .content")!! |  | ||||||
| 
 |  | ||||||
|             // replace the facebook blockquote in synopsis with the link (if there is one) |  | ||||||
|             val fbElement = synopsisBlock.selectFirst(".fb-page, .fb-group") |  | ||||||
|             if (fbElement != null) { |  | ||||||
|                 val fbLink = fbElement.attr("data-href") |  | ||||||
| 
 |  | ||||||
|                 val node = document.createElement("p") |  | ||||||
|                 node.appendText(fbLink) |  | ||||||
| 
 |  | ||||||
|                 fbElement.replaceWith(node) |  | ||||||
|             } |  | ||||||
|             appendLine(synopsisBlock.textWithNewlines().trim()) |  | ||||||
|             appendLine() |  | ||||||
| 
 |  | ||||||
|             // other metadata |  | ||||||
|             document.select(".description p").forEach { |  | ||||||
|                 val text = it.text() |  | ||||||
|                 if (text.contains("Thể loại") || |  | ||||||
|                     text.contains("Tác giả") || |  | ||||||
|                     text.isBlank() |  | ||||||
|                 ) { |  | ||||||
|                     return@forEach |  | ||||||
|                 } |  | ||||||
| 
 |  | ||||||
|                 if (text.contains("Trạng thái")) { |  | ||||||
|                     appendLine(text.substringBefore("Trạng thái").trim()) |  | ||||||
|                     return@forEach |  | ||||||
|                 } |  | ||||||
| 
 |  | ||||||
|                 if (text.contains("Nguồn") || |  | ||||||
|                     text.contains("Tham gia update") || |  | ||||||
|                     text.contains("Nhóm dịch") |  | ||||||
|                 ) { |  | ||||||
|                     val key = text.substringBefore(":") |  | ||||||
|                     val value = it.select("a").joinToString { el -> el.text() } |  | ||||||
|                     appendLine("$key: $value") |  | ||||||
|                     return@forEach |  | ||||||
|                 } |  | ||||||
| 
 |  | ||||||
|                 it.select("a, span").append("\\n") |  | ||||||
|                 appendLine(it.text().replace("\\n", "\n").replace("\n ", "\n").trim()) |  | ||||||
|             } |  | ||||||
|         }.toString().trim() |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private fun Element.textWithNewlines() = run { |  | ||||||
|         select("p").prepend("\\n") |  | ||||||
|         select("br").prepend("\\n") |  | ||||||
|         text().replace("\\n", "\n").replace("\n ", "\n") |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private fun parseStatus(status: String) = when { |  | ||||||
|         status.contains("Đang tiến hành") -> SManga.ONGOING |  | ||||||
|         status.contains("Đã hoàn thành") -> SManga.COMPLETED |  | ||||||
|         status.contains("Tạm ngưng") -> SManga.ON_HIATUS |  | ||||||
|         else -> SManga.UNKNOWN |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     override fun chapterListParse(response: Response): List<SChapter> { |  | ||||||
|         val document = response.asJsoup() |  | ||||||
|         val title = getMangaTitle(document) |  | ||||||
|         return document.select(chapterListSelector()).map { chapterFromElement(it, title) } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     override fun chapterListSelector() = "div.list-wrap > p" |  | ||||||
| 
 |  | ||||||
|     override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException() |  | ||||||
| 
 |  | ||||||
|     private fun chapterFromElement(element: Element, title: String): SChapter = SChapter.create().apply { |  | ||||||
|         val anchor = element.select("span > a").first()!! |  | ||||||
| 
 |  | ||||||
|         setUrlWithoutDomain(anchor.attr("href")) |  | ||||||
|         name = anchor.attr("title").replace(title, "", true).trim() |  | ||||||
|         date_upload = runCatching { |  | ||||||
|             dateFormat.parse( |  | ||||||
|                 element.selectFirst("span.publishedDate")!!.text(), |  | ||||||
|             )?.time |  | ||||||
|         }.getOrNull() ?: 0L |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     private fun countViewRequest(mangaId: String, chapterId: String): Request = POST( |  | ||||||
|         "$baseUrl/Chapter/UpdateView", |  | ||||||
|         headers, |  | ||||||
|         FormBody.Builder() |  | ||||||
|             .add("mangaId", mangaId) |  | ||||||
|             .add("chapterId", chapterId) |  | ||||||
|             .build(), |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     private fun countView(document: Document) { |  | ||||||
|         val mangaId = document.getElementById("MangaId")!!.attr("value") |  | ||||||
|         val chapterId = document.getElementById("ChapterId")!!.attr("value") |  | ||||||
|         runCatching { |  | ||||||
|             client.newCall(countViewRequest(mangaId, chapterId)).execute().close() |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     override fun pageListParse(document: Document): List<Page> { |  | ||||||
|         val pages = mutableListOf<Page>() |  | ||||||
| 
 |  | ||||||
|         document.select("#content > img").forEachIndexed { i, e -> |  | ||||||
|             pages.add(Page(i, imageUrl = e.attr("abs:src"))) |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         // Some chapters use js script to render images |  | ||||||
|         document.select("#content > script:containsData(listImageCaption)").lastOrNull() |  | ||||||
|             ?.let { script -> |  | ||||||
|                 val imagesStr = script.data().substringBefore(";").substringAfterLast("=").trim() |  | ||||||
|                 val imageArr = json.parseToJsonElement(imagesStr).jsonArray |  | ||||||
|                 imageArr.forEach { |  | ||||||
|                     val imageUrl = it.jsonObject["url"]!!.jsonPrimitive.content |  | ||||||
|                     pages.add(Page(pages.size, imageUrl = imageUrl)) |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|         countView(document) |  | ||||||
|         return pages |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() |  | ||||||
| 
 |  | ||||||
|     private class Status : Filter.Select<String>( |  | ||||||
|         "Status", |  | ||||||
|         arrayOf("Sao cũng được", "Đang tiến hành", "Đã hoàn thành", "Tạm ngưng"), |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     private class Author : Filter.Text("Tác giả") |  | ||||||
|     private class Scanlator : Filter.Text("Nhóm dịch") |  | ||||||
|     private class Genre(name: String, val id: Int) : Filter.TriState(name) |  | ||||||
|     private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Thể loại", genres) |  | ||||||
| 
 |  | ||||||
|     override fun getFilterList() = FilterList( |  | ||||||
|         Author(), |  | ||||||
|         Scanlator(), |  | ||||||
|         Status(), |  | ||||||
|         GenreList(getGenreList()), |  | ||||||
|     ) |  | ||||||
| 
 |  | ||||||
|     private fun getGenreList() = listOf( |  | ||||||
|         Genre("16+", 54), |  | ||||||
|         Genre("18+", 45), |  | ||||||
|         Genre("Action", 1), |  | ||||||
|         Genre("Adult", 2), |  | ||||||
|         Genre("Adventure", 3), |  | ||||||
|         Genre("Anime", 4), |  | ||||||
|         Genre("Comedy", 5), |  | ||||||
|         Genre("Comic", 6), |  | ||||||
|         Genre("Doujinshi", 7), |  | ||||||
|         Genre("Drama", 49), |  | ||||||
|         Genre("Ecchi", 48), |  | ||||||
|         Genre("Even BT", 60), |  | ||||||
|         Genre("Fantasy", 50), |  | ||||||
|         Genre("Game", 61), |  | ||||||
|         Genre("Gender Bender", 51), |  | ||||||
|         Genre("Harem", 12), |  | ||||||
|         Genre("Historical", 13), |  | ||||||
|         Genre("Horror", 14), |  | ||||||
|         Genre("Isekai/Dị Giới", 63), |  | ||||||
|         Genre("Josei", 15), |  | ||||||
|         Genre("Live Action", 16), |  | ||||||
|         Genre("Magic", 46), |  | ||||||
|         Genre("Manga", 55), |  | ||||||
|         Genre("Manhua", 17), |  | ||||||
|         Genre("Manhwa", 18), |  | ||||||
|         Genre("Martial Arts", 19), |  | ||||||
|         Genre("Mature", 20), |  | ||||||
|         Genre("Mecha", 21), |  | ||||||
|         Genre("Mystery", 22), |  | ||||||
|         Genre("Nấu ăn", 56), |  | ||||||
|         Genre("NTR", 62), |  | ||||||
|         Genre("One shot", 23), |  | ||||||
|         Genre("Psychological", 24), |  | ||||||
|         Genre("Romance", 25), |  | ||||||
|         Genre("School Life", 26), |  | ||||||
|         Genre("Sci-fi", 27), |  | ||||||
|         Genre("Seinen", 28), |  | ||||||
|         Genre("Shoujo", 29), |  | ||||||
|         Genre("Shoujo Ai", 30), |  | ||||||
|         Genre("Shounen", 31), |  | ||||||
|         Genre("Shounen Ai", 32), |  | ||||||
|         Genre("Slice of Life", 33), |  | ||||||
|         Genre("Smut", 34), |  | ||||||
|         Genre("Soft Yaoi", 35), |  | ||||||
|         Genre("Soft Yuri", 36), |  | ||||||
|         Genre("Sports", 37), |  | ||||||
|         Genre("Supernatural", 38), |  | ||||||
|         Genre("Tạp chí truyện tranh", 39), |  | ||||||
|         Genre("Tragedy", 40), |  | ||||||
|         Genre("Trap", 58), |  | ||||||
|         Genre("Trinh thám", 57), |  | ||||||
|         Genre("Truyện scan", 41), |  | ||||||
|         Genre("Video clip", 53), |  | ||||||
|         Genre("VnComic", 42), |  | ||||||
|         Genre("Webtoon", 52), |  | ||||||
|         Genre("Yuri", 59), |  | ||||||
|     ) |  | ||||||
| } |  | ||||||
 beerpsi
						beerpsi