New multisrc theme: Liliana (#2413)
* new multisrc theme: liliana * dont specify type * suggestions * add raw1001
							
								
								
									
										5
									
								
								lib-multisrc/liliana/build.gradle.kts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,5 @@ | ||||
| plugins { | ||||
|     id("lib-multisrc") | ||||
| } | ||||
| 
 | ||||
| baseVersionCode = 1 | ||||
| @ -0,0 +1,90 @@ | ||||
| package eu.kanade.tachiyomi.multisrc.liliana | ||||
| 
 | ||||
| import eu.kanade.tachiyomi.source.model.Filter | ||||
| import okhttp3.HttpUrl | ||||
| 
 | ||||
| interface UrlPartFilter { | ||||
|     fun addUrlParameter(url: HttpUrl.Builder) | ||||
| } | ||||
| 
 | ||||
| abstract class SelectFilter( | ||||
|     name: String, | ||||
|     private val options: List<Pair<String, String>>, | ||||
|     private val urlParameter: String, | ||||
| ) : UrlPartFilter, Filter.Select<String>( | ||||
|     name, | ||||
|     options.map { it.first }.toTypedArray(), | ||||
| ) { | ||||
|     override fun addUrlParameter(url: HttpUrl.Builder) { | ||||
|         url.addQueryParameter(urlParameter, options[state].second) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| class TriStateFilter(name: String, val id: String) : Filter.TriState(name) | ||||
| 
 | ||||
| abstract class TriStateGroupFilter( | ||||
|     name: String, | ||||
|     options: List<Pair<String, String>>, | ||||
|     private val includeUrlParameter: String, | ||||
|     private val excludeUrlParameter: String, | ||||
| ) : UrlPartFilter, Filter.Group<TriStateFilter>( | ||||
|     name, | ||||
|     options.map { TriStateFilter(it.first, it.second) }, | ||||
| ) { | ||||
|     override fun addUrlParameter(url: HttpUrl.Builder) { | ||||
|         url.addQueryParameter( | ||||
|             includeUrlParameter, | ||||
|             state.filter { it.isIncluded() }.joinToString(",") { it.id }, | ||||
|         ) | ||||
|         url.addQueryParameter( | ||||
|             excludeUrlParameter, | ||||
|             state.filter { it.isExcluded() }.joinToString(",") { it.id }, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| class GenreFilter( | ||||
|     name: String, | ||||
|     options: List<Pair<String, String>>, | ||||
| ) : TriStateGroupFilter( | ||||
|     name, | ||||
|     options, | ||||
|     "genres", | ||||
|     "notGenres", | ||||
| ) | ||||
| 
 | ||||
| class ChapterCountFilter( | ||||
|     name: String, | ||||
|     options: List<Pair<String, String>>, | ||||
| ) : SelectFilter( | ||||
|     name, | ||||
|     options, | ||||
|     "chapter_count", | ||||
| ) | ||||
| 
 | ||||
| class StatusFilter( | ||||
|     name: String, | ||||
|     options: List<Pair<String, String>>, | ||||
| ) : SelectFilter( | ||||
|     name, | ||||
|     options, | ||||
|     "status", | ||||
| ) | ||||
| 
 | ||||
| class GenderFilter( | ||||
|     name: String, | ||||
|     options: List<Pair<String, String>>, | ||||
| ) : SelectFilter( | ||||
|     name, | ||||
|     options, | ||||
|     "sex", | ||||
| ) | ||||
| 
 | ||||
| class SortFilter( | ||||
|     name: String, | ||||
|     options: List<Pair<String, String>>, | ||||
| ) : SelectFilter( | ||||
|     name, | ||||
|     options, | ||||
|     "sort", | ||||
| ) | ||||
| @ -0,0 +1,353 @@ | ||||
| package eu.kanade.tachiyomi.multisrc.liliana | ||||
| 
 | ||||
| import android.util.Log | ||||
| import eu.kanade.tachiyomi.network.GET | ||||
| import eu.kanade.tachiyomi.network.POST | ||||
| import eu.kanade.tachiyomi.network.await | ||||
| 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.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.serialization.Serializable | ||||
| import kotlinx.serialization.decodeFromString | ||||
| import kotlinx.serialization.json.Json | ||||
| import okhttp3.FormBody | ||||
| import okhttp3.HttpUrl.Companion.toHttpUrl | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import org.jsoup.Jsoup | ||||
| import org.jsoup.nodes.Document | ||||
| import org.jsoup.nodes.Element | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.lang.Exception | ||||
| 
 | ||||
| abstract class Liliana( | ||||
|     override val name: String, | ||||
|     override val baseUrl: String, | ||||
|     final override val lang: String, | ||||
|     private val usesPostSearch: Boolean = false, | ||||
| ) : ParsedHttpSource() { | ||||
|     override val supportsLatest = true | ||||
| 
 | ||||
|     private val json: Json by injectLazy() | ||||
| 
 | ||||
|     override val client = network.cloudflareClient | ||||
| 
 | ||||
|     override fun headersBuilder() = super.headersBuilder() | ||||
|         .add("Referer", "$baseUrl/") | ||||
| 
 | ||||
|     // ============================== Popular =============================== | ||||
| 
 | ||||
|     override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/ranking/week/$page", headers) | ||||
| 
 | ||||
|     override fun popularMangaSelector(): String = "div#main div.grid > div" | ||||
| 
 | ||||
|     override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { | ||||
|         thumbnail_url = element.selectFirst("img")?.imgAttr() | ||||
|         with(element.selectFirst(".text-center a")!!) { | ||||
|             title = text() | ||||
|             setUrlWithoutDomain(attr("abs:href")) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun popularMangaNextPageSelector(): String = ".blog-pager > span.pagecurrent + span" | ||||
| 
 | ||||
|     // =============================== Latest =============================== | ||||
| 
 | ||||
|     override fun latestUpdatesRequest(page: Int): Request = | ||||
|         GET("$baseUrl/all-manga/$page/?sort=last_update&status=0", headers) | ||||
| 
 | ||||
|     override fun latestUpdatesParse(response: Response): MangasPage = | ||||
|         popularMangaParse(response) | ||||
| 
 | ||||
|     override fun latestUpdatesSelector(): String = | ||||
|         throw UnsupportedOperationException() | ||||
| 
 | ||||
|     override fun latestUpdatesFromElement(element: Element): SManga = | ||||
|         throw UnsupportedOperationException() | ||||
| 
 | ||||
|     override fun latestUpdatesNextPageSelector(): String = | ||||
|         throw UnsupportedOperationException() | ||||
| 
 | ||||
|     // =============================== Search =============================== | ||||
| 
 | ||||
|     override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { | ||||
|         if (query.isNotBlank() && usesPostSearch) { | ||||
|             val formBody = FormBody.Builder() | ||||
|                 .add("search", query) | ||||
|                 .build() | ||||
| 
 | ||||
|             val formHeaders = headersBuilder().apply { | ||||
|                 add("Accept", "application/json, text/javascript, */*; q=0.01") | ||||
|                 add("Host", baseUrl.toHttpUrl().host) | ||||
|                 add("Origin", baseUrl) | ||||
|                 add("X-Requested-With", "XMLHttpRequest") | ||||
|             }.build() | ||||
| 
 | ||||
|             return POST("$baseUrl/ajax/search", formHeaders, formBody) | ||||
|         } | ||||
| 
 | ||||
|         val url = baseUrl.toHttpUrl().newBuilder().apply { | ||||
|             if (query.isNotBlank()) { | ||||
|                 addPathSegment("search") | ||||
|                 addQueryParameter("keyword", query) | ||||
|             } else { | ||||
|                 addPathSegment("filter") | ||||
|                 filters.filterIsInstance<UrlPartFilter>().forEach { | ||||
|                     it.addUrlParameter(this) | ||||
|                 } | ||||
|             } | ||||
|             addPathSegment(page.toString()) | ||||
|             addPathSegment("") | ||||
|         }.build() | ||||
| 
 | ||||
|         return GET(url, headers) | ||||
|     } | ||||
| 
 | ||||
|     override fun searchMangaParse(response: Response): MangasPage { | ||||
|         if (response.request.method == "GET") { | ||||
|             return popularMangaParse(response) | ||||
|         } | ||||
| 
 | ||||
|         val mangaList = response.parseAs<SearchResponseDto>().list.map { manga -> | ||||
|             SManga.create().apply { | ||||
|                 setUrlWithoutDomain(manga.url) | ||||
|                 title = manga.name | ||||
|                 thumbnail_url = baseUrl + manga.cover | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return MangasPage(mangaList, false) | ||||
|     } | ||||
| 
 | ||||
|     @Serializable | ||||
|     class SearchResponseDto( | ||||
|         val list: List<MangaDto>, | ||||
|     ) { | ||||
|         @Serializable | ||||
|         class MangaDto( | ||||
|             val cover: String, | ||||
|             val name: String, | ||||
|             val url: String, | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     override fun searchMangaSelector(): String = | ||||
|         throw UnsupportedOperationException() | ||||
| 
 | ||||
|     override fun searchMangaFromElement(element: Element): SManga = | ||||
|         throw UnsupportedOperationException() | ||||
| 
 | ||||
|     override fun searchMangaNextPageSelector(): String = | ||||
|         throw UnsupportedOperationException() | ||||
| 
 | ||||
|     // =============================== Filters ============================== | ||||
| 
 | ||||
|     protected var genreName = "" | ||||
|     protected var genreData = listOf<Pair<String, String>>() | ||||
|     protected var chapterCountName = "" | ||||
|     protected var chapterCountData = listOf<Pair<String, String>>() | ||||
|     protected var statusName = "" | ||||
|     protected var statusData = listOf<Pair<String, String>>() | ||||
|     protected var genderName = "" | ||||
|     protected var genderData = listOf<Pair<String, String>>() | ||||
|     protected var sortName = "" | ||||
|     protected var sortData = listOf<Pair<String, String>>() | ||||
|     private var fetchFilterAttempts = 0 | ||||
| 
 | ||||
|     protected suspend fun fetchFilters() { | ||||
|         if ( | ||||
|             fetchFilterAttempts < 3 && | ||||
|             arrayOf(genreData, chapterCountData, statusData, genderData, sortData).any { it.isEmpty() } | ||||
|         ) { | ||||
|             try { | ||||
|                 val doc = client.newCall(filtersRequest()) | ||||
|                     .await() | ||||
|                     .asJsoup() | ||||
| 
 | ||||
|                 parseFilters(doc) | ||||
|             } catch (e: Exception) { | ||||
|                 Log.e("$name: Filters", e.stackTraceToString()) | ||||
|             } | ||||
|             fetchFilterAttempts++ | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     protected open fun filtersRequest() = GET("$baseUrl/filter", headers) | ||||
| 
 | ||||
|     protected open fun parseFilters(document: Document) { | ||||
|         genreName = document.selectFirst("div.advanced-genres > h3")?.text() ?: "" | ||||
|         genreData = document.select("div.advanced-genres > div > .advance-item").map { | ||||
|             it.text() to it.selectFirst("span")!!.attr("data-genre") | ||||
|         } | ||||
| 
 | ||||
|         chapterCountName = document.getSelectName("select-count") | ||||
|         chapterCountData = document.getSelectData("select-count") | ||||
| 
 | ||||
|         statusName = document.getSelectName("select-status") | ||||
|         statusData = document.getSelectData("select-status") | ||||
| 
 | ||||
|         genderName = document.getSelectName("select-gender") | ||||
|         genderData = document.getSelectData("select-gender") | ||||
| 
 | ||||
|         sortName = document.getSelectName("select-sort") | ||||
|         sortData = document.getSelectData("select-sort") | ||||
|     } | ||||
| 
 | ||||
|     private fun Document.getSelectName(selectorClass: String): String { | ||||
|         return this.selectFirst(".select-div > label.$selectorClass")?.text() ?: "" | ||||
|     } | ||||
| 
 | ||||
|     private fun Document.getSelectData(selectorId: String): List<Pair<String, String>> { | ||||
|         return this.select("#$selectorId > option").map { | ||||
|             it.text() to it.attr("value") | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun getFilterList(): FilterList { | ||||
|         launchIO { fetchFilters() } | ||||
| 
 | ||||
|         val filters = mutableListOf<Filter<*>>() | ||||
| 
 | ||||
|         if (genreData.isNotEmpty()) { | ||||
|             filters.add(GenreFilter(genreName, genreData)) | ||||
|         } | ||||
|         if (chapterCountData.isNotEmpty()) { | ||||
|             filters.add(ChapterCountFilter(chapterCountName, chapterCountData)) | ||||
|         } | ||||
|         if (statusData.isNotEmpty()) { | ||||
|             filters.add(StatusFilter(statusName, statusData)) | ||||
|         } | ||||
|         if (genderData.isNotEmpty()) { | ||||
|             filters.add(GenderFilter(genderName, genderData)) | ||||
|         } | ||||
|         if (sortData.isNotEmpty()) { | ||||
|             filters.add(SortFilter(sortName, sortData)) | ||||
|         } | ||||
|         if (filters.size < 5) { | ||||
|             filters.add(0, Filter.Header("Press 'reset' to load more filters")) | ||||
|         } else { | ||||
|             filters.add(0, Filter.Header("NOTE: Ignored if using text search!")) | ||||
|             filters.add(1, Filter.Separator()) | ||||
|         } | ||||
| 
 | ||||
|         return FilterList(filters) | ||||
|     } | ||||
| 
 | ||||
|     private val scope = CoroutineScope(Dispatchers.IO) | ||||
| 
 | ||||
|     protected fun launchIO(block: suspend () -> Unit) = scope.launch { block() } | ||||
| 
 | ||||
|     // =========================== Manga Details ============================ | ||||
| 
 | ||||
|     override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { | ||||
|         description = document.selectFirst("div#syn-target")?.text() | ||||
|         thumbnail_url = document.selectFirst(".a1 > figure img")?.imgAttr() | ||||
|         title = document.selectFirst(".a2 header h1")!!.text() | ||||
|         genre = document.select(".a2 div > a[rel='tag'].label").joinToString { it.text() } | ||||
|         author = document.selectFirst("div.y6x11p i.fas.fa-user + span.dt")?.text()?.takeUnless { | ||||
|             it.equals("updating", true) | ||||
|         } | ||||
|         status = document.selectFirst("div.y6x11p i.fas.fa-rss + span.dt").parseStatus() | ||||
|     } | ||||
| 
 | ||||
|     private fun Element?.parseStatus(): Int = when (this?.text()?.lowercase()) { | ||||
|         "ongoing", "đang tiến hành", "進行中" -> SManga.ONGOING | ||||
|         "completed", "hoàn thành", "完了" -> SManga.COMPLETED | ||||
|         "on-hold", "tạm ngưng", "保留" -> SManga.ON_HIATUS | ||||
|         "canceled", "đã huỷ", "キャンセル" -> SManga.CANCELLED | ||||
|         else -> SManga.UNKNOWN | ||||
|     } | ||||
| 
 | ||||
|     // ============================== Chapters ============================== | ||||
| 
 | ||||
|     override fun chapterListSelector() = "ul > li.chapter" | ||||
| 
 | ||||
|     override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { | ||||
|         element.selectFirst("time[datetime]")?.also { | ||||
|             date_upload = it.attr("datetime").toLongOrNull()?.let { it * 1000L } ?: 0L | ||||
|         } | ||||
|         with(element.selectFirst("a")!!) { | ||||
|             name = text() | ||||
|             setUrlWithoutDomain(attr("abs:href")) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     // =============================== Pages ================================ | ||||
| 
 | ||||
|     @Serializable | ||||
|     class PageListResponseDto( | ||||
|         val status: Boolean = false, | ||||
|         val msg: String? = null, | ||||
|         val html: String, | ||||
|     ) | ||||
| 
 | ||||
|     override fun pageListParse(response: Response): List<Page> { | ||||
|         val document = response.asJsoup() | ||||
|         val script = document.selectFirst("script:containsData(const CHAPTER_ID)")?.data() | ||||
|             ?: throw Exception("Failed to get chapter id") | ||||
| 
 | ||||
|         val chapterId = script.substringAfter("const CHAPTER_ID = ").substringBefore(";") | ||||
| 
 | ||||
|         val pageHeaders = headersBuilder().apply { | ||||
|             add("Accept", "application/json, text/javascript, *//*; q=0.01") | ||||
|             add("Host", baseUrl.toHttpUrl().host) | ||||
|             set("Referer", response.request.url.toString()) | ||||
|             add("X-Requested-With", "XMLHttpRequest") | ||||
|         }.build() | ||||
| 
 | ||||
|         val ajaxResponse = client.newCall( | ||||
|             GET("$baseUrl/ajax/image/list/chap/$chapterId", pageHeaders), | ||||
|         ).execute() | ||||
| 
 | ||||
|         val data = ajaxResponse.parseAs<PageListResponseDto>() | ||||
| 
 | ||||
|         if (!data.status) { | ||||
|             throw Exception(data.msg) | ||||
|         } | ||||
| 
 | ||||
|         return pageListParse( | ||||
|             Jsoup.parseBodyFragment( | ||||
|                 data.html, | ||||
|                 response.request.url.toString(), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     override fun pageListParse(document: Document): List<Page> { | ||||
|         return document.select("div.separator").mapIndexed { i, page -> | ||||
|             val url = page.selectFirst("a")!!.attr("abs:href") | ||||
|             Page(i, document.location(), url) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun imageUrlParse(document: Document) = "" | ||||
| 
 | ||||
|     override fun imageRequest(page: Page): Request { | ||||
|         val imgHeaders = headersBuilder().apply { | ||||
|             add("Accept", "image/avif,image/webp,*/*") | ||||
|             add("Host", page.imageUrl!!.toHttpUrl().host) | ||||
|         }.build() | ||||
|         return GET(page.imageUrl!!, imgHeaders) | ||||
|     } | ||||
| 
 | ||||
|     // ============================= Utilities ============================== | ||||
| 
 | ||||
|     // From mangathemesia | ||||
|     private fun Element.imgAttr(): String = when { | ||||
|         hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") | ||||
|         hasAttr("data-src") -> attr("abs:data-src") | ||||
|         else -> attr("abs:src") | ||||
|     } | ||||
| 
 | ||||
|     private inline fun <reified T> Response.parseAs(): T { | ||||
|         return json.decodeFromString(body.string()) | ||||
|     } | ||||
| } | ||||
| @ -1,9 +1,9 @@ | ||||
| ext { | ||||
|     extName = 'Manhuagold' | ||||
|     extClass = '.Manhuagold' | ||||
|     themePkg = 'mangareader' | ||||
|     baseUrl = 'https://manhuagold.com' | ||||
|     overrideVersionCode = 33 | ||||
|     themePkg = 'liliana' | ||||
|     baseUrl = 'https://manhuagold.top' | ||||
|     overrideVersionCode = 34 | ||||
|     isNsfw = true | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -1,233 +1,18 @@ | ||||
| package eu.kanade.tachiyomi.extension.en.comickiba | ||||
| 
 | ||||
| import eu.kanade.tachiyomi.multisrc.mangareader.MangaReader | ||||
| import eu.kanade.tachiyomi.network.GET | ||||
| import eu.kanade.tachiyomi.network.asObservableSuccess | ||||
| import eu.kanade.tachiyomi.multisrc.liliana.Liliana | ||||
| import eu.kanade.tachiyomi.network.interceptor.rateLimit | ||||
| import eu.kanade.tachiyomi.source.model.FilterList | ||||
| 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.util.asJsoup | ||||
| import kotlinx.serialization.json.Json | ||||
| import kotlinx.serialization.json.jsonObject | ||||
| import kotlinx.serialization.json.jsonPrimitive | ||||
| import okhttp3.HttpUrl.Companion.toHttpUrl | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import org.jsoup.Jsoup | ||||
| import org.jsoup.nodes.Document | ||||
| import org.jsoup.nodes.Element | ||||
| import org.jsoup.nodes.TextNode | ||||
| import org.jsoup.select.Evaluator | ||||
| import rx.Observable | ||||
| 
 | ||||
| class Manhuagold : MangaReader() { | ||||
| class Manhuagold : Liliana( | ||||
|     "Manhuagold", | ||||
|     "https://manhuagold.top", | ||||
|     "en", | ||||
|     usesPostSearch = true, | ||||
| ) { | ||||
|     // MangaReader -> Liliana | ||||
|     override val versionId = 2 | ||||
| 
 | ||||
|     override val name = "Manhuagold" | ||||
| 
 | ||||
|     override val lang = "en" | ||||
| 
 | ||||
|     override val baseUrl = "https://manhuagold.com" | ||||
| 
 | ||||
|     override val client = network.cloudflareClient.newBuilder() | ||||
|     override val client = super.client.newBuilder() | ||||
|         .rateLimit(2) | ||||
|         .build() | ||||
| 
 | ||||
|     override fun headersBuilder() = super.headersBuilder() | ||||
|         .add("Referer", "$baseUrl/") | ||||
| 
 | ||||
|     // Popular | ||||
| 
 | ||||
|     override fun popularMangaRequest(page: Int) = | ||||
|         GET("$baseUrl/filter/$page/?sort=views&sex=All&chapter_count=0", headers) | ||||
| 
 | ||||
|     // Latest | ||||
| 
 | ||||
|     override fun latestUpdatesRequest(page: Int) = | ||||
|         GET("$baseUrl/filter/$page/?sort=latest-updated&sex=All&chapter_count=0", headers) | ||||
| 
 | ||||
|     // Search | ||||
| 
 | ||||
|     override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { | ||||
|         val urlBuilder = baseUrl.toHttpUrl().newBuilder() | ||||
|         if (query.isNotBlank()) { | ||||
|             urlBuilder.addPathSegment("search").apply { | ||||
|                 addQueryParameter("keyword", query) | ||||
|             } | ||||
|         } else { | ||||
|             urlBuilder.addPathSegment("filter").apply { | ||||
|                 filters.ifEmpty(::getFilterList).forEach { filter -> | ||||
|                     when (filter) { | ||||
|                         is Select -> { | ||||
|                             addQueryParameter(filter.param, filter.selection) | ||||
|                         } | ||||
|                         is GenresFilter -> { | ||||
|                             addQueryParameter(filter.param, filter.selection) | ||||
|                         } | ||||
|                         else -> {} | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         urlBuilder.addPathSegment(page.toString()) | ||||
|         urlBuilder.addPathSegment("") | ||||
| 
 | ||||
|         return GET(urlBuilder.build(), headers) | ||||
|     } | ||||
| 
 | ||||
|     override fun searchMangaSelector() = ".manga_list-sbs .manga-poster" | ||||
| 
 | ||||
|     override fun searchMangaFromElement(element: Element) = | ||||
|         SManga.create().apply { | ||||
|             setUrlWithoutDomain(element.attr("href")) | ||||
|             element.selectFirst(Evaluator.Tag("img"))!!.let { | ||||
|                 title = it.attr("alt") | ||||
|                 thumbnail_url = it.imgAttr() | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|     override fun searchMangaNextPageSelector() = "ul.pagination > li.active + li" | ||||
| 
 | ||||
|     // Filters | ||||
| 
 | ||||
|     override fun getFilterList() = | ||||
|         FilterList( | ||||
|             Note, | ||||
|             StatusFilter(), | ||||
|             SortFilter(), | ||||
|             GenresFilter(), | ||||
|         ) | ||||
| 
 | ||||
|     // Details | ||||
| 
 | ||||
|     override fun mangaDetailsParse(document: Document) = SManga.create().apply { | ||||
|         val root = document.selectFirst(Evaluator.Id("ani_detail"))!! | ||||
|         val mangaTitle = root.selectFirst(Evaluator.Class("manga-name"))!!.ownText() | ||||
|         title = mangaTitle | ||||
|         description = root.run { | ||||
|             val description = selectFirst(Evaluator.Class("description"))!!.ownText() | ||||
|             when (val altTitle = selectFirst(Evaluator.Class("manga-name-or"))!!.ownText()) { | ||||
|                 "", mangaTitle -> description | ||||
|                 else -> "$description\n\nAlternative Title: $altTitle" | ||||
|             } | ||||
|         } | ||||
|         thumbnail_url = root.selectFirst(Evaluator.Tag("img"))!!.imgAttr() | ||||
|         genre = root.selectFirst(Evaluator.Class("genres"))!!.children().joinToString { it.ownText() } | ||||
|         for (item in root.selectFirst(Evaluator.Class("anisc-info"))!!.children()) { | ||||
|             if (item.hasClass("item").not()) continue | ||||
|             when (item.selectFirst(Evaluator.Class("item-head"))!!.ownText()) { | ||||
|                 "Authors:" -> item.parseAuthorsTo(this) | ||||
|                 "Status:" -> status = when (item.selectFirst(Evaluator.Class("name"))!!.ownText().lowercase()) { | ||||
|                     "ongoing" -> SManga.ONGOING | ||||
|                     "completed" -> SManga.COMPLETED | ||||
|                     "on-hold" -> SManga.ON_HIATUS | ||||
|                     "canceled" -> SManga.CANCELLED | ||||
|                     else -> SManga.UNKNOWN | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun Element.parseAuthorsTo(manga: SManga) { | ||||
|         val authors = select(Evaluator.Tag("a")) | ||||
|         val text = authors.map { it.ownText().replace(",", "") } | ||||
|         val count = authors.size | ||||
|         when (count) { | ||||
|             0 -> return | ||||
|             1 -> { | ||||
|                 manga.author = text[0] | ||||
|                 return | ||||
|             } | ||||
|         } | ||||
|         val authorList = ArrayList<String>(count) | ||||
|         val artistList = ArrayList<String>(count) | ||||
|         for ((index, author) in authors.withIndex()) { | ||||
|             val textNode = author.nextSibling() as? TextNode | ||||
|             val list = if (textNode != null && "(Art)" in textNode.wholeText) artistList else authorList | ||||
|             list.add(text[index]) | ||||
|         } | ||||
|         if (authorList.isEmpty().not()) manga.author = authorList.joinToString() | ||||
|         if (artistList.isEmpty().not()) manga.artist = artistList.joinToString() | ||||
|     } | ||||
| 
 | ||||
|     // Chapters | ||||
| 
 | ||||
|     override fun chapterListRequest(mangaUrl: String, type: String): Request = | ||||
|         GET(baseUrl + mangaUrl, headers) | ||||
| 
 | ||||
|     override fun parseChapterElements(response: Response, isVolume: Boolean): List<Element> { | ||||
|         TODO("Not yet implemented") | ||||
|     } | ||||
| 
 | ||||
|     override val chapterType = "" | ||||
|     override val volumeType = "" | ||||
| 
 | ||||
|     override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { | ||||
|         return client.newCall(chapterListRequest(manga)) | ||||
|             .asObservableSuccess() | ||||
|             .map(::parseChapterList) | ||||
|     } | ||||
| 
 | ||||
|     private fun parseChapterList(response: Response): List<SChapter> { | ||||
|         val document = response.use { it.asJsoup() } | ||||
| 
 | ||||
|         return document.select(chapterListSelector()) | ||||
|             .map(::chapterFromElement) | ||||
|     } | ||||
| 
 | ||||
|     private fun chapterListSelector(): String = "#chapters-list > li" | ||||
| 
 | ||||
|     private fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { | ||||
|         element.selectFirst("a")!!.run { | ||||
|             setUrlWithoutDomain(attr("href")) | ||||
|             name = selectFirst(".name")?.text() ?: text() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     // Images | ||||
| 
 | ||||
|     override fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.fromCallable { | ||||
|         val document = client.newCall(pageListRequest(chapter)).execute().asJsoup() | ||||
| 
 | ||||
|         val script = document.selectFirst("script:containsData(const CHAPTER_ID)")!!.data() | ||||
|         val id = script.substringAfter("const CHAPTER_ID = ").substringBefore(";") | ||||
| 
 | ||||
|         val ajaxHeaders = super.headersBuilder().apply { | ||||
|             add("Accept", "application/json, text/javascript, */*; q=0.01") | ||||
|             add("Referer", baseUrl + chapter.url) | ||||
|             add("X-Requested-With", "XMLHttpRequest") | ||||
|         }.build() | ||||
| 
 | ||||
|         val ajaxUrl = "$baseUrl/ajax/image/list/chap/$id" | ||||
|         client.newCall(GET(ajaxUrl, ajaxHeaders)).execute().let(::pageListParse) | ||||
|     } | ||||
| 
 | ||||
|     override fun pageListParse(response: Response): List<Page> { | ||||
|         val document = response.use { it.parseHtmlProperty() } | ||||
| 
 | ||||
|         val pageList = document.select("div").map { | ||||
|             val index = it.attr("data-number").toInt() | ||||
|             val imgUrl = it.imgAttr().ifEmpty { it.selectFirst("img")!!.imgAttr() } | ||||
| 
 | ||||
|             Page(index, "", imgUrl) | ||||
|         } | ||||
| 
 | ||||
|         return pageList | ||||
|     } | ||||
| 
 | ||||
|     // Utilities | ||||
| 
 | ||||
|     // From mangathemesia | ||||
|     private fun Element.imgAttr(): String = when { | ||||
|         hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") | ||||
|         hasAttr("data-src") -> attr("abs:data-src") | ||||
|         else -> attr("abs:src") | ||||
|     } | ||||
| 
 | ||||
|     private fun Response.parseHtmlProperty(): Document { | ||||
|         val html = Json.parseToJsonElement(body.string()).jsonObject["html"]!!.jsonPrimitive.content | ||||
|         return Jsoup.parseBodyFragment(html) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -1,7 +1,9 @@ | ||||
| ext { | ||||
|     extName = 'ManhuaPlus (unoriginal)' | ||||
|     extClass = '.ManhuaPlusOrg' | ||||
|     extVersionCode = 1 | ||||
|     themePkg = 'liliana' | ||||
|     baseUrl = 'https://manhuaplus.org' | ||||
|     overrideVersionCode = 1 | ||||
| } | ||||
| 
 | ||||
| apply from: "$rootDir/common.gradle" | ||||
|  | ||||
| @ -1,242 +1,9 @@ | ||||
| package eu.kanade.tachiyomi.extension.en.manhuaplusorg | ||||
| 
 | ||||
| import eu.kanade.tachiyomi.network.GET | ||||
| 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.Serializable | ||||
| import kotlinx.serialization.decodeFromString | ||||
| import kotlinx.serialization.json.Json | ||||
| import okhttp3.HttpUrl.Companion.toHttpUrl | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import org.jsoup.Jsoup | ||||
| import org.jsoup.nodes.Document | ||||
| import org.jsoup.nodes.Element | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import eu.kanade.tachiyomi.multisrc.liliana.Liliana | ||||
| 
 | ||||
| class ManhuaPlusOrg : ParsedHttpSource() { | ||||
| 
 | ||||
|     override val name = "ManhuaPlus (Unoriginal)" | ||||
| 
 | ||||
|     override val baseUrl = "https://manhuaplus.org" | ||||
| 
 | ||||
|     override val lang = "en" | ||||
| 
 | ||||
|     override val supportsLatest = true | ||||
| 
 | ||||
|     private val json: Json by injectLazy() | ||||
| 
 | ||||
|     override val client: OkHttpClient = network.cloudflareClient.newBuilder() | ||||
|         .rateLimit(1) | ||||
|         .build() | ||||
| 
 | ||||
|     override fun headersBuilder() = super.headersBuilder() | ||||
|         .add("Referer", "$baseUrl/") | ||||
| 
 | ||||
|     // Popular | ||||
| 
 | ||||
|     override fun popularMangaRequest(page: Int): Request = GET("$baseUrl/ranking/week/$page", headers) | ||||
| 
 | ||||
|     override fun popularMangaSelector(): String = "div#main div.grid > div" | ||||
| 
 | ||||
|     override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { | ||||
|         thumbnail_url = element.selectFirst("img")?.imgAttr() | ||||
|         element.selectFirst(".text-center a")!!.run { | ||||
|             title = text().trim() | ||||
|             setUrlWithoutDomain(attr("href")) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun popularMangaNextPageSelector(): String = ".blog-pager > span.pagecurrent + span" | ||||
| 
 | ||||
|     // Latest | ||||
| 
 | ||||
|     override fun latestUpdatesRequest(page: Int): Request = | ||||
|         GET("$baseUrl/all-manga/$page/?sort=1", headers) | ||||
| 
 | ||||
|     override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response) | ||||
| 
 | ||||
|     override fun latestUpdatesSelector(): String = | ||||
|         throw UnsupportedOperationException() | ||||
| 
 | ||||
|     override fun latestUpdatesFromElement(element: Element): SManga = | ||||
|         throw UnsupportedOperationException() | ||||
| 
 | ||||
|     override fun latestUpdatesNextPageSelector(): String = | ||||
|         throw UnsupportedOperationException() | ||||
| 
 | ||||
|     // Search | ||||
| 
 | ||||
|     override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { | ||||
|         val url = baseUrl.toHttpUrl().newBuilder().apply { | ||||
|             if (query.isNotBlank()) { | ||||
|                 addPathSegment("search") | ||||
|                 addQueryParameter("keyword", query) | ||||
|             } else { | ||||
|                 addPathSegment("filter") | ||||
|                 filters.forEach { filter -> | ||||
|                     when (filter) { | ||||
|                         is GenreFilter -> { | ||||
|                             if (filter.checked.isNotEmpty()) { | ||||
|                                 addQueryParameter("genres", filter.checked.joinToString(",")) | ||||
|                             } | ||||
|                         } | ||||
|                         is StatusFilter -> { | ||||
|                             if (filter.selected.isNotBlank()) { | ||||
|                                 addQueryParameter("status", filter.selected) | ||||
|                             } | ||||
|                         } | ||||
|                         is SortFilter -> { | ||||
|                             addQueryParameter("sort", filter.selected) | ||||
|                         } | ||||
|                         is ChapterCountFilter -> { | ||||
|                             addQueryParameter("chapter_count", filter.selected) | ||||
|                         } | ||||
|                         is GenderFilter -> { | ||||
|                             addQueryParameter("sex", filter.selected) | ||||
|                         } | ||||
|                         else -> {} | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             addPathSegment(page.toString()) | ||||
|             addPathSegment("") | ||||
|         } | ||||
| 
 | ||||
|         return GET(url.build(), headers) | ||||
|     } | ||||
| 
 | ||||
|     override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response) | ||||
| 
 | ||||
|     override fun searchMangaSelector(): String = | ||||
|         throw UnsupportedOperationException() | ||||
| 
 | ||||
|     override fun searchMangaFromElement(element: Element): SManga = | ||||
|         throw UnsupportedOperationException() | ||||
| 
 | ||||
|     override fun searchMangaNextPageSelector(): String = | ||||
|         throw UnsupportedOperationException() | ||||
| 
 | ||||
|     // Filters | ||||
| 
 | ||||
|     override fun getFilterList(): FilterList = FilterList( | ||||
|         Filter.Header("Ignored when using text search"), | ||||
|         Filter.Separator(), | ||||
|         GenreFilter(), | ||||
|         ChapterCountFilter(), | ||||
|         GenderFilter(), | ||||
|         StatusFilter(), | ||||
|         SortFilter(), | ||||
| class ManhuaPlusOrg : Liliana( | ||||
|     "ManhuaPlus (Unoriginal)", | ||||
|     "https://manhuaplus.org", | ||||
|     "en", | ||||
| ) | ||||
| 
 | ||||
|     // Details | ||||
| 
 | ||||
|     override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { | ||||
|         description = document.selectFirst("div#syn-target")?.text() | ||||
|         thumbnail_url = document.selectFirst(".a1 > figure img")?.imgAttr() | ||||
|         title = document.selectFirst(".a2 header h1")?.text()?.trim() ?: "N/A" | ||||
|         genre = document.select(".a2 div > a[rel='tag'].label").joinToString(", ") { it.text() } | ||||
| 
 | ||||
|         document.selectFirst(".a1 > aside")?.run { | ||||
|             author = select("div:contains(Authors) > span a") | ||||
|                 .joinToString(", ") { it.text().trim() } | ||||
|                 .takeUnless { it.isBlank() || it.equals("Updating", true) } | ||||
|             status = selectFirst("div:contains(Status) > span")?.text().let(::parseStatus) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun parseStatus(status: String?): Int = when { | ||||
|         status.equals("ongoing", true) -> SManga.ONGOING | ||||
|         status.equals("completed", true) -> SManga.COMPLETED | ||||
|         status.equals("on-hold", true) -> SManga.ON_HIATUS | ||||
|         status.equals("canceled", true) -> SManga.CANCELLED | ||||
|         else -> SManga.UNKNOWN | ||||
|     } | ||||
| 
 | ||||
|     // Chapters | ||||
| 
 | ||||
|     override fun chapterListSelector() = "ul > li.chapter" | ||||
| 
 | ||||
|     override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { | ||||
|         element.selectFirst("time[datetime]")?.also { | ||||
|             date_upload = it.attr("datetime").toLongOrNull()?.let { it * 1000L } ?: 0L | ||||
|         } | ||||
|         element.selectFirst("a")!!.run { | ||||
|             text().trim().also { | ||||
|                 name = it | ||||
|                 chapter_number = it.substringAfter("hapter ").toFloatOrNull() ?: 0F | ||||
|             } | ||||
|             setUrlWithoutDomain(attr("href")) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun pageListRequest(chapter: SChapter): Request { | ||||
|         val document = client.newCall(GET(baseUrl + chapter.url, headers)).execute().asJsoup() | ||||
| 
 | ||||
|         val script = document.selectFirst("script:containsData(const CHAPTER_ID)")!!.data() | ||||
| 
 | ||||
|         val id = script.substringAfter("const CHAPTER_ID = ").substringBefore(";") | ||||
| 
 | ||||
|         val pageHeaders = headersBuilder().apply { | ||||
|             add("Accept", "application/json, text/javascript, *//*; q=0.01") | ||||
|             add("Host", baseUrl.toHttpUrl().host) | ||||
|             add("Referer", baseUrl + chapter.url) | ||||
|             add("X-Requested-With", "XMLHttpRequest") | ||||
|         }.build() | ||||
| 
 | ||||
|         return GET("$baseUrl/ajax/image/list/chap/$id", pageHeaders) | ||||
|     } | ||||
| 
 | ||||
|     @Serializable | ||||
|     data class PageListResponseDto(val html: String) | ||||
| 
 | ||||
|     override fun pageListParse(response: Response): List<Page> { | ||||
|         val data = response.parseAs<PageListResponseDto>().html | ||||
|         return pageListParse( | ||||
|             Jsoup.parseBodyFragment( | ||||
|                 data, | ||||
|                 response.request.header("Referer")!!, | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     override fun pageListParse(document: Document): List<Page> { | ||||
|         return document.select("div.separator").map { page -> | ||||
|             val index = page.selectFirst("img")!!.attr("alt").substringAfterLast(" ").toInt() | ||||
|             val url = page.selectFirst("a")!!.attr("abs:href") | ||||
|             Page(index, document.location(), url) | ||||
|         }.sortedBy { it.index } | ||||
|     } | ||||
| 
 | ||||
|     override fun imageUrlParse(document: Document) = "" | ||||
| 
 | ||||
|     override fun imageRequest(page: Page): Request { | ||||
|         val imgHeaders = headersBuilder().apply { | ||||
|             add("Accept", "image/avif,image/webp,*/*") | ||||
|             add("Host", page.imageUrl!!.toHttpUrl().host) | ||||
|         }.build() | ||||
|         return GET(page.imageUrl!!, imgHeaders) | ||||
|     } | ||||
| 
 | ||||
|     // Utilities | ||||
| 
 | ||||
|     // From mangathemesia | ||||
|     private fun Element.imgAttr(): String = when { | ||||
|         hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") | ||||
|         hasAttr("data-src") -> attr("abs:data-src") | ||||
|         else -> attr("abs:src") | ||||
|     } | ||||
| 
 | ||||
|     private inline fun <reified T> Response.parseAs(): T { | ||||
|         return json.decodeFromString(body.string()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -1,139 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.extension.en.manhuaplusorg | ||||
| 
 | ||||
| import eu.kanade.tachiyomi.source.model.Filter | ||||
| 
 | ||||
| abstract class SelectFilter( | ||||
|     name: String, | ||||
|     private val options: List<Pair<String, String>>, | ||||
| ) : Filter.Select<String>( | ||||
|     name, | ||||
|     options.map { it.first }.toTypedArray(), | ||||
| ) { | ||||
|     val selected get() = options[state].second | ||||
| } | ||||
| 
 | ||||
| class CheckBoxFilter( | ||||
|     name: String, | ||||
|     val value: String, | ||||
| ) : Filter.CheckBox(name) | ||||
| 
 | ||||
| class ChapterCountFilter : SelectFilter("Chapter count", chapterCount) { | ||||
|     companion object { | ||||
|         private val chapterCount = listOf( | ||||
|             Pair(">= 0", "0"), | ||||
|             Pair(">= 10", "10"), | ||||
|             Pair(">= 30", "30"), | ||||
|             Pair(">= 50", "50"), | ||||
|             Pair(">= 100", "100"), | ||||
|             Pair(">= 200", "200"), | ||||
|             Pair(">= 300", "300"), | ||||
|             Pair(">= 400", "400"), | ||||
|             Pair(">= 500", "500"), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| class GenderFilter : SelectFilter("Manga Gender", gender) { | ||||
|     companion object { | ||||
|         private val gender = listOf( | ||||
|             Pair("All", "All"), | ||||
|             Pair("Boy", "Boy"), | ||||
|             Pair("Girl", "Girl"), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| class StatusFilter : SelectFilter("Status", status) { | ||||
|     companion object { | ||||
|         private val status = listOf( | ||||
|             Pair("All", ""), | ||||
|             Pair("Completed", "completed"), | ||||
|             Pair("OnGoing", "on-going"), | ||||
|             Pair("On-Hold", "on-hold"), | ||||
|             Pair("Canceled", "canceled"), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| class SortFilter : SelectFilter("Sort", sort) { | ||||
|     companion object { | ||||
|         private val sort = listOf( | ||||
|             Pair("Default", "default"), | ||||
|             Pair("Latest Updated", "latest-updated"), | ||||
|             Pair("Most Viewed", "views"), | ||||
|             Pair("Most Viewed Month", "views_month"), | ||||
|             Pair("Most Viewed Week", "views_week"), | ||||
|             Pair("Most Viewed Day", "views_day"), | ||||
|             Pair("Score", "score"), | ||||
|             Pair("Name A-Z", "az"), | ||||
|             Pair("Name Z-A", "za"), | ||||
|             Pair("Newest", "new"), | ||||
|             Pair("Oldest", "old"), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| class GenreFilter : Filter.Group<CheckBoxFilter>( | ||||
|     "Genre", | ||||
|     genres.map { CheckBoxFilter(it.first, it.second) }, | ||||
| ) { | ||||
|     val checked get() = state.filter { it.state }.map { it.value } | ||||
| 
 | ||||
|     companion object { | ||||
|         private val genres = listOf( | ||||
|             Pair("Action", "4"), | ||||
|             Pair("Adaptation", "87"), | ||||
|             Pair("Adult", "31"), | ||||
|             Pair("Adventure", "5"), | ||||
|             Pair("Animals", "1657"), | ||||
|             Pair("Cartoon", "46"), | ||||
|             Pair("Comedy", "14"), | ||||
|             Pair("Demons", "284"), | ||||
|             Pair("Drama", "59"), | ||||
|             Pair("Ecchi", "67"), | ||||
|             Pair("Fantasy", "6"), | ||||
|             Pair("Full Color", "89"), | ||||
|             Pair("Genderswap", "2409"), | ||||
|             Pair("Ghosts", "2253"), | ||||
|             Pair("Gore", "1182"), | ||||
|             Pair("Harem", "17"), | ||||
|             Pair("Historical", "642"), | ||||
|             Pair("Horror", "797"), | ||||
|             Pair("Isekai", "239"), | ||||
|             Pair("Live action", "11"), | ||||
|             Pair("Long Strip", "86"), | ||||
|             Pair("Magic", "90"), | ||||
|             Pair("Magical Girls", "1470"), | ||||
|             Pair("Manhua", "7"), | ||||
|             Pair("Manhwa", "70"), | ||||
|             Pair("Martial Arts", "8"), | ||||
|             Pair("Mature", "12"), | ||||
|             Pair("Mecha", "786"), | ||||
|             Pair("Medical", "1443"), | ||||
|             Pair("Monsters", "138"), | ||||
|             Pair("Mystery", "9"), | ||||
|             Pair("Post-Apocalyptic", "285"), | ||||
|             Pair("Psychological", "798"), | ||||
|             Pair("Reincarnation", "139"), | ||||
|             Pair("Romance", "987"), | ||||
|             Pair("School Life", "10"), | ||||
|             Pair("Sci-fi", "135"), | ||||
|             Pair("Seinen", "196"), | ||||
|             Pair("Shounen", "26"), | ||||
|             Pair("Shounen ai", "64"), | ||||
|             Pair("Slice of Life", "197"), | ||||
|             Pair("Superhero", "136"), | ||||
|             Pair("Supernatural", "13"), | ||||
|             Pair("Survival", "140"), | ||||
|             Pair("Thriller", "137"), | ||||
|             Pair("Time travel", "231"), | ||||
|             Pair("Tragedy", "15"), | ||||
|             Pair("Video Games", "283"), | ||||
|             Pair("Villainess", "676"), | ||||
|             Pair("Virtual Reality", "611"), | ||||
|             Pair("Web comic", "88"), | ||||
|             Pair("Webtoon", "18"), | ||||
|             Pair("Wuxia", "239"), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										9
									
								
								src/ja/mangakoma/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,9 @@ | ||||
| ext { | ||||
|     extName = 'Manga Koma' | ||||
|     extClass = '.MangaKoma' | ||||
|     themePkg = 'liliana' | ||||
|     baseUrl = 'https://mangakoma01.net' | ||||
|     overrideVersionCode = 0 | ||||
| } | ||||
| 
 | ||||
| apply from: "$rootDir/common.gradle" | ||||
							
								
								
									
										
											BIN
										
									
								
								src/ja/mangakoma/res/mipmap-hdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/ja/mangakoma/res/mipmap-mdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/ja/mangakoma/res/mipmap-xhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 4.1 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/ja/mangakoma/res/mipmap-xxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/ja/mangakoma/res/mipmap-xxxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 8.6 KiB | 
| @ -0,0 +1,15 @@ | ||||
| package eu.kanade.tachiyomi.extension.ja.mangakoma | ||||
| 
 | ||||
| import eu.kanade.tachiyomi.multisrc.liliana.Liliana | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import org.jsoup.nodes.Document | ||||
| 
 | ||||
| class MangaKoma : Liliana("Manga Koma", "https://mangakoma01.net", "ja") { | ||||
|     override fun pageListParse(document: Document): List<Page> { | ||||
|         return document.select("div.separator[data-index]").map { page -> | ||||
|             val index = page.attr("data-index").toInt() | ||||
|             val url = page.selectFirst("a")!!.attr("abs:href") | ||||
|             Page(index, document.location(), url) | ||||
|         }.sortedBy { it.index } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										9
									
								
								src/ja/raw1001/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,9 @@ | ||||
| ext { | ||||
|     extName = 'Raw1001' | ||||
|     extClass = '.Raw1001' | ||||
|     themePkg = 'liliana' | ||||
|     baseUrl = 'https://raw1001.net' | ||||
|     overrideVersionCode = 0 | ||||
| } | ||||
| 
 | ||||
| apply from: "$rootDir/common.gradle" | ||||
							
								
								
									
										
											BIN
										
									
								
								src/ja/raw1001/res/mipmap-hdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.0 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/ja/raw1001/res/mipmap-mdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/ja/raw1001/res/mipmap-xhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/ja/raw1001/res/mipmap-xxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.8 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/ja/raw1001/res/mipmap-xxxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 9.1 KiB | 
| @ -0,0 +1,5 @@ | ||||
| package eu.kanade.tachiyomi.extension.ja.raw1001 | ||||
| 
 | ||||
| import eu.kanade.tachiyomi.multisrc.liliana.Liliana | ||||
| 
 | ||||
| class Raw1001 : Liliana("Raw1001", "https://raw1001.net", "ja") | ||||
| @ -1,7 +1,9 @@ | ||||
| ext { | ||||
|     extName = 'DocTruyen5s' | ||||
|     extClass = '.DocTruyen5s' | ||||
|     extVersionCode = 2 | ||||
|     themePkg = 'liliana' | ||||
|     baseUrl = 'https://manga.io.vn' | ||||
|     overrideVersionCode = 2 | ||||
| } | ||||
| 
 | ||||
| apply from: "$rootDir/common.gradle" | ||||
|  | ||||
| @ -1,377 +1,5 @@ | ||||
| package eu.kanade.tachiyomi.extension.vi.doctruyen5s | ||||
| 
 | ||||
| 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.Page | ||||
| import eu.kanade.tachiyomi.source.model.SChapter | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import eu.kanade.tachiyomi.source.online.ParsedHttpSource | ||||
| import kotlinx.serialization.Serializable | ||||
| import kotlinx.serialization.decodeFromString | ||||
| import kotlinx.serialization.json.Json | ||||
| import okhttp3.FormBody | ||||
| import okhttp3.HttpUrl | ||||
| import okhttp3.HttpUrl.Companion.toHttpUrl | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import org.jsoup.Jsoup | ||||
| import org.jsoup.nodes.Document | ||||
| import org.jsoup.nodes.Element | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import eu.kanade.tachiyomi.multisrc.liliana.Liliana | ||||
| 
 | ||||
| class DocTruyen5s : ParsedHttpSource() { | ||||
| 
 | ||||
|     override val name = "DocTruyen5s" | ||||
| 
 | ||||
|     override val lang = "vi" | ||||
| 
 | ||||
|     override val baseUrl = "https://manga.io.vn" | ||||
| 
 | ||||
|     override val supportsLatest = true | ||||
| 
 | ||||
|     override val client = network.cloudflareClient | ||||
| 
 | ||||
|     private val json: Json by injectLazy() | ||||
| 
 | ||||
|     override fun popularMangaRequest(page: Int) = | ||||
|         GET("$baseUrl/filter/$page/?sort=views_day&chapter_count=0&sex=All", headers) | ||||
| 
 | ||||
|     override fun popularMangaSelector() = "div.Blog section div.grid > div" | ||||
| 
 | ||||
|     override fun popularMangaFromElement(element: Element) = SManga.create().apply { | ||||
|         val anchor = element.selectFirst("div.text-center a")!! | ||||
| 
 | ||||
|         setUrlWithoutDomain(anchor.attr("abs:href")) | ||||
|         title = anchor.text() | ||||
|         thumbnail_url = element.selectFirst("img")?.attr("abs:data-src") | ||||
|     } | ||||
| 
 | ||||
|     override fun popularMangaNextPageSelector() = "span.pagecurrent:not(:last-child)" | ||||
| 
 | ||||
|     override fun latestUpdatesRequest(page: Int) = | ||||
|         GET("$baseUrl/filter/$page/?sort=latest-updated&chapter_count=0&sex=All", headers) | ||||
| 
 | ||||
|     override fun latestUpdatesSelector() = popularMangaSelector() | ||||
| 
 | ||||
|     override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element) | ||||
| 
 | ||||
|     override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() | ||||
| 
 | ||||
|     override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { | ||||
|         val url = if (query.isNotBlank()) { | ||||
|             "$baseUrl/search/$page/".toHttpUrl().newBuilder().apply { | ||||
|                 addQueryParameter("keyword", query) | ||||
|             }.build() | ||||
|         } else { | ||||
|             val builder = "$baseUrl/filter/$page/".toHttpUrl().newBuilder() | ||||
| 
 | ||||
|             (if (filters.isEmpty()) getFilterList() else filters).filterIsInstance<UriFilter>() | ||||
|                 .forEach { it.addToUri(builder) } | ||||
| 
 | ||||
|             builder.build() | ||||
|         } | ||||
| 
 | ||||
|         return GET(url, headers) | ||||
|     } | ||||
| 
 | ||||
|     override fun searchMangaSelector() = popularMangaSelector() | ||||
| 
 | ||||
|     override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element) | ||||
| 
 | ||||
|     override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() | ||||
| 
 | ||||
|     override fun mangaDetailsParse(document: Document) = SManga.create().apply { | ||||
|         title = document.selectFirst("article header h1")!!.text() | ||||
|         author = document.selectFirst("div.y6x11p i.fas.fa-user + span.dt")?.text() | ||||
|         description = document.selectFirst("div#syn-target")?.text() | ||||
|         genre = document.select("a.label[rel=tag]").joinToString { it.text() } | ||||
|         status = when (document.selectFirst("div.y6x11p i.fas.fa-rss + span.dt")?.text()) { | ||||
|             "Đang tiến hành" -> SManga.ONGOING | ||||
|             "Hoàn thành" -> SManga.COMPLETED | ||||
|             "Tạm ngưng" -> SManga.ON_HIATUS | ||||
|             "Đã huỷ" -> SManga.CANCELLED | ||||
|             else -> SManga.UNKNOWN | ||||
|         } | ||||
|         thumbnail_url = document.selectFirst("figure img")?.attr("abs:src") | ||||
|     } | ||||
| 
 | ||||
|     override fun chapterListSelector() = "li.chapter" | ||||
| 
 | ||||
|     override fun chapterFromElement(element: Element) = SChapter.create().apply { | ||||
|         val anchor = element.selectFirst("a")!! | ||||
| 
 | ||||
|         setUrlWithoutDomain(anchor.attr("abs:href")) | ||||
|         name = anchor.text() | ||||
|         date_upload = element | ||||
|             .selectFirst("time") | ||||
|             ?.attr("datetime") | ||||
|             ?.toLongOrNull() | ||||
|             ?.times(1000L) ?: 0L | ||||
|     } | ||||
| 
 | ||||
|     private val mangaIdRegex = Regex("""const MANGA_ID = (\d+);""") | ||||
|     private val chapterIdRegex = Regex("""const CHAPTER_ID = (\d+);""") | ||||
| 
 | ||||
|     @Serializable | ||||
|     data class PageAjaxResponse( | ||||
|         val status: Boolean = false, | ||||
|         val msg: String? = null, | ||||
|         val html: String, | ||||
|     ) | ||||
| 
 | ||||
|     override fun pageListRequest(chapter: SChapter): Request { | ||||
|         val html = client.newCall(GET("$baseUrl${chapter.url}")).execute().body.string() | ||||
|         val chapterId = chapterIdRegex.find(html)?.groupValues?.get(1) | ||||
|             ?: throw Exception("Không tìm thấy ID của chương truyện.") | ||||
|         val mangaId = mangaIdRegex.find(html)?.groupValues?.get(1) | ||||
| 
 | ||||
|         if (mangaId != null) { | ||||
|             countViews(mangaId, chapterId) | ||||
|         } | ||||
| 
 | ||||
|         return POST("https://manga.io.vn/ajax/image/list/chap/$chapterId", headers) | ||||
|     } | ||||
| 
 | ||||
|     override fun pageListParse(response: Response): List<Page> { | ||||
|         val data = json.decodeFromString<PageAjaxResponse>(response.body.string()) | ||||
| 
 | ||||
|         if (!data.status) { | ||||
|             throw Exception(data.msg) | ||||
|         } | ||||
| 
 | ||||
|         return pageListParse(Jsoup.parse(data.html)) | ||||
|     } | ||||
| 
 | ||||
|     override fun pageListParse(document: Document) = | ||||
|         document.select("a.readImg img").mapIndexed { i, it -> | ||||
|             Page(i, imageUrl = it.attr("abs:src")) | ||||
|         } | ||||
| 
 | ||||
|     override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() | ||||
| 
 | ||||
|     private fun countViews(mangaId: String, chapterId: String) { | ||||
|         val body = FormBody.Builder() | ||||
|             .add("manga", mangaId) | ||||
|             .add("chapter", chapterId) | ||||
|             .build() | ||||
|         val request = POST( | ||||
|             "$baseUrl/ajax/manga/view", | ||||
|             headers, | ||||
|             body, | ||||
|         ) | ||||
| 
 | ||||
|         runCatching { client.newCall(request).execute().close() } | ||||
|     } | ||||
| 
 | ||||
|     override fun getFilterList() = FilterList( | ||||
|         Filter.Header("Không dùng chung với tìm kiếm bằng tên"), | ||||
|         ChapterCountFilter(), | ||||
|         StatusFilter(), | ||||
|         GenderFilter(), | ||||
|         OrderByFilter(), | ||||
|         GenreList(getGenresList()), | ||||
|     ) | ||||
| 
 | ||||
|     interface UriFilter { | ||||
|         fun addToUri(builder: HttpUrl.Builder) | ||||
|     } | ||||
| 
 | ||||
|     open class UriPartFilter( | ||||
|         name: String, | ||||
|         private val query: String, | ||||
|         private val vals: Array<Pair<String, String>>, | ||||
|         state: Int = 0, | ||||
|     ) : UriFilter, Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), state) { | ||||
|         override fun addToUri(builder: HttpUrl.Builder) { | ||||
|             builder.addQueryParameter(query, vals[state].second) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     class ChapterCountFilter : UriPartFilter( | ||||
|         "Số chương", | ||||
|         "chapter_count", | ||||
|         arrayOf( | ||||
|             ">= 0" to "0", | ||||
|             ">= 10" to "10", | ||||
|             ">= 30" to "30", | ||||
|             ">= 50" to "50", | ||||
|             ">= 100" to "100", | ||||
|             ">= 200" to "200", | ||||
|             ">= 300" to "300", | ||||
|             ">= 400" to "400", | ||||
|             ">= 500" to "500", | ||||
|         ), | ||||
|     ) | ||||
| 
 | ||||
|     class GenderFilter : UriPartFilter( | ||||
|         "Giới tính", | ||||
|         "sex", | ||||
|         arrayOf( | ||||
|             "Tất cả" to "All", | ||||
|             "Con trai" to "Boy", | ||||
|             "Con gái" to "Girl", | ||||
|         ), | ||||
|     ) | ||||
| 
 | ||||
|     class StatusFilter : UriPartFilter( | ||||
|         "Trạng thái", | ||||
|         "status", | ||||
|         arrayOf( | ||||
|             "Tất cả" to "", | ||||
|             "Hoàn thành" to "completed", | ||||
|             "Đang tiến hành" to "on-going", | ||||
|             "Tạm ngưng" to "on-hold", | ||||
|             "Đã huỷ" to "canceled", | ||||
|         ), | ||||
|     ) | ||||
| 
 | ||||
|     class OrderByFilter : UriPartFilter( | ||||
|         "Sắp xếp", | ||||
|         "sort", | ||||
|         arrayOf( | ||||
|             "Mặc định" to "default", | ||||
|             "Mới cập nhật" to "latest-updated", | ||||
|             "Xem nhiều" to "views", | ||||
|             "Xem nhiều nhất tháng" to "views_month", | ||||
|             "Xem nhiều nhất tuần" to "views_week", | ||||
|             "Xem nhiều nhất hôm nay" to "views_day", | ||||
|             "Đánh giá cao" to "score", | ||||
|             "Từ A-Z" to "az", | ||||
|             "Từ Z-A" to "za", | ||||
|             "Số chương nhiều nhất" to "chapters", | ||||
|             "Mới nhất" to "new", | ||||
|             "Cũ nhất" to "old", | ||||
|         ), | ||||
|         5, | ||||
|     ) | ||||
| 
 | ||||
|     class Genre(name: String, val id: String) : Filter.TriState(name) | ||||
| 
 | ||||
|     class GenreList(state: List<Genre>) : UriFilter, Filter.Group<Genre>("Thể loại", state) { | ||||
|         override fun addToUri(builder: HttpUrl.Builder) { | ||||
|             val genres = mutableListOf<String>() | ||||
|             val genresEx = mutableListOf<String>() | ||||
| 
 | ||||
|             state.forEach { | ||||
|                 when (it.state) { | ||||
|                     TriState.STATE_INCLUDE -> genres.add(it.id) | ||||
|                     TriState.STATE_EXCLUDE -> genresEx.add(it.id) | ||||
|                     else -> {} | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             if (genres.size > 0) { | ||||
|                 builder.addQueryParameter("genres", genres.joinToString(",")) | ||||
|             } | ||||
| 
 | ||||
|             if (genresEx.size > 0) { | ||||
|                 builder.addQueryParameter("notGenres", genresEx.joinToString(",")) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /* | ||||
|         Get the list by navigating to https://manga.io.vn/filter/1 and paste in the code below | ||||
|         ``` | ||||
|         copy([...document.querySelectorAll("div.advanced-genres div.advance-item")].map((e) => { | ||||
|             const genreId = e.querySelector("span").dataset.genre; | ||||
|             const genreName = e.querySelector("label").textContent; | ||||
|             return `Genre("${genreName}", "${genreId}"),` | ||||
|         }).join("\n")) | ||||
|         ``` | ||||
|      */ | ||||
|     private fun getGenresList() = listOf( | ||||
|         Genre("16+", "788"), | ||||
|         Genre("Action", "129"), | ||||
|         Genre("Adult", "837"), | ||||
|         Genre("Adventure", "810"), | ||||
|         Genre("Bi Kịch", "393"), | ||||
|         Genre("Cải Biên Tiểu Thuyết", "771"), | ||||
|         Genre("Chuyển sinh", "287"), | ||||
|         Genre("Chuyển Thể", "803"), | ||||
|         Genre("Cổ Đại", "809"), | ||||
|         Genre("Cổ Trang", "340"), | ||||
|         Genre("Comedy", "131"), | ||||
|         Genre("Comic", "828"), | ||||
|         Genre("Cooking", "834"), | ||||
|         Genre("Doujinshi", "201"), | ||||
|         Genre("Drama", "149"), | ||||
|         Genre("Ecchi", "300"), | ||||
|         Genre("Fantasy", "132"), | ||||
|         Genre("Full màu", "189"), | ||||
|         Genre("Game", "38"), | ||||
|         Genre("Gender Bender", "133"), | ||||
|         Genre("gender_bender", "832"), | ||||
|         Genre("Girls Love", "815"), | ||||
|         Genre("Hài Hước", "791"), | ||||
|         Genre("Hào Môn", "779"), | ||||
|         Genre("Harem", "187"), | ||||
|         Genre("Hiện đại", "285"), | ||||
|         Genre("Historical", "836"), | ||||
|         Genre("Hoạt Hình", "497"), | ||||
|         Genre("Horror", "191"), | ||||
|         Genre("Huyền Huyễn", "475"), | ||||
|         Genre("Isekai", "811"), | ||||
|         Genre("Josei", "395"), | ||||
|         Genre("Lịch Sử", "561"), | ||||
|         Genre("Ma Mị", "764"), | ||||
|         Genre("Magic", "160"), | ||||
|         Genre("Main Mạnh", "763"), | ||||
|         Genre("Manga", "151"), | ||||
|         Genre("Manh Bảo", "807"), | ||||
|         Genre("Mạnh Mẽ", "818"), | ||||
|         Genre("Manhua", "153"), | ||||
|         Genre("Manhwa", "193"), | ||||
|         Genre("Martial Arts", "614"), | ||||
|         Genre("Mystery", "155"), | ||||
|         Genre("Ngôn Tình", "156"), | ||||
|         Genre("Ngọt Sủng", "799"), | ||||
|         Genre("Nữ Cường", "819"), | ||||
|         Genre("Oneshot", "65"), | ||||
|         Genre("Phép Thuật", "808"), | ||||
|         Genre("Phiêu Lưu", "478"), | ||||
|         Genre("Psychological", "180"), | ||||
|         Genre("Quái Vật", "758"), | ||||
|         Genre("Romance", "756"), | ||||
|         Genre("School Life", "31"), | ||||
|         Genre("school_life", "833"), | ||||
|         Genre("Sci-Fi", "812"), | ||||
|         Genre("Seinen", "172"), | ||||
|         Genre("Shoujo", "68"), | ||||
|         Genre("Shoujo Ai", "136"), | ||||
|         Genre("Shounen", "140"), | ||||
|         Genre("Shounen Ai", "203"), | ||||
|         Genre("Showbiz", "436"), | ||||
|         Genre("siêu nhiên", "765"), | ||||
|         Genre("Slice Of Life", "8"), | ||||
|         Genre("Sports", "167"), | ||||
|         Genre("Sư Tôn", "794"), | ||||
|         Genre("Sủng", "820"), | ||||
|         Genre("Sủng Nịch", "806"), | ||||
|         Genre("Supernatural", "150"), | ||||
|         Genre("Tận Thế", "759"), | ||||
|         Genre("Thú Thê", "800"), | ||||
|         Genre("Tiên Hiệp", "773"), | ||||
|         Genre("Tình cảm", "814"), | ||||
|         Genre("Tragedy", "822"), | ||||
|         Genre("Tranh Sủng", "805"), | ||||
|         Genre("Trap (Crossdressing)", "147"), | ||||
|         Genre("Trinh Thám", "336"), | ||||
|         Genre("Trọng Sinh", "398"), | ||||
|         Genre("Trùng Sinh", "392"), | ||||
|         Genre("Truy Thê", "780"), | ||||
|         Genre("Truyện Màu", "154"), | ||||
|         Genre("Truyện Nam", "761"), | ||||
|         Genre("Truyện Nữ", "776"), | ||||
|         Genre("Tu Tiên", "477"), | ||||
|         Genre("Viễn Tưởng", "438"), | ||||
|         Genre("VNComic", "787"), | ||||
|         Genre("Vườn Trường", "813"), | ||||
|         Genre("Webtoon", "198"), | ||||
|         Genre("Xuyên Không", "157"), | ||||
|         Genre("Yaoi", "593"), | ||||
|         Genre("Yuri", "137"), | ||||
|     ) | ||||
| } | ||||
| class DocTruyen5s : Liliana("DocTruyen5s", "https://manga.io.vn", "vi") | ||||
|  | ||||
 Secozzi
						Secozzi