diff --git a/multisrc/overrides/mmrcms/bentoscan/src/Bentoscan.kt b/multisrc/overrides/mmrcms/bentoscan/src/Bentoscan.kt index 87ac11a1d..0b4557e0e 100644 --- a/multisrc/overrides/mmrcms/bentoscan/src/Bentoscan.kt +++ b/multisrc/overrides/mmrcms/bentoscan/src/Bentoscan.kt @@ -3,58 +3,21 @@ package eu.kanade.tachiyomi.extension.fr.bentoscan import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.source.model.SChapter import okhttp3.Request -import org.jsoup.nodes.Element -import java.text.SimpleDateFormat -import java.util.Locale -class Bentoscan : MMRCMS("Bentoscan", "https://bentoscan.com", "fr") { +class Bentoscan : MMRCMS( + "Bentoscan", + "https://bentoscan.com", + "fr", + supportsAdvancedSearch = false, + chapterNamePrefix = "Scan ", +) { override fun imageRequest(page: Page): Request { val newHeaders = headersBuilder() - .set("Referer", IMG_URL) + .set("Referer", "https://scansmangas.me/") .set("Accept", "image/avif,image/webp,*/*") .build() return GET(page.imageUrl!!, newHeaders) } - - override fun nullableChapterFromElement(element: Element): SChapter? { - val chapter = SChapter.create() - - val titleWrapper = element.select("[class^=chapter-title-rtl]").first()!! - val chapterElement = titleWrapper.getElementsByTag("a")!! - val url = chapterElement.attr("href") - - chapter.url = getUrlWithoutBaseUrl(url) - - // Construct chapter names - // Before -> Scan VF: - // Now -> Chapitre : OR Chapitre - val chapterText = chapterElement.text() - val numberRegex = Regex("""[1-9]\d*(\.\d+)*""") - val chapterNumber = numberRegex.find(chapterText)?.value.orEmpty() - val chapterTitle = titleWrapper.getElementsByTag("em")!!.text() - if (chapterTitle.toIntOrNull() != null) { - chapter.name = "Chapitre $chapterNumber" - } else { - chapter.name = "Chapitre $chapterNumber : $chapterTitle" - } - - // Parse date - val dateText = element.getElementsByClass("date-chapter-title-rtl").text().trim() - - chapter.date_upload = runCatching { - dateFormat.parse(dateText)?.time - }.getOrNull() ?: 0L - - return chapter - } - - companion object { - private const val IMG_URL = "https://scansmangas.me" - val dateFormat by lazy { - SimpleDateFormat("d MMM. yyyy", Locale.US) - } - } } diff --git a/multisrc/overrides/mmrcms/hentaishark/res/mipmap-hdpi/ic_launcher.png b/multisrc/overrides/mmrcms/hentaishark/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 3b98bbd06..000000000 Binary files a/multisrc/overrides/mmrcms/hentaishark/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/hentaishark/res/mipmap-mdpi/ic_launcher.png b/multisrc/overrides/mmrcms/hentaishark/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index ab75eec75..000000000 Binary files a/multisrc/overrides/mmrcms/hentaishark/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/hentaishark/res/mipmap-xhdpi/ic_launcher.png b/multisrc/overrides/mmrcms/hentaishark/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 696f84ec8..000000000 Binary files a/multisrc/overrides/mmrcms/hentaishark/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/hentaishark/res/mipmap-xxhdpi/ic_launcher.png b/multisrc/overrides/mmrcms/hentaishark/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 89319e58a..000000000 Binary files a/multisrc/overrides/mmrcms/hentaishark/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/hentaishark/res/mipmap-xxxhdpi/ic_launcher.png b/multisrc/overrides/mmrcms/hentaishark/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 22764f228..000000000 Binary files a/multisrc/overrides/mmrcms/hentaishark/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/jpmangas/src/Jpmangas.kt b/multisrc/overrides/mmrcms/jpmangas/src/Jpmangas.kt new file mode 100644 index 000000000..8b8d3b8e1 --- /dev/null +++ b/multisrc/overrides/mmrcms/jpmangas/src/Jpmangas.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.extension.fr.jpmangas + +import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS + +class Jpmangas : MMRCMS( + "Jpmangas", + "https://jpmangas.xyz", + "fr", + supportsAdvancedSearch = false, +) diff --git a/multisrc/overrides/mmrcms/komikid/src/Komikid.kt b/multisrc/overrides/mmrcms/komikid/src/Komikid.kt new file mode 100644 index 000000000..ea422919c --- /dev/null +++ b/multisrc/overrides/mmrcms/komikid/src/Komikid.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.extension.id.komikid + +import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS + +class Komikid : MMRCMS( + "Komikid", + "https://www.komikid.com", + "id", + supportsAdvancedSearch = false, +) diff --git a/multisrc/overrides/mmrcms/lelscanvf/src/LelscanVF.kt b/multisrc/overrides/mmrcms/lelscanvf/src/LelscanVF.kt new file mode 100644 index 000000000..07d51af0c --- /dev/null +++ b/multisrc/overrides/mmrcms/lelscanvf/src/LelscanVF.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.extension.fr.lelscanvf + +import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS + +class LelscanVF : MMRCMS( + "Lelscan-VF", + "https://lelscanvf.cc", + "fr", + supportsAdvancedSearch = false, +) diff --git a/multisrc/overrides/mmrcms/mangafr/src/MangaFR.kt b/multisrc/overrides/mmrcms/mangafr/src/MangaFR.kt deleted file mode 100644 index 83328562d..000000000 --- a/multisrc/overrides/mmrcms/mangafr/src/MangaFR.kt +++ /dev/null @@ -1,55 +0,0 @@ -package eu.kanade.tachiyomi.extension.fr.mangafr - -import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS -import eu.kanade.tachiyomi.source.model.SChapter -import eu.kanade.tachiyomi.source.model.SManga -import okhttp3.Response -import org.jsoup.nodes.Element -import java.text.SimpleDateFormat -import java.util.Locale - -class MangaFR : MMRCMS("Manga-FR", "https://manga-fr.cc", "fr") { - override fun mangaDetailsParse(response: Response): SManga { - return super.mangaDetailsParse(response).apply { - title = title.replace("Chapitres ", "") - } - } - - override fun nullableChapterFromElement(element: Element): SChapter? { - val chapter = SChapter.create() - - val titleWrapper = element.select("[class^=chapter-title-rtl]").first()!! - val chapterElement = titleWrapper.getElementsByTag("a")!! - val url = chapterElement.attr("href") - - chapter.url = getUrlWithoutBaseUrl(url) - - // Construct chapter names - // Before -> Scan VF: - // Now -> Chapitre : OR Chapitre - val chapterText = chapterElement.text() - val numberRegex = Regex("""[1-9]\d*(\.\d+)*""") - val chapterNumber = numberRegex.find(chapterText)?.value.orEmpty() - val chapterTitle = titleWrapper.getElementsByTag("em")!!.text() - if (chapterTitle.toIntOrNull() != null) { - chapter.name = "Chapitre $chapterNumber" - } else { - chapter.name = "Chapitre $chapterNumber : $chapterTitle" - } - - // Parse date - val dateText = element.getElementsByClass("date-chapter-title-rtl").text().trim() - - chapter.date_upload = runCatching { - dateFormat.parse(dateText)?.time - }.getOrNull() ?: 0L - - return chapter - } - - companion object { - val dateFormat by lazy { - SimpleDateFormat("d MMM. yyyy", Locale.US) - } - } -} diff --git a/multisrc/overrides/mmrcms/mangascan/src/MangaScan.kt b/multisrc/overrides/mmrcms/mangascan/src/MangaScan.kt index 19507bcb8..e894b5055 100644 --- a/multisrc/overrides/mmrcms/mangascan/src/MangaScan.kt +++ b/multisrc/overrides/mmrcms/mangascan/src/MangaScan.kt @@ -3,21 +3,18 @@ package eu.kanade.tachiyomi.extension.fr.mangascan import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.source.model.SManga import okhttp3.Request -import okhttp3.Response - -class MangaScan : MMRCMS("Manga-Scan", "https://mangascan-fr.com", "fr") { - - override fun mangaDetailsParse(response: Response): SManga { - return super.mangaDetailsParse(response).apply { - title = title.substringBefore("Chapitres en ligne").substringAfter("Scan").trim() - } - } +class MangaScan : MMRCMS( + "Manga-Scan", + "https://mangascan-fr.com", + "fr", + supportsAdvancedSearch = false, + detailsTitleSelector = "div.col-sm-12 h1", +) { override fun imageRequest(page: Page): Request { val newHeaders = headersBuilder() - .set("Referer", baseUrl) + .set("Referer", "$baseUrl/") .set("Accept", "image/avif,image/webp,*/*") .build() diff --git a/multisrc/overrides/mmrcms/mangasin/src/MangasIn.kt b/multisrc/overrides/mmrcms/mangasin/src/MangasIn.kt index 6cec30197..219c012de 100644 --- a/multisrc/overrides/mmrcms/mangasin/src/MangasIn.kt +++ b/multisrc/overrides/mmrcms/mangasin/src/MangasIn.kt @@ -1,10 +1,11 @@ package eu.kanade.tachiyomi.extension.es.mangasin -import android.net.Uri import android.util.Base64 import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES import eu.kanade.tachiyomi.lib.synchrony.Deobfuscator import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS +import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMSUtils +import eu.kanade.tachiyomi.multisrc.mmrcms.SuggestionDto import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.interceptor.rateLimitHost import eu.kanade.tachiyomi.source.model.FilterList @@ -13,18 +14,19 @@ import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.util.asJsoup import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Request import okhttp3.Response -import uy.kohesive.injekt.injectLazy +import org.jsoup.nodes.Document import java.text.SimpleDateFormat import java.util.Locale -class MangasIn : MMRCMS("Mangas.in", "https://mangas.in", "es") { - - private val json: Json by injectLazy() - +class MangasIn : MMRCMS( + "Mangas.in", + "https://mangas.in", + "es", + supportsAdvancedSearch = false, +) { override val client = super.client.newBuilder() .rateLimitHost(baseUrl.toHttpUrl(), 1, 1) .build() @@ -32,6 +34,57 @@ class MangasIn : MMRCMS("Mangas.in", "https://mangas.in", "es") { override fun headersBuilder() = super.headersBuilder() .add("Referer", "$baseUrl/") + override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/lasted?p=$page", headers) + + override fun latestUpdatesParse(response: Response): MangasPage { + runCatching { fetchFilterOptions() } + + val data = json.decodeFromString(response.body.string()) + val manga = data.data.map { + SManga.create().apply { + url = "/$itemPath/${it.slug}" + title = it.name + thumbnail_url = MMRCMSUtils.guessCover(baseUrl, url, null) + } + } + val hasNextPage = response.request.url.queryParameter("p")!!.toInt() < data.totalPages + + return MangasPage(manga, hasNextPage) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.isEmpty()) { + return super.searchMangaRequest(page, query, filters) + } + + val url = "$baseUrl/search".toHttpUrl().newBuilder().apply { + addQueryParameter("q", query) + }.build() + + return GET(url, headers) + } + + override fun searchMangaParse(response: Response): MangasPage { + val searchType = response.request.url.pathSegments.last() + + if (searchType != "search") { + return super.searchMangaParse(response) + } + + searchDirectory = json.decodeFromString>(response.body.string()) + + return parseSearchDirectory(1) + } + + override fun mangaDetailsParse(document: Document) = super.mangaDetailsParse(document).apply { + status = when (document.selectFirst("div.manga-name span.label")?.text()?.lowercase()) { + in detailStatusComplete -> SManga.COMPLETED + in detailStatusOngoing -> SManga.ONGOING + in detailStatusDropped -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + } + private var key = "" private fun getKey(): String { @@ -43,41 +96,6 @@ class MangasIn : MMRCMS("Mangas.in", "https://mangas.in", "es") { ?: throw Exception("No se pudo encontrar la clave") } - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val url: Uri.Builder - when { - query.isNotBlank() -> { - url = Uri.parse("$baseUrl/search")!!.buildUpon() - url.appendQueryParameter("q", query) - } - else -> { - url = Uri.parse("$baseUrl/filterList?page=$page")!!.buildUpon() - filters.filterIsInstance() - .forEach { it.addToUri(url) } - } - } - return GET(url.toString(), headers) - } - - override fun searchMangaParse(response: Response): MangasPage { - return if (listOf("query", "q").any { it in response.request.url.queryParameterNames }) { - val searchResult = json.decodeFromString>(response.body.string()) - MangasPage( - searchResult - .map { - SManga.create().apply { - url = getUrlWithoutBaseUrl(itemUrl + it.slug) - title = it.name - thumbnail_url = "$baseUrl/uploads/manga/${it.slug}/cover/cover_250x350.jpg" - } - }, - false, - ) - } else { - internalMangaParse(response) - } - } - override fun chapterListParse(response: Response): List { val document = response.asJsoup() val mangaUrl = document.location().removeSuffix("/") @@ -100,7 +118,12 @@ class MangasIn : MMRCMS("Mangas.in", "https://mangas.in", "es") { return chapters.map { SChapter.create().apply { - name = "Capítulo ${it.number}: ${it.name}" + name = if (it.name == "Capítulo ${it.number}") { + it.name + } else { + "Capítulo ${it.number}: ${it.name}" + } + date_upload = it.createdAt.parseDate() setUrlWithoutDomain("$mangaUrl/${it.slug}") } diff --git a/multisrc/overrides/mmrcms/mangasin/src/MangasInDto.kt b/multisrc/overrides/mmrcms/mangasin/src/MangasInDto.kt index b931485d1..557de9697 100644 --- a/multisrc/overrides/mmrcms/mangasin/src/MangasInDto.kt +++ b/multisrc/overrides/mmrcms/mangasin/src/MangasInDto.kt @@ -15,7 +15,13 @@ data class Chapter( ) @Serializable -data class SearchResult( - @SerialName("value") val name: String, - @SerialName("data") val slug: String, +data class LatestManga( + @SerialName("manga_name") val name: String, + @SerialName("manga_slug") val slug: String, +) + +@Serializable +data class LatestUpdateResponse( + val data: List, + val totalPages: Int, ) diff --git a/multisrc/overrides/mmrcms/onma/src/Onma.kt b/multisrc/overrides/mmrcms/onma/src/Onma.kt new file mode 100644 index 000000000..e63aacaf2 --- /dev/null +++ b/multisrc/overrides/mmrcms/onma/src/Onma.kt @@ -0,0 +1,32 @@ +package eu.kanade.tachiyomi.extension.ar.onma + +import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS +import eu.kanade.tachiyomi.source.model.SManga +import org.jsoup.nodes.Document + +class Onma : MMRCMS( + "مانجا اون لاين", + "https://onma.top", + "ar", + detailsTitleSelector = ".panel-heading", +) { + override fun searchMangaSelector() = "div.chapter-container" + + override fun mangaDetailsParse(document: Document): SManga { + return super.mangaDetailsParse(document).apply { + document.select(".panel-body h3").forEach { element -> + when (element.ownText().lowercase().removeSuffix(" :")) { + in detailAuthor -> author = element.selectFirst("div.text")!!.text() + in detailArtist -> artist = element.selectFirst("div.text")!!.text() + in detailGenre -> genre = element.select("div.text a").joinToString { it.text() } + in detailStatus -> status = when (element.selectFirst("div.text")!!.text()) { + in detailStatusComplete -> SManga.COMPLETED + in detailStatusOngoing -> SManga.ONGOING + in detailStatusDropped -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + } + } + } + } +} diff --git a/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-hdpi/ic_launcher.png b/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 049a461c3..000000000 Binary files a/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-mdpi/ic_launcher.png b/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 43a05fdf9..000000000 Binary files a/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-xhdpi/ic_launcher.png b/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index edde2ce2a..000000000 Binary files a/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-xxhdpi/ic_launcher.png b/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index ff7710a11..000000000 Binary files a/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-xxxhdpi/ic_launcher.png b/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 22f5b611b..000000000 Binary files a/multisrc/overrides/mmrcms/phoenixscans/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/readcomicsonline/src/ReadComicsOnline.kt b/multisrc/overrides/mmrcms/readcomicsonline/src/ReadComicsOnline.kt new file mode 100644 index 000000000..8fc171cbc --- /dev/null +++ b/multisrc/overrides/mmrcms/readcomicsonline/src/ReadComicsOnline.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.extension.en.readcomicsonline + +import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS + +class ReadComicsOnline : MMRCMS( + "Read Comics Online", + "https://readcomicsonline.ru", + "en", + itemPath = "comic", +) diff --git a/multisrc/overrides/mmrcms/scanfr/res/mipmap-hdpi/ic_launcher.png b/multisrc/overrides/mmrcms/scanfr/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 8db9293db..000000000 Binary files a/multisrc/overrides/mmrcms/scanfr/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/scanfr/res/mipmap-mdpi/ic_launcher.png b/multisrc/overrides/mmrcms/scanfr/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 0be530d80..000000000 Binary files a/multisrc/overrides/mmrcms/scanfr/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/scanfr/res/mipmap-xhdpi/ic_launcher.png b/multisrc/overrides/mmrcms/scanfr/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 729e66ca3..000000000 Binary files a/multisrc/overrides/mmrcms/scanfr/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/scanfr/res/mipmap-xxhdpi/ic_launcher.png b/multisrc/overrides/mmrcms/scanfr/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 723565825..000000000 Binary files a/multisrc/overrides/mmrcms/scanfr/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/scanfr/res/mipmap-xxxhdpi/ic_launcher.png b/multisrc/overrides/mmrcms/scanfr/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index d7dc1a240..000000000 Binary files a/multisrc/overrides/mmrcms/scanfr/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/multisrc/overrides/mmrcms/scanvf/src/ScanVF.kt b/multisrc/overrides/mmrcms/scanvf/src/ScanVF.kt new file mode 100644 index 000000000..1099b8a68 --- /dev/null +++ b/multisrc/overrides/mmrcms/scanvf/src/ScanVF.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.extension.fr.scanvf + +import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS + +class ScanVF : MMRCMS( + "Scan VF", + "https://www.scan-vf.net", + "fr", + supportsAdvancedSearch = false, +) diff --git a/multisrc/overrides/mmrcms/utsukushii/src/Utsukushii.kt b/multisrc/overrides/mmrcms/utsukushii/src/Utsukushii.kt index 6a736a080..2857553f2 100644 --- a/multisrc/overrides/mmrcms/utsukushii/src/Utsukushii.kt +++ b/multisrc/overrides/mmrcms/utsukushii/src/Utsukushii.kt @@ -4,7 +4,7 @@ import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS import eu.kanade.tachiyomi.network.GET import okhttp3.Request -class Utsukushii : MMRCMS("Utsukushii", "https://manga.utsukushii-bg.com", "bg") { +class Utsukushii : MMRCMS("Utsukushii", "https://utsukushii-bg.com", "bg") { override fun popularMangaRequest(page: Int): Request { return GET("$baseUrl/manga-list", headers) } diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/Forbidden.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/Forbidden.kt new file mode 100644 index 000000000..c37bdc973 --- /dev/null +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/Forbidden.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.multisrc.mmrcms + +/** + * A class similar to [kotlin.Nothing]. + * + * This class has no instances, and is used as a placeholder + * for hacking in forced named arguments, similar to Python's + * `kwargs`. + * + * This is used instead of [kotlin.Nothing] because that class + * is specifically forbidden from being a vararg parameter. + */ +class Forbidden private constructor() diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMS.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMS.kt index fda84896d..fd39ea467 100644 --- a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMS.kt +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMS.kt @@ -1,8 +1,11 @@ package eu.kanade.tachiyomi.multisrc.mmrcms import android.annotation.SuppressLint -import android.net.Uri +import android.util.Log +import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMSUtils.imgAttr +import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMSUtils.textWithNewlines import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList @@ -10,520 +13,464 @@ 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.HttpSource +import eu.kanade.tachiyomi.source.online.ParsedHttpSource import eu.kanade.tachiyomi.util.asJsoup import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import okhttp3.OkHttpClient +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.Subscription +import rx.schedulers.Schedulers import uy.kohesive.injekt.injectLazy -import java.text.ParseException import java.text.SimpleDateFormat import java.util.Locale -import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock -abstract class MMRCMS( +/** + * @param dateFormat The date format used for parsing chapter dates. + * @param itemPath The path used in the URL for entries. + * @param fetchFilterOptions Whether to fetch filtering options (categories, types, tags). + * @param supportsAdvancedSearch Whether the source supports advanced search under /advanced-search. + * @param detailsTitleSelector Selector for the entry's title in its details page. + * @param chapterNamePrefix A word that always precedes the chapter title, e.g. "Scan " + * @param chapterString The word for "Chapter" in the source's language. + */ +abstract class MMRCMS +@Suppress("UNUSED") +constructor( override val name: String, override val baseUrl: String, - override val lang: String, - sourceInfo: String = "", -) : HttpSource() { - open val jsonData = if (sourceInfo == "") { - SourceData.giveMetaData(baseUrl) - } else { - sourceInfo + final override val lang: String, + + vararg useNamedArgumentsBelow: Forbidden, + + private val dateFormat: SimpleDateFormat = SimpleDateFormat("d MMM. yyyy", Locale.US), + protected val itemPath: String = "manga", + private val fetchFilterOptions: Boolean = true, + private val supportsAdvancedSearch: Boolean = true, + private val detailsTitleSelector: String = ".listmanga-header, .widget-title", + private val chapterNamePrefix: String = "", + private val chapterString: String = when (lang) { + "es" -> "Capítulo" + "fr" -> "Chapitre" + else -> "Chapter" + }, +) : ParsedHttpSource() { + + override val supportsLatest = true + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + protected val json: Json by injectLazy() + + override fun popularMangaRequest(page: Int) = GET("$baseUrl/filterList?page=$page&sortBy=views&asc=false") + + override fun popularMangaParse(response: Response): MangasPage { + runCatching { fetchFilterOptions() } + return super.popularMangaParse(response) } + override fun popularMangaSelector() = searchMangaSelector() + + override fun popularMangaFromElement(element: Element) = searchMangaFromElement(element) + + override fun popularMangaNextPageSelector() = searchMangaNextPageSelector() + /** - * Parse a List of JSON sources into a list of `MyMangaReaderCMSSource`s - * - * Example JSON : - * ``` - * { - * "language": "en", - * "name": "Example manga reader", - * "base_url": "https://example.com", - * "supports_latest": true, - * "item_url": "https://example.com/manga/", - * "categories": [ - * {"id": "stuff", "name": "Stuff"}, - * {"id": "test", "name": "Test"} - * ], - * "tags": [ - * {"id": "action", "name": "Action"}, - * {"id": "adventure", "name": "Adventure"} - * ] - * } - * - * - * Sources that do not supports tags may use `null` instead of a list of json objects - * - * @param sourceString The List of JSON strings 1 entry = one source - * @return The list of parsed sources - * - * isNSFW, language, name and base_url are no longer needed as that is handled by multisrc - * supports_latest, item_url, categories and tags are still needed - * - * + * A cache of all titles that have already appeared in latest updates. */ - private val json: Json by injectLazy() - val jsonObject = json.decodeFromString(jsonData) - override val supportsLatest = jsonObject["supports_latest"]!!.jsonPrimitive.boolean - open val itemUrl = jsonObject["item_url"]!!.jsonPrimitive.content - open val categoryMappings = mapToPairs(jsonObject["categories"]!!.jsonArray) - open var tagMappings = jsonObject["tags"]?.jsonArray?.let { mapToPairs(it) } ?: emptyList() - - /** - * Map an array of JSON objects to pairs. Each JSON object must have - * the following properties: - * - * id: first item in pair - * name: second item in pair - * - * @param array The array to process - * @return The new list of pairs - */ - open fun mapToPairs(array: JsonArray): List> = array.map { - it as JsonObject - - it["id"]!!.jsonPrimitive.content to it["name"]!!.jsonPrimitive.content - } - - private val itemUrlPath = Uri.parse(itemUrl).pathSegments.firstOrNull() - private val parsedBaseUrl = Uri.parse(baseUrl) - - override val client: OkHttpClient = network.cloudflareClient.newBuilder() - .connectTimeout(1, TimeUnit.MINUTES) - .readTimeout(1, TimeUnit.MINUTES) - .writeTimeout(1, TimeUnit.MINUTES) - .build() - - override fun popularMangaRequest(page: Int): Request { - return GET("$baseUrl/filterList?page=$page&sortBy=views&asc=false", headers) - } - - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val url: Uri.Builder - when { - query.isNotBlank() -> { - url = Uri.parse("$baseUrl/search")!!.buildUpon() - url.appendQueryParameter("query", query) - } - else -> { - url = Uri.parse("$baseUrl/filterList?page=$page")!!.buildUpon() - filters.filterIsInstance() - .forEach { it.addToUri(url) } - } - } - return GET(url.toString(), headers) - } - - /** - * If the usual search engine isn't available, search through the list of titles with this - */ - private fun selfSearch(query: String): Observable { - return client.newCall(GET("$baseUrl/changeMangaList?type=text", headers)) - .asObservableSuccess() - .map { response -> - val mangas = response.asJsoup().select("ul.manga-list a").toList() - .filter { it.text().contains(query, ignoreCase = true) } - .map { - SManga.create().apply { - title = it.text() - setUrlWithoutDomain(it.attr("abs:href")) - thumbnail_url = coverGuess(null, it.attr("abs:href")) - } - } - MangasPage(mangas, false) - } - } + private val latestTitles = mutableSetOf() override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/latest-release?page=$page", headers) - override fun popularMangaParse(response: Response) = internalMangaParse(response) - override fun searchMangaParse(response: Response): MangasPage { - return if (listOf("query", "q").any { it in response.request.url.queryParameterNames }) { - // If a search query was specified, use search instead! - val jsonArray = json.decodeFromString(response.body.string()).let { - it["suggestions"]!!.jsonArray - } - MangasPage( - jsonArray - .map { - SManga.create().apply { - val segment = it.jsonObject["data"]!!.jsonPrimitive.content - url = getUrlWithoutBaseUrl(itemUrl + segment) - title = it.jsonObject["value"]!!.jsonPrimitive.content - - // Guess thumbnails - // thumbnail_url = "$baseUrl/uploads/manga/$segment/cover/cover_250x350.jpg" - } - }, - false, - ) - } else { - internalMangaParse(response) - } - } - - private val latestTitles = mutableSetOf() - override fun latestUpdatesParse(response: Response): MangasPage { + runCatching { fetchFilterOptions() } + val document = response.asJsoup() - if (document.location().contains("page=1")) latestTitles.clear() + if (response.request.url.queryParameter("page") == "1") { + latestTitles.clear() + } - val mangas = document.select(latestUpdatesSelector()) - .let { elements -> - when { - // List layout (most sources) - elements.select("a[href]").firstOrNull()?.hasText() == true -> elements.map { latestUpdatesFromElement(it, "a[href]") } - // Grid layout (e.g. MangaID) - else -> document.select(gridLatestUpdatesSelector()).map { gridLatestUpdatesFromElement(it) } - } - } - .filterNotNull() + val manga = document.select(latestUpdatesSelector()).mapNotNull { + val item = latestUpdatesFromElement(it) - return MangasPage(mangas, document.selectFirst(latestUpdatesNextPageSelector()) != null) - } - private fun latestUpdatesSelector() = "div.mangalist div.manga-item" - private fun latestUpdatesNextPageSelector() = "a[rel=next]" - protected open fun latestUpdatesFromElement(element: Element, urlSelector: String): SManga? { - return element.select(urlSelector).first()!!.let { titleElement -> - if (titleElement.text() in latestTitles) { + if (latestTitles.contains(item.url)) { null } else { - latestTitles.add(titleElement.text()) - SManga.create().apply { - url = titleElement.attr("abs:href").substringAfter(baseUrl) // intentionally not using setUrlWithoutDomain - title = titleElement.text().trim() - thumbnail_url = "$baseUrl/uploads/manga/${url.substringAfterLast('/')}/cover/cover_250x350.jpg" - } + latestTitles.add(item.url) + item } } - } - private fun gridLatestUpdatesSelector() = "div.mangalist div.manga-item, div.grid-manga tr" - protected open fun gridLatestUpdatesFromElement(element: Element): SManga = SManga.create().apply { - element.select("a.chart-title").let { - setUrlWithoutDomain(it.attr("href")) - title = it.text() - } - thumbnail_url = element.select("img").attr("abs:src") + val hasNextPage = latestUpdatesNextPageSelector()?.let { + document.selectFirst(it) + } != null + + return MangasPage(manga, hasNextPage) } - protected open fun internalMangaParse(response: Response): MangasPage { - val document = response.asJsoup() + override fun latestUpdatesSelector() = "div.mangalist div.manga-item" - val internalMangaSelector = when (name) { - "Utsukushii" -> "div.content div.col-sm-6" - else -> "div[class^=col-sm], div.col-xs-6" - } - return MangasPage( - document.select(internalMangaSelector).map { - SManga.create().apply { - val urlElement = it.getElementsByClass("chart-title") - if (urlElement.size == 0) { - url = getUrlWithoutBaseUrl(it.select("a").attr("href")) - title = it.select("div.caption").text() - it.select("div.caption div").text().let { if (it.isNotEmpty()) title = title.substringBefore(it) } // To clean submanga's titles without breaking hentaishark's - } else { - url = getUrlWithoutBaseUrl(urlElement.attr("href")) - title = urlElement.text().trim() - } + override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element) - it.select("img").let { img -> - thumbnail_url = when { - it.hasAttr("data-background-image") -> it.attr("data-background-image") // Utsukushii - img.hasAttr("data-src") -> coverGuess(img.attr("abs:data-src"), url) - else -> coverGuess(img.attr("abs:src"), url) - } - } - } - }, - document.select(".pagination a[rel=next]").isNotEmpty(), - ) - } + override fun latestUpdatesNextPageSelector(): String? = popularMangaNextPageSelector() - // Guess thumbnails on broken websites - fun coverGuess(url: String?, mangaUrl: String): String? { - return if (url?.endsWith("no-image.png") == true) { - "$baseUrl/uploads/manga/${mangaUrl.substringAfterLast('/')}/cover/cover_250x350.jpg" - } else { - url - } - } + protected var searchDirectory = emptyList() - fun getUrlWithoutBaseUrl(newUrl: String): String { - val parsedNewUrl = Uri.parse(newUrl) - val newPathSegments = parsedNewUrl.pathSegments.toMutableList() + private var searchQuery = "" - for (i in parsedBaseUrl.pathSegments) { - if (i.trim().equals(newPathSegments.first(), true)) { - newPathSegments.removeAt(0) + override fun fetchSearchManga( + page: Int, + query: String, + filters: FilterList, + ): Observable { + return if (query.isNotEmpty()) { + if (page == 1 && query != searchQuery) { + searchQuery = query + client.newCall(searchMangaRequest(page, query, filters)) + .asObservableSuccess() + .map { searchMangaParse(it) } } else { - break + Observable.just(parseSearchDirectory(page)) } + } else { + super.fetchSearchManga(page, query, filters) } - - val builtUrl = parsedNewUrl.buildUpon().path("/") - newPathSegments.forEach { builtUrl.appendPath(it) } - - var out = builtUrl.build().encodedPath!! - if (parsedNewUrl.encodedQuery != null) { - out += "?" + parsedNewUrl.encodedQuery - } - if (parsedNewUrl.encodedFragment != null) { - out += "#" + parsedNewUrl.encodedFragment - } - - return out } + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + val filterList = filters.ifEmpty { getFilterList() } + + if (query.isNotEmpty()) { + addPathSegment("search") + addQueryParameter("query", query) + } else { + addPathSegment(if (supportsAdvancedSearch) "advanced-search" else "filterList") + addQueryParameter("page", page.toString()) + filterList.filterIsInstance().forEach { it.addToUri(this) } + } + }.build() + + return if (query.isEmpty() && supportsAdvancedSearch) { + GET(url.toString().replaceFirst("?", "#"), headers) + } else { + GET(url, headers) + } + } + + private val searchTokenRegex = Regex("""['"]_token['"]\s*:\s*['"]([0-9A-Za-z]+)['"]""") + + override fun searchMangaParse(response: Response): MangasPage { + runCatching { fetchFilterOptions() } + + val searchType = response.request.url.pathSegments.last() + + if (searchType == "filterList") { + return super.searchMangaParse(response) + } + + if (searchType == "advanced-search") { + val document = response.asJsoup() + val fragment = response.request.url.fragment!! + val body = FormBody.Builder().apply { + val page = fragment.substringAfter("page=").substringBefore("&") + + add("params", fragment.substringAfter("page=$page&")) + add("page", page) + + document.selectFirst("script:containsData(_token)")?.data()?.let { + add("_token", searchTokenRegex.find(it)!!.groupValues[1]) + } + }.build() + val request = POST("$baseUrl/advSearchFilter", headers, body) + + return super.searchMangaParse(client.newCall(request).execute()) + } + + searchDirectory = json.decodeFromString(response.body.string()).suggestions + return parseSearchDirectory(1) + } + + override fun searchMangaSelector() = "div.media" + + override fun searchMangaFromElement(element: Element) = SManga.create().apply { + val anchor = element.selectFirst(".media-heading a, .manga-heading a")!! + + setUrlWithoutDomain(anchor.attr("href")) + title = anchor.text() + thumbnail_url = MMRCMSUtils.guessCover(baseUrl, url, element.selectFirst("img")?.imgAttr()) + } + + override fun searchMangaNextPageSelector(): String? = ".pagination a[rel=next]" + + protected fun parseSearchDirectory(page: Int): MangasPage { + val manga = mutableListOf() + val endRange = ((page * 24) - 1).let { if (it <= searchDirectory.lastIndex) it else searchDirectory.lastIndex } + + for (i in (((page - 1) * 24)..endRange)) { + manga.add( + SManga.create().apply { + url = "/$itemPath/${searchDirectory[i].data}" + title = searchDirectory[i].value + thumbnail_url = MMRCMSUtils.guessCover(baseUrl, url, null) + }, + ) + } + + return MangasPage(manga, endRange < searchDirectory.lastIndex) + } + + protected val detailAuthor = hashSetOf("author(s)", "autor(es)", "auteur(s)", "著作", "yazar(lar)", "mangaka(lar)", "pengarang/penulis", "pengarang", "penulis", "autor", "المؤلف", "перевод", "autor/autorzy") + protected val detailArtist = hashSetOf("artist(s)", "artiste(s)", "sanatçi(lar)", "artista(s)", "artist(s)/ilustrator", "الرسام", "seniman", "rysownik/rysownicy", "artista") + protected val detailGenre = hashSetOf("categories", "categorías", "catégories", "ジャンル", "kategoriler", "categorias", "kategorie", "التصنيفات", "жанр", "kategori", "tagi", "género") + protected val detailStatus = hashSetOf("status", "statut", "estado", "状態", "durum", "الحالة", "статус") + protected val detailStatusComplete = hashSetOf("complete", "مكتملة", "complet", "completo", "zakończone", "concluído", "finalizado") + protected val detailStatusOngoing = hashSetOf("ongoing", "مستمرة", "en cours", "em lançamento", "prace w toku", "ativo", "em andamento", "activo") + protected val detailStatusDropped = hashSetOf("dropped") + @SuppressLint("DefaultLocale") - override fun mangaDetailsParse(response: Response) = SManga.create().apply { - val document = response.asJsoup() - document.select("h2.listmanga-header, h2.widget-title").firstOrNull()?.text()?.trim()?.let { title = it } - thumbnail_url = coverGuess(document.select(".row [class^=img-responsive]").firstOrNull()?.attr("abs:src"), document.location()) - description = document.select(".row .well p").text().trim() + override fun mangaDetailsParse(document: Document) = SManga.create().apply { + title = document.selectFirst(detailsTitleSelector)!!.text() + thumbnail_url = MMRCMSUtils.guessCover( + baseUrl, + document.location(), + document.selectFirst(".row img.img-responsive")?.imgAttr(), + ) + description = document.select(".row .well").let { + it.select("h5").remove() + it.textWithNewlines() + } - val detailAuthor = setOf("author(s)", "autor(es)", "auteur(s)", "著作", "yazar(lar)", "mangaka(lar)", "pengarang/penulis", "pengarang", "penulis", "autor", "المؤلف", "перевод", "autor/autorzy") - val detailArtist = setOf("artist(s)", "artiste(s)", "sanatçi(lar)", "artista(s)", "artist(s)/ilustrator", "الرسام", "seniman", "rysownik/rysownicy") - val detailGenre = setOf("categories", "categorías", "catégories", "ジャンル", "kategoriler", "categorias", "kategorie", "التصنيفات", "жанр", "kategori", "tagi") - val detailStatus = setOf("status", "statut", "estado", "状態", "durum", "الحالة", "статус") - val detailStatusComplete = setOf("complete", "مكتملة", "complet", "completo", "zakończone", "concluído") - val detailStatusOngoing = setOf("ongoing", "مستمرة", "en cours", "em lançamento", "prace w toku", "ativo", "em andamento") - val detailDescription = setOf("description", "resumen") - - for (element in document.select(".row .dl-horizontal dt")) { - when (element.text().trim().lowercase().removeSuffix(":")) { + document.select(".row .dl-horizontal dt").forEach { element -> + when (element.text().lowercase().removeSuffix(":")) { in detailAuthor -> author = element.nextElementSibling()!!.text() in detailArtist -> artist = element.nextElementSibling()!!.text() in detailGenre -> genre = element.nextElementSibling()!!.select("a").joinToString { - it.text().trim() + it.text() } - in detailStatus -> status = when (element.nextElementSibling()!!.text().trim().lowercase()) { + in detailStatus -> status = when (element.nextElementSibling()!!.text().lowercase()) { in detailStatusComplete -> SManga.COMPLETED in detailStatusOngoing -> SManga.ONGOING + in detailStatusDropped -> SManga.CANCELLED else -> SManga.UNKNOWN } } } - // When details are in a .panel instead of .row (ES sources) - for (element in document.select("div.panel span.list-group-item")) { - when (element.select("b").text().lowercase().substringBefore(":")) { - in detailAuthor -> author = element.select("b + a").text() - in detailArtist -> artist = element.select("b + a").text() - in detailGenre -> genre = element.getElementsByTag("a").joinToString { - it.text().trim() - } - in detailStatus -> status = when (element.select("b + span.label").text().lowercase()) { - in detailStatusComplete -> SManga.COMPLETED - in detailStatusOngoing -> SManga.ONGOING - else -> SManga.UNKNOWN - } - in detailDescription -> description = element.ownText() - } - } } - /** - * Parses the response from the site and returns a list of chapters. - * - * Overriden to allow for null chapters - * - * @param response the response from the site. - */ override fun chapterListParse(response: Response): List { val document = response.asJsoup() - return document.select(chapterListSelector()).mapNotNull { nullableChapterFromElement(it) } + val title = document.selectFirst(detailsTitleSelector)!!.text() + + return document.select(chapterListSelector()).map { chapterFromElement(it, title) } + } + + override fun chapterListSelector() = "ul.chapters > li:not(.btn)" + + override fun chapterFromElement(element: Element) = throw UnsupportedOperationException() + + protected open fun chapterFromElement(element: Element, mangaTitle: String) = SChapter.create().apply { + val titleWrapper = element.selectFirst(".chapter-title-rtl")!! + val anchor = titleWrapper.selectFirst("a")!! + + setUrlWithoutDomain(anchor.attr("href")) + name = cleanChapterName(mangaTitle, titleWrapper.text()) + date_upload = runCatching { + val date = element.selectFirst(".date-chapter-title-rtl")!!.text() + + dateFormat.parse(date)!!.time + }.getOrDefault(0L) } /** - * Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter. + * The word for "Chapter" in your language. */ - protected open fun chapterListSelector() = "ul[class^=chapters] > li:not(.btn), table.table tr" - // Some websites add characters after "chapters" thus the need of checking classes that starts with "chapters" /** - * titleWrapper can have multiple "a" elements, filter to the first that contains letters (i.e. not "" or # as is possible) + * Function to clean up chapter names. Mostly useful for sites that + * don't know what a chapter title is and do "One Piece 1234 : Chapter 1234". */ - private val urlRegex = Regex("""[a-zA-z]""") + protected open fun cleanChapterName(mangaTitle: String, name: String): String { + val initialName = name.replaceFirst(chapterNamePrefix + mangaTitle, chapterString) - /** - * Returns a chapter from the given element. - * - * @param element an element obtained from [chapterListSelector]. - */ - protected open fun nullableChapterFromElement(element: Element): SChapter? { - val chapter = SChapter.create() + val splits = initialName.split(":", limit = 2).map { it.trim() } - try { - val titleWrapper = element.select("[class^=chapter-title-rtl]").first()!! - // Some websites add characters after "..-rtl" thus the need of checking classes that starts with that - val url = titleWrapper.getElementsByTag("a") - .first { it.attr("href").contains(urlRegex) } - .attr("href") - - // Ensure chapter actually links to a manga - // Some websites use the chapters box to link to post announcements - // The check is skipped if mangas are stored in the root of the website (ex '/one-piece' without a segment like '/manga/one-piece') - if (itemUrlPath != null && !Uri.parse(url).pathSegments.firstOrNull().equals(itemUrlPath, true)) { - return null - } - - chapter.url = getUrlWithoutBaseUrl(url) - chapter.name = titleWrapper.text() - - // Parse date - val dateText = element.getElementsByClass("date-chapter-title-rtl").text().trim() - chapter.date_upload = parseDate(dateText) - - return chapter - } catch (e: NullPointerException) { - // For chapter list in a table - if (element.select("td").hasText()) { - element.select("td a").let { - chapter.setUrlWithoutDomain(it.attr("href")) - chapter.name = it.text() - } - val tableDateText = element.select("td + td").text() - chapter.date_upload = parseDate(tableDateText) - - return chapter - } - } - - return null - } - - private fun parseDate(dateText: String): Long { - return try { - DATE_FORMAT.parse(dateText)?.time ?: 0 - } catch (e: ParseException) { - 0L + return if (splits[0] == splits[1]) { + splits[0] + } else { + "${splits[0]}: ${splits[1]}" } } - override fun pageListParse(response: Response) = response.asJsoup().select("#all > .img-responsive") - .mapIndexed { i, e -> - var url = (if (e.hasAttr("data-src")) e.attr("abs:data-src") else e.attr("abs:src")).trim() - - Page(i, response.request.url.toString(), url) + override fun pageListParse(document: Document) = + document.select("#all > img.img-responsive").mapIndexed { i, it -> + Page(i, imageUrl = it.imgAttr()) } - override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() + override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() - private fun getInitialFilterList() = listOf>( - Filter.Header("NOTE: Ignored if using text search!"), - Filter.Separator(), - AuthorFilter(), - UriSelectFilter( - "Category", - "cat", - arrayOf( - "" to "Any", - *categoryMappings.toTypedArray(), - ), - ), - UriSelectFilter( - "Begins with", - "alpha", - arrayOf( - "" to "Any", - *"#ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray().map { - Pair(it.toString(), it.toString()) - }.toTypedArray(), - ), - ), - SortFilter(), - ) - - /** - * Returns the list of filters for the source. - */ override fun getFilterList(): FilterList { - return when { - tagMappings != emptyList>() -> { - FilterList( - getInitialFilterList() + UriSelectFilter( - "Tag", - "tag", + val filters = buildList> { + add(Filter.Header("Note: Ignored if using text search!")) + + if (supportsAdvancedSearch) { + if (fetchFilterOptions && fetchFiltersAttempts > 0 && fetchFiltersFailed) { + add(Filter.Header("Press 'Reset' to attempt to show filter options")) + } + + add(Filter.Separator()) + + if (categories.isNotEmpty()) { + add( + UriMultiSelectFilter( + "Categories", + "categories[]", + categories.toTypedArray(), + ), + ) + } + + if (statuses.isNotEmpty()) { + add( + UriMultiSelectFilter( + "Statuses", + "status[]", + statuses.toTypedArray(), + ), + ) + } + + if (tags.isNotEmpty()) { + add( + UriMultiSelectFilter( + "Types", + "types[]", + tags.toTypedArray(), + ), + ) + } + + add(TextFilter("Year of release", "release")) + add(TextFilter("Author", "author")) + } else { + if (fetchFilterOptions && fetchFiltersAttempts > 0 && fetchFiltersFailed) { + add(Filter.Header("Press 'Reset' to attempt to show filter options")) + } + + add(Filter.Separator()) + + if (categories.isNotEmpty()) { + add( + UriPartFilter( + "Category", + "cat", + arrayOf( + "Any" to "", + *categories.toTypedArray(), + ), + ), + ) + } + + add( + UriPartFilter( + "Title begins with", + "alpha", arrayOf( - "" to "Any", - *tagMappings.toTypedArray(), + "Any" to "", + *"#ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray().map { + Pair(it.toString(), it.toString()) + }.toTypedArray(), ), ), ) - } - else -> FilterList(getInitialFilterList()) - } - } - /** - * Class that creates a select filter. Each entry in the dropdown has a name and a display name. - * If an entry is selected it is appended as a query parameter onto the end of the URI. - * If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI. - */ - // vals: - open class UriSelectFilter( - displayName: String, - private val uriParam: String, - private val vals: Array>, - private val firstIsUnspecified: Boolean = true, - defaultValue: Int = 0, - ) : - Filter.Select(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter { - override fun addToUri(uri: Uri.Builder) { - if (state != 0 || !firstIsUnspecified) { - uri.appendQueryParameter(uriParam, vals[state].first) + if (tags.isNotEmpty()) { + add( + UriPartFilter( + "Tag", + "tag", + arrayOf( + "Any" to "", + *tags.toTypedArray(), + ), + ), + ) + } + + add(SortFilter()) } } + + return FilterList(filters) } - class AuthorFilter : Filter.Text("Author"), UriFilter { - override fun addToUri(uri: Uri.Builder) { - uri.appendQueryParameter("author", state) - } - } + private var categories = emptyList>() - class SortFilter : - Filter.Sort( - "Sort", - sortables.map { it.second }.toTypedArray(), - Selection(0, true), - ), - UriFilter { - override fun addToUri(uri: Uri.Builder) { - uri.appendQueryParameter("sortBy", sortables[state!!.index].first) - uri.appendQueryParameter("asc", state!!.ascending.toString()) + private var statuses = emptyList>() + + private var tags = emptyList>() + + private var fetchFiltersFailed = false + + private var fetchFiltersAttempts = 0 + + private val fetchFiltersLock = ReentrantLock() + + protected open fun fetchFilterOptions(): Subscription = Single.fromCallable { + if (!fetchFilterOptions) { + return@fromCallable } - companion object { - private val sortables = arrayOf( - "name" to "Name", - "views" to "Popularity", - "last_release" to "Last update", - ) + fetchFiltersLock.lock() + + if (fetchFiltersAttempts > 3 || (fetchFiltersAttempts > 0 && !fetchFiltersFailed)) { + fetchFiltersLock.unlock() + return@fromCallable } - } - /** - * Represents a filter that is able to modify a URI. - */ - interface UriFilter { - fun addToUri(uri: Uri.Builder) - } + fetchFiltersFailed = try { + if (supportsAdvancedSearch) { + val document = client.newCall(GET("$baseUrl/advanced-search", headers)).execute().asJsoup() - companion object { - private val DATE_FORMAT = SimpleDateFormat("d MMM. yyyy", Locale.US) + categories = document.select("select[name='categories[]'] option").map { + it.text() to it.attr("value") + } + statuses = document.select("select[name='status[]'] option").map { + it.text() to it.attr("value") + } + tags = document.select("select[name='types[]'] option").map { + it.text() to it.attr("value") + } + } else { + val document = client.newCall(GET("$baseUrl/$itemPath-list", headers)).execute().asJsoup() + + categories = document.select("a.category").map { + it.text() to it.attr("href").toHttpUrl().queryParameter("cat")!! + } + tags = document.select("div.tag-links a").map { + it.text() to it.attr("href").toHttpUrl().pathSegments.last() + } + } + + false + } catch (e: Throwable) { + Log.e(name, "Could not fetch filtering options", e) + true + } + + fetchFiltersAttempts++ + fetchFiltersLock.unlock() } + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.io()) + .subscribe() } diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSDto.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSDto.kt new file mode 100644 index 000000000..7791759c7 --- /dev/null +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSDto.kt @@ -0,0 +1,14 @@ +package eu.kanade.tachiyomi.multisrc.mmrcms + +import kotlinx.serialization.Serializable + +@Serializable +data class SearchResultDto( + val suggestions: List, +) + +@Serializable +data class SuggestionDto( + val value: String, + val data: String, +) diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSFilters.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSFilters.kt new file mode 100644 index 000000000..d144cb505 --- /dev/null +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSFilters.kt @@ -0,0 +1,73 @@ +package eu.kanade.tachiyomi.multisrc.mmrcms + +import eu.kanade.tachiyomi.source.model.Filter +import okhttp3.HttpUrl + +interface UriFilter { + fun addToUri(builder: HttpUrl.Builder) +} + +class TextFilter(name: String, private val param: String) : Filter.Text(name), UriFilter { + override fun addToUri(builder: HttpUrl.Builder) { + builder.addQueryParameter(param, state) + } +} + +class UriPartFilter( + name: String, + private val param: String, + private val vals: Array>, + private val firstIsUnspecified: Boolean = true, + defaultValue: Int = 0, +) : Filter.Select(name, vals.map { it.first }.toTypedArray(), defaultValue), UriFilter { + override fun addToUri(builder: HttpUrl.Builder) { + if (state == 0 && firstIsUnspecified) { + return + } + + builder.addQueryParameter(param, vals[state].second) + } +} + +class UriMultiSelectOption(name: String, val value: String) : Filter.CheckBox(name) + +class UriMultiSelectFilter( + name: String, + private val param: String, + private val vals: Array>, +) : Filter.Group(name, vals.map { UriMultiSelectOption(it.first, it.second) }), UriFilter { + override fun addToUri(builder: HttpUrl.Builder) { + val checked = state.filter { it.state } + + if (checked.isEmpty()) { + return + } + + checked.forEach { builder.addQueryParameter(param, it.value) } + } +} + +class SortFilter(selection: Selection = Selection(0, true)) : + Filter.Sort( + "Sort by", + sortables.map { it.second }.toTypedArray(), + selection, + ), + UriFilter { + override fun addToUri(builder: HttpUrl.Builder) { + val state = state!! + + builder.apply { + addQueryParameter("sortBy", sortables[state.index].first) + addQueryParameter("asc", state.ascending.toString()) + } + } + + companion object { + private val sortables = arrayOf( + "name" to "Name", + "views" to "Popularity", + "last_release" to "Last update", + ) + } +} diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSGenerator.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSGenerator.kt index 410091893..35a86d1bf 100644 --- a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSGenerator.kt +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSGenerator.kt @@ -9,26 +9,21 @@ class MMRCMSGenerator : ThemeSourceGenerator { override val themeClass = "MMRCMS" - override val baseVersionCode = 7 + override val baseVersionCode = 8 override val sources = listOf( - SingleLang("مانجا اون لاين", "https://onma.top", "ar", className = "onma"), + SingleLang("مانجا اون لاين", "https://onma.top", "ar", className = "Onma"), SingleLang("Read Comics Online", "https://readcomicsonline.ru", "en"), - SingleLang("Scan FR", "https://www.scan-fr.org", "fr", overrideVersionCode = 2), SingleLang("Scan VF", "https://www.scan-vf.net", "fr", overrideVersionCode = 1), SingleLang("Komikid", "https://www.komikid.com", "id"), - SingleLang("Mangadoor", "https://mangadoor.com", "es", overrideVersionCode = 1), + SingleLang("Mangadoor", "https://mangadoor.com", "es", overrideVersionCode = 1, isNsfw = true), SingleLang("Mangas.in", "https://mangas.in", "es", isNsfw = true, className = "MangasIn", overrideVersionCode = 2), - SingleLang("Utsukushii", "https://manga.utsukushii-bg.com", "bg", overrideVersionCode = 1), - SingleLang("Phoenix-Scans", "https://phoenix-scans.pl", "pl", className = "PhoenixScans", overrideVersionCode = 1), + SingleLang("Utsukushii", "https://utsukushii-bg.com", "bg", overrideVersionCode = 1), SingleLang("Lelscan-VF", "https://lelscanvf.cc", "fr", className = "LelscanVF", overrideVersionCode = 2), SingleLang("MangaID", "https://mangaid.click", "id", overrideVersionCode = 1), SingleLang("Jpmangas", "https://jpmangas.xyz", "fr", overrideVersionCode = 2), - SingleLang("Manga-FR", "https://manga-fr.cc", "fr", className = "MangaFR", overrideVersionCode = 2), SingleLang("Manga-Scan", "https://mangascan-fr.com", "fr", className = "MangaScan", overrideVersionCode = 4), SingleLang("Bentoscan", "https://bentoscan.com", "fr"), - // NOTE: THIS SOURCE CONTAINS A CUSTOM LANGUAGE SYSTEM (which will be ignored)! - SingleLang("HentaiShark", "https://www.hentaishark.com", "all", isNsfw = true), ) companion object { diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSJsonGen.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSJsonGen.kt deleted file mode 100644 index 73f8d05fd..000000000 --- a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSJsonGen.kt +++ /dev/null @@ -1,225 +0,0 @@ -package eu.kanade.tachiyomi.multisrc.mmrcms - -import android.annotation.SuppressLint -import android.annotation.TargetApi -import android.os.Build -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import okhttp3.OkHttpClient -import okhttp3.Request -import org.jsoup.Jsoup -import org.jsoup.nodes.Document -import java.io.PrintWriter -import java.security.cert.CertificateException -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.util.concurrent.TimeUnit -import javax.net.ssl.SSLContext -import javax.net.ssl.TrustManager -import javax.net.ssl.X509TrustManager - -/** - * This class generates the sources for MMRCMS. - * Credit to nulldev for writing the original shell script - * - * CMS: https://getcyberworks.com/product/manga-reader-cms/ - */ -class MMRCMSJsonGen { - // private var preRunTotal: String - - init { - System.setProperty("https.protocols", "TLSv1,TLSv1.1,TLSv1.2,TLSv1.3") - // preRunTotal = Regex("""-> (\d+)""").findAll(File(relativePath).readText(Charsets.UTF_8)).last().groupValues[1] - } - - @TargetApi(Build.VERSION_CODES.O) - fun generate() { - val buffer = StringBuffer() - val dateTime = ZonedDateTime.now() - val formattedDate = dateTime.format(DateTimeFormatter.RFC_1123_DATE_TIME) - buffer.append("package eu.kanade.tachiyomi.multisrc.mmrcms") - buffer.append("\n\n// GENERATED FILE, DO NOT MODIFY!\n// Generated $formattedDate\n\n") - buffer.append("object SourceData {\n") - buffer.append(" fun giveMetaData(url: String) = when (url) {\n") - var number = 1 - sources.forEach { - println("Generating ${it.name}") - try { - val advancedSearchDocument = getDocument("${it.baseUrl}/advanced-search", false) - - var parseCategories = mutableListOf>() - if (advancedSearchDocument != null) { - parseCategories = parseCategories(advancedSearchDocument) - } - - val homePageDocument = getDocument(it.baseUrl) - - val itemUrl = getItemUrl(homePageDocument, it.baseUrl) - - var prefix = itemUrl.substringAfterLast("/").substringBeforeLast("/") - - // Sometimes itemUrl is the root of the website, and thus the prefix found is the website address. - // In this case, we set the default prefix as "manga". - if (prefix.startsWith("www") || prefix.startsWith("wwv")) { - prefix = "manga" - } - - val mangaListDocument = getDocument("${it.baseUrl}/$prefix-list")!! - - if (parseCategories.isEmpty()) { - parseCategories = parseCategories(mangaListDocument) - } - - val tags = parseTags(mangaListDocument) - - val source = SourceDataModel( - name = it.name, - base_url = it.baseUrl, - supports_latest = supportsLatest(it.baseUrl), - item_url = "$itemUrl/", - categories = parseCategories, - tags = if (tags.size in 1..49) tags else null, - ) - - if (!itemUrl.startsWith(it.baseUrl)) println("**Note: ${it.name} URL does not match! Check for changes: \n ${it.baseUrl} vs $itemUrl") - - buffer.append(" \"${it.baseUrl}\" -> \"\"\"${Json.encodeToString(source)}\"\"\"\n") - number++ - } catch (e: Exception) { - println("error generating source ${it.name} ${e.printStackTrace()}") - } - } - - buffer.append(" else -> \"\"\n") - buffer.append(" }\n") - buffer.append("}\n") - // println("Pre-run sources: $preRunTotal") - println("Post-run sources: ${number - 1}") - PrintWriter(relativePath).use { - it.write(buffer.toString()) - } - } - - private fun getDocument(url: String, printStackTrace: Boolean = true): Document? { - val serverCheck = arrayOf("cloudflare-nginx", "cloudflare") - - try { - val request = Request.Builder().url(url) - getOkHttpClient().newCall(request.build()).execute().let { response -> - // Bypass Cloudflare ("Please wait 5 seconds" page) - if (response.code == 503 && response.header("Server") in serverCheck) { - var cookie = "${response.header("Set-Cookie")!!.substringBefore(";")}; " - Jsoup.parse(response.body.string()).let { document -> - val path = document.select("[id=\"challenge-form\"]").attr("action") - val chk = document.select("[name=\"s\"]").attr("value") - getOkHttpClient().newCall(Request.Builder().url("$url/$path?s=$chk").build()).execute().let { solved -> - cookie += solved.header("Set-Cookie")!!.substringBefore(";") - request.addHeader("Cookie", cookie).build().let { - return Jsoup.parse(getOkHttpClient().newCall(it).execute().body.string()) - } - } - } - } - if (response.code == 200) { - return Jsoup.parse(response.body.string()) - } - } - } catch (e: Exception) { - if (printStackTrace) { - e.printStackTrace() - } - } - return null - } - - private fun parseTags(mangaListDocument: Document): List> { - val elements = mangaListDocument.select("div.tag-links a") - return elements.map { - mapOf( - "id" to it.attr("href").substringAfterLast("/"), - "name" to it.text(), - ) - } - } - - private fun getItemUrl(document: Document?, url: String): String { - document ?: throw Exception("Couldn't get document for: $url") - return document.toString().substringAfter("showURL = \"").substringAfter("showURL=\"").substringBefore("/SELECTION\";") - - // Some websites like mangasyuri use javascript minifiers, and thus "showURL = " becomes "showURL="https://mangasyuri.net/manga/SELECTION"" - // (without spaces). Hence the double substringAfter. - } - - private fun supportsLatest(third: String): Boolean { - val document = getDocument("$third/latest-release?page=1", false) ?: return false - return document.select("div.mangalist div.manga-item a, div.grid-manga tr").isNotEmpty() - } - - private fun parseCategories(document: Document): MutableList> { - val elements = document.select("select[name^=categories] option, a.category") - return elements.mapIndexed { index, element -> - mapOf( - "id" to (index + 1).toString(), - "name" to element.text(), - ) - }.toMutableList() - } - - @Throws(Exception::class) - private fun getOkHttpClient(): OkHttpClient { - // Create all-trusting host name verifier - val trustAllCerts = arrayOf( - object : X509TrustManager { - @SuppressLint("TrustAllX509TrustManager") - @Throws(CertificateException::class) - override fun checkClientTrusted(chain: Array, authType: String) { - } - - @SuppressLint("TrustAllX509TrustManager") - @Throws(CertificateException::class) - override fun checkServerTrusted(chain: Array, authType: String) { - } - - override fun getAcceptedIssuers(): Array { - return arrayOf() - } - }, - ) - - // Install the all-trusting trust manager - val sc = SSLContext.getInstance("SSL").apply { - init(null, trustAllCerts, java.security.SecureRandom()) - } - val sslSocketFactory = sc.socketFactory - - return OkHttpClient.Builder() - .sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager) - .hostnameVerifier { _, _ -> true } - .connectTimeout(1, TimeUnit.MINUTES) - .readTimeout(1, TimeUnit.MINUTES) - .writeTimeout(1, TimeUnit.MINUTES) - .build() - } - - @Serializable - private data class SourceDataModel( - val name: String, - val base_url: String, - val supports_latest: Boolean, - val item_url: String, - val categories: List>, - val tags: List>? = null, - ) - - companion object { - val sources = MMRCMSGenerator().sources - - val relativePath = System.getProperty("user.dir")!! + "/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/SourceData.kt" - - @JvmStatic - fun main(args: Array) { - MMRCMSJsonGen().generate() - } - } -} diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSUtils.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSUtils.kt new file mode 100644 index 000000000..a8e460a2f --- /dev/null +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/MMRCMSUtils.kt @@ -0,0 +1,27 @@ +package eu.kanade.tachiyomi.multisrc.mmrcms + +import org.jsoup.nodes.Element +import org.jsoup.select.Elements + +object MMRCMSUtils { + fun guessCover(baseUrl: String, mangaUrl: String, url: String?): String { + return if (url == null || url.endsWith("no-image.png")) { + "$baseUrl/uploads/manga/${mangaUrl.substringAfterLast('/')}/cover/cover_250x350.jpg" + } else { + url + } + } + + fun Element.imgAttr(): String = when { + hasAttr("data-background-image") -> absUrl("data-background-image") + hasAttr("data-cfsrc") -> absUrl("data-cfsrc") + hasAttr("data-lazy-src") -> absUrl("data-lazy-src") + hasAttr("data-src") -> absUrl("data-src") + else -> absUrl("src") + } + + fun Elements.textWithNewlines() = run { + select("p, br").prepend("\\n") + text().replace("\\n", "\n").replace("\n ", "\n") + } +} diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/SourceData.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/SourceData.kt deleted file mode 100644 index c4f541f58..000000000 --- a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mmrcms/SourceData.kt +++ /dev/null @@ -1,28 +0,0 @@ -package eu.kanade.tachiyomi.multisrc.mmrcms - -// GENERATED FILE, DO NOT MODIFY! -// Generated Sun, 16 Apr 2022 14:18:00 GMT - -object SourceData { - fun giveMetaData(url: String) = when (url) { - "https://onma.top" -> """{"name":"مانجا اون لاين","base_url":"https://onma.top","supports_latest":true,"item_url":"https://onma.top/manga/","categories":[{"id":"1","name":"أكشن"},{"id":"2","name":"مغامرة"},{"id":"3","name":"كوميدي"},{"id":"4","name":"شياطين"},{"id":"5","name":"دراما"},{"id":"6","name":"إيتشي"},{"id":"7","name":"خيال"},{"id":"8","name":"انحراف جنسي"},{"id":"9","name":"حريم"},{"id":"10","name":"تاريخي"},{"id":"11","name":"رعب"},{"id":"12","name":"جوسي"},{"id":"13","name":"فنون قتالية"},{"id":"14","name":"ناضج"},{"id":"15","name":"ميكا"},{"id":"16","name":"غموض"},{"id":"17","name":"وان شوت"},{"id":"18","name":"نفسي"},{"id":"19","name":"رومنسي"},{"id":"20","name":"حياة مدرسية"},{"id":"21","name":"خيال علمي"},{"id":"22","name":"سينين"},{"id":"23","name":"شوجو"},{"id":"24","name":"شوجو أي"},{"id":"25","name":"شونين"},{"id":"26","name":"شونين أي"},{"id":"27","name":"شريحة من الحياة"},{"id":"28","name":"رياضة"},{"id":"29","name":"خارق للطبيعة"},{"id":"30","name":"مأساة"},{"id":"31","name":"مصاصي الدماء"},{"id":"32","name":"سحر"},{"id":"33","name":"ويب تون"},{"id":"34","name":"دوجينشي"}]}""" - "https://readcomicsonline.ru" -> """{"name":"Read Comics Online","base_url":"https://readcomicsonline.ru","supports_latest":true,"item_url":"https://readcomicsonline.ru/comic/","categories":[{"id":"1","name":"One Shots \u0026 TPBs"},{"id":"2","name":"DC Comics"},{"id":"3","name":"Marvel Comics"},{"id":"4","name":"Boom Studios"},{"id":"5","name":"Dynamite"},{"id":"6","name":"Rebellion"},{"id":"7","name":"Dark Horse"},{"id":"8","name":"IDW"},{"id":"9","name":"Archie"},{"id":"10","name":"Graphic India"},{"id":"11","name":"Darby Pop"},{"id":"12","name":"Oni Press"},{"id":"13","name":"Icon Comics"},{"id":"14","name":"United Plankton"},{"id":"15","name":"Udon"},{"id":"16","name":"Image Comics"},{"id":"17","name":"Valiant"},{"id":"18","name":"Vertigo"},{"id":"19","name":"Devils Due"},{"id":"20","name":"Aftershock Comics"},{"id":"21","name":"Antartic Press"},{"id":"22","name":"Action Lab"},{"id":"23","name":"American Mythology"},{"id":"24","name":"Zenescope"},{"id":"25","name":"Top Cow"},{"id":"26","name":"Hermes Press"},{"id":"27","name":"451"},{"id":"28","name":"Black Mask"},{"id":"29","name":"Chapterhouse Comics"},{"id":"30","name":"Red 5"},{"id":"31","name":"Heavy Metal"},{"id":"32","name":"Bongo"},{"id":"33","name":"Top Shelf"},{"id":"34","name":"Bubble"},{"id":"35","name":"Boundless"},{"id":"36","name":"Avatar Press"},{"id":"37","name":"Space Goat Productions"},{"id":"38","name":"BroadSword Comics"},{"id":"39","name":"AAM-Markosia"},{"id":"40","name":"Fantagraphics"},{"id":"41","name":"Aspen"},{"id":"42","name":"American Gothic Press"},{"id":"43","name":"Vault"},{"id":"44","name":"215 Ink"},{"id":"45","name":"Abstract Studio"},{"id":"46","name":"Albatross"},{"id":"47","name":"ARH Comix"},{"id":"48","name":"Legendary Comics"},{"id":"49","name":"Monkeybrain"},{"id":"50","name":"Joe Books"},{"id":"51","name":"MAD"},{"id":"52","name":"Comics Experience"},{"id":"53","name":"Alterna Comics"},{"id":"54","name":"Lion Forge"},{"id":"55","name":"Benitez"},{"id":"56","name":"Storm King"},{"id":"57","name":"Sucker"},{"id":"58","name":"Amryl Entertainment"},{"id":"59","name":"Ahoy Comics"},{"id":"60","name":"Mad Cave"},{"id":"61","name":"Coffin Comics"},{"id":"62","name":"Magnetic Press"},{"id":"63","name":"Ablaze"},{"id":"64","name":"Europe Comics"},{"id":"65","name":"Humanoids"},{"id":"66","name":"TKO"},{"id":"67","name":"Soleil"},{"id":"68","name":"SAF Comics"},{"id":"69","name":"Scholastic"},{"id":"70","name":"Upshot"},{"id":"71","name":"Stranger Comics"},{"id":"72","name":"Inverse"},{"id":"73","name":"Virus"}]}""" - "https://zahard.xyz" -> """{"name":"Zahard","base_url":"https://zahard.xyz","supports_latest":true,"item_url":"https://zahard.xyz/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"}]}""" - "https://www.scan-fr.org" -> """{"name":"Scan FR","base_url":"https://www.scan-fr.org","supports_latest":true,"item_url":"https://www.scan-fr.org/manga/","categories":[{"id":"1","name":"Comedy"},{"id":"2","name":"Doujinshi"},{"id":"3","name":"Drama"},{"id":"4","name":"Ecchi"},{"id":"5","name":"Fantasy"},{"id":"6","name":"Gender Bender"},{"id":"7","name":"Josei"},{"id":"8","name":"Mature"},{"id":"9","name":"Mecha"},{"id":"10","name":"Mystery"},{"id":"11","name":"One Shot"},{"id":"12","name":"Psychological"},{"id":"13","name":"Romance"},{"id":"14","name":"School Life"},{"id":"15","name":"Sci-fi"},{"id":"16","name":"Seinen"},{"id":"17","name":"Shoujo"},{"id":"18","name":"Shoujo Ai"},{"id":"19","name":"Shounen"},{"id":"20","name":"Shounen Ai"},{"id":"21","name":"Slice of Life"},{"id":"22","name":"Sports"},{"id":"23","name":"Supernatural"},{"id":"24","name":"Tragedy"},{"id":"25","name":"Yaoi"},{"id":"26","name":"Yuri"},{"id":"27","name":"Comics"},{"id":"28","name":"Autre"},{"id":"29","name":"BD Occidentale"},{"id":"30","name":"Manhwa"},{"id":"31","name":"Action"},{"id":"32","name":"Aventure"}]}""" - "https://www.scan-vf.net" -> """{"name":"Scan VF","base_url":"https://www.scan-vf.net","supports_latest":true,"item_url":"https://www.scan-vf.net/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"}]}""" - "https://www.komikid.com" -> """{"name":"Komikid","base_url":"https://www.komikid.com","supports_latest":true,"item_url":"https://www.komikid.com/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Fantasy"},{"id":"7","name":"Gender Bender"},{"id":"8","name":"Historical"},{"id":"9","name":"Horror"},{"id":"10","name":"Josei"},{"id":"11","name":"Martial Arts"},{"id":"12","name":"Mature"},{"id":"13","name":"Mecha"},{"id":"14","name":"Mystery"},{"id":"15","name":"One Shot"},{"id":"16","name":"Psychological"},{"id":"17","name":"Romance"},{"id":"18","name":"School Life"},{"id":"19","name":"Sci-fi"},{"id":"20","name":"Seinen"},{"id":"21","name":"Shoujo"},{"id":"22","name":"Shoujo Ai"},{"id":"23","name":"Shounen"},{"id":"24","name":"Shounen Ai"},{"id":"25","name":"Slice of Life"},{"id":"26","name":"Sports"},{"id":"27","name":"Supernatural"},{"id":"28","name":"Tragedy"},{"id":"29","name":"Yaoi"},{"id":"30","name":"Yuri"}]}""" - "http://azbivo.webd.pro" -> """{"name":"Nikushima","base_url":"http://azbivo.webd.pro","supports_latest":false,"item_url":"\u003chtml\u003e \n \u003chead\u003e \n \u003cmeta http-equiv\u003d\"Content-Language\" content\u003d\"pl\"\u003e \n \u003cmeta http-equiv name\u003d\"pragma\" content\u003d\"no-cache\"\u003e \n \u003clink href\u003d\"style/style.css\" rel\u003d\"stylesheet\" type\u003d\"text/css\"\u003e \n \u003cmeta http-equiv\u003d\"Refresh\" content\u003d\"0; url\u003dhttps://www.webd.pl/_errnda.php?utm_source\u003dwn07\u0026amp;utm_medium\u003dwww\u0026amp;utm_campaign\u003dblock\"\u003e \n \u003cmeta name\u003d\"Robots\" content\u003d\"index, follow\"\u003e \n \u003cmeta name\u003d\"revisit-after\" content\u003d\"2 days\"\u003e \n \u003cmeta name\u003d\"rating\" content\u003d\"general\"\u003e \n \u003cmeta name\u003d\"keywords\" content\u003d\"STRONA ZAWIESZONA, WEBD, DOMENY, DOMENA, HOSTING, SERWER, INTERNET, PHP, MySQL, FTP, WEBMASTER, SERWERY WIRTUALNE, WWW, KONTO, MAIL, POCZTA, E-MAIL, NET, .COM, .ORG, TANIE, PHP+MySQL, DOMENY, DOMENA, HOSTING, SERWER, INTERNET, PHP, MySQL, FTP, WEBMASTER, SERWERY WIRTUALNE, WWW, KONTO, MAIL, POCZTA, E-MAIL, DOMENY, DOMENA, NET, .COM, .ORG, TANIE, PHP+MySQL, HOSTING, SERWER, INTERNET, PHP, MySQL, FTP, WEBMASTER, SERWERY WIRTUALNE, WWW, KONTO, MAIL, POCZTA, E-MAIL, NET, .COM, .ORG, TANIE, PHP+MySQL\"\u003e \n \u003cmeta name\u003d\"description\" content\u003d\"STRONA ZAWIESZONA - Oferujemy profesjonalny hosting z PHP + MySQL, rejestrujemy domeny. Sprawdz nasz hosting i przetestuj nasze serwery. Kupuj tanio domeny i serwery!\"\u003e \n \u003ctitle\u003eSTRONA ZAWIESZONA - WEBD.PL - Tw�j profesjonalny hosting za jedyne 4.99PLN! Serwery z PHP+MySQL, tanie domeny, serwer + domena .pl - taniej sie nie da!\u003c/title\u003e \n \u003cscript type\u003d\"text/javascript\"\u003e\nfunction init() {\n if (!document.getElementById) return\n var imgOriginSrc;\n var imgTemp \u003d new Array();\n var imgarr \u003d document.getElementsByTagName(\u0027img\u0027);\n for (var i \u003d 0; i \u003c imgarr.length; i++) {\n if (imgarr[i].getAttribute(\u0027hsrc\u0027)) {\n imgTemp[i] \u003d new Image();\n imgTemp[i].src \u003d imgarr[i].getAttribute(\u0027hsrc\u0027);\n imgarr[i].onmouseover \u003d function() {\n imgOriginSrc \u003d this.getAttribute(\u0027src\u0027);\n this.setAttribute(\u0027src\u0027,this.getAttribute(\u0027hsrc\u0027))\n }\n imgarr[i].onmouseout \u003d function() {\n this.setAttribute(\u0027src\u0027,imgOriginSrc)\n }\n }\n }\n}\nonload\u003dinit;\n\u003c/script\u003e \n \u003c/head\u003e \n \u003cbody\u003e\n Trwa przekierowanie .... \u0026gt;\u0026gt;\u0026gt;\u0026gt; \u003c!--\n--\u003e \n \u003c/body\u003e\n\u003c/html\u003e/","categories":[]}""" - "https://mangadoor.com" -> """{"name":"Mangadoor","base_url":"https://mangadoor.com","supports_latest":true,"item_url":"https://mangadoor.com/manga/","categories":[{"id":"1","name":"Acción"},{"id":"2","name":"Aventura"},{"id":"3","name":"Comedia"},{"id":"4","name":"Drama"},{"id":"5","name":"Ecchi"},{"id":"6","name":"Fantasía"},{"id":"7","name":"Gender Bender"},{"id":"8","name":"Harem"},{"id":"9","name":"Histórico"},{"id":"10","name":"Horror"},{"id":"11","name":"Josei"},{"id":"12","name":"Artes Marciales"},{"id":"13","name":"Maduro"},{"id":"14","name":"Mecha"},{"id":"15","name":"Misterio"},{"id":"16","name":"One Shot"},{"id":"17","name":"Psicológico"},{"id":"18","name":"Romance"},{"id":"19","name":"Escolar"},{"id":"20","name":"Ciencia Ficción"},{"id":"21","name":"Seinen"},{"id":"22","name":"Shoujo"},{"id":"23","name":"Shoujo Ai"},{"id":"24","name":"Shounen"},{"id":"25","name":"Shounen Ai"},{"id":"26","name":"Recuentos de la vida"},{"id":"27","name":"Deportes"},{"id":"28","name":"Supernatural"},{"id":"29","name":"Tragedia"},{"id":"30","name":"Yaoi"},{"id":"31","name":"Yuri"},{"id":"32","name":"Demonios"},{"id":"33","name":"Juegos"},{"id":"34","name":"Policial"},{"id":"35","name":"Militar"},{"id":"36","name":"Thriller"},{"id":"37","name":"Autos"},{"id":"38","name":"Música"},{"id":"39","name":"Vampiros"},{"id":"40","name":"Magia"},{"id":"41","name":"Samurai"},{"id":"42","name":"Boys love"},{"id":"43","name":"Hentai"}]}""" - "https://mangas.in" -> """{"name":"Mangas.in","base_url":"https://mangas.in","supports_latest":true,"item_url":"https://mangas.in/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"},{"id":"33","name":"Hentai"},{"id":"34","name":"Smut"}]}""" - "https://manga.utsukushii-bg.com" -> """{"name":"Utsukushii","base_url":"https://manga.utsukushii-bg.com","supports_latest":true,"item_url":"https://manga.utsukushii-bg.com/manga/","categories":[{"id":"1","name":"Екшън"},{"id":"2","name":"Приключенски"},{"id":"3","name":"Комедия"},{"id":"4","name":"Драма"},{"id":"5","name":"Фентъзи"},{"id":"6","name":"Исторически"},{"id":"7","name":"Ужаси"},{"id":"8","name":"Джосей"},{"id":"9","name":"Бойни изкуства"},{"id":"10","name":"Меха"},{"id":"11","name":"Мистерия"},{"id":"12","name":"Самостоятелна/Пилотна глава"},{"id":"13","name":"Психологически"},{"id":"14","name":"Романтика"},{"id":"15","name":"Училищни"},{"id":"16","name":"Научна фантастика"},{"id":"17","name":"Сейнен"},{"id":"18","name":"Шоджо"},{"id":"19","name":"Реализъм"},{"id":"20","name":"Спорт"},{"id":"21","name":"Свръхестествено"},{"id":"22","name":"Трагедия"},{"id":"23","name":"Йокаи"},{"id":"24","name":"Паралелна вселена"},{"id":"25","name":"Супер сили"},{"id":"26","name":"Пародия"},{"id":"27","name":"Шонен"}]}""" - "https://phoenix-scans.pl" -> """{"name":"Phoenix-Scans","base_url":"https://phoenix-scans.pl","supports_latest":true,"item_url":"https://phoenix-scans.pl/manga/","categories":[{"id":"1","name":"Shounen"},{"id":"2","name":"Tragedia"},{"id":"3","name":"Szkolne życie"},{"id":"4","name":"Romans"},{"id":"5","name":"Zagadka"},{"id":"6","name":"Horror"},{"id":"7","name":"Dojrzałe"},{"id":"8","name":"Psychologiczne"},{"id":"9","name":"Przygodowe"},{"id":"10","name":"Akcja"},{"id":"11","name":"Komedia"},{"id":"12","name":"Zboczone"},{"id":"13","name":"Fantasy"},{"id":"14","name":"Harem"},{"id":"15","name":"Historyczne"},{"id":"16","name":"Manhua"},{"id":"17","name":"Manhwa"},{"id":"18","name":"Sztuki walki"},{"id":"19","name":"One shot"},{"id":"20","name":"Sci fi"},{"id":"21","name":"Seinen"},{"id":"22","name":"Shounen ai"},{"id":"23","name":"Spokojne życie"},{"id":"24","name":"Sport"},{"id":"25","name":"Nadprzyrodzone"},{"id":"26","name":"Webtoons"},{"id":"27","name":"Dramat"},{"id":"28","name":"Hentai"},{"id":"29","name":"Mecha"},{"id":"30","name":"Gender Bender"},{"id":"31","name":"Gry"},{"id":"32","name":"Yaoi"}],"tags":[{"id":"aktywne","name":"aktywne"},{"id":"zakonczone","name":"zakończone"},{"id":"porzucone","name":"porzucone"},{"id":"zawieszone","name":"zawieszone"},{"id":"zlicencjonowane","name":"zlicencjonowane"},{"id":"hentai","name":"Hentai"}]}""" - "https://lelscanvf.cc" -> """{"name":"Lelscan-VF","base_url":"https://lelscanvf.cc","supports_latest":true,"item_url":"https://lelscanvf.cc/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"}]}""" - "https://mangaid.click" -> """{"name":"MangaID","base_url":"https://mangaid.click","supports_latest":true,"item_url":"https://mangaid.click/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"Psychological"},{"id":"18","name":"Romance"},{"id":"19","name":"School Life"},{"id":"20","name":"Sci-fi"},{"id":"21","name":"Seinen"},{"id":"22","name":"Shoujo"},{"id":"23","name":"Shoujo Ai"},{"id":"24","name":"Shounen"},{"id":"25","name":"Shounen Ai"},{"id":"26","name":"Slice of Life"},{"id":"27","name":"Sports"},{"id":"28","name":"Supernatural"},{"id":"29","name":"Tragedy"},{"id":"30","name":"Yaoi"},{"id":"31","name":"Yuri"},{"id":"32","name":"School"},{"id":"33","name":"Isekai"},{"id":"34","name":"Military"}]}""" - "https://jpmangas.xyz" -> """{"name":"Jpmangas","base_url":"https://jpmangas.xyz","supports_latest":true,"item_url":"https://jpmangas.xyz/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Adventure"},{"id":"3","name":"Comedy"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drama"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historical"},{"id":"11","name":"Horror"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"}]}""" - "https://www.hentaishark.com" -> """{"name":"HentaiShark","base_url":"https://www.hentaishark.com","supports_latest":true,"item_url":"https://www.hentaishark.com/manga/","categories":[{"id":"1","name":"Doujinshi"},{"id":"2","name":"Manga"},{"id":"3","name":"Western"},{"id":"4","name":"non-h"},{"id":"5","name":"imageset"},{"id":"6","name":"artistcg"},{"id":"7","name":"misc"}]}""" - "https://manga-fr.cc" -> """{"name":"Manga-FR","base_url":"https://manga-fr.cc","supports_latest":true,"item_url":"https://manga-fr.cc/lecture-en-ligne/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Aventure"},{"id":"3","name":"Comédie"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drame"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasie"},{"id":"8","name":"Gender Bender"},{"id":"9","name":"Harem"},{"id":"10","name":"Historique"},{"id":"11","name":"Horreur"},{"id":"12","name":"Josei"},{"id":"13","name":"Martial Arts"},{"id":"14","name":"Mature"},{"id":"15","name":"Mecha"},{"id":"16","name":"Mystery"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychological"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Sci-fi"},{"id":"22","name":"Seinen"},{"id":"23","name":"Shoujo"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sports"},{"id":"29","name":"Supernatural"},{"id":"30","name":"Tragédie"},{"id":"31","name":"Yaoi"},{"id":"32","name":"Yuri"},{"id":"33","name":"Fantastique"},{"id":"34","name":"Webtoon"},{"id":"35","name":"Manhwa"},{"id":"36","name":"Amour"},{"id":"37","name":"Combats"},{"id":"38","name":"Amitié"},{"id":"39","name":"Psychologique"},{"id":"40","name":"Magie"}]}""" - "https://mangascan-fr.com" -> """{"name":"Manga-Scan","base_url":"https://mangascan-fr.com","supports_latest":true,"item_url":"https://mangascan-fr.com/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Aventure"},{"id":"3","name":"Comédie"},{"id":"4","name":"Doujinshi"},{"id":"5","name":"Drame"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Webtoon"},{"id":"9","name":"Harem"},{"id":"10","name":"Historique"},{"id":"11","name":"Horreur"},{"id":"12","name":"Thriller"},{"id":"13","name":"Arts Martiaux"},{"id":"14","name":"Mature"},{"id":"15","name":"Tragique"},{"id":"16","name":"Mystère"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychologique"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Science-fiction"},{"id":"22","name":"Seinen"},{"id":"23","name":"Erotique"},{"id":"24","name":"Shoujo Ai"},{"id":"25","name":"Shounen"},{"id":"26","name":"Shounen Ai"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sport"},{"id":"29","name":"Surnaturel"},{"id":"30","name":"Tragedy"},{"id":"31","name":"Gangster"},{"id":"32","name":"Crime"},{"id":"33","name":"Biographique"},{"id":"34","name":"Fantastique"}]}""" - "https://bentoscan.com" -> """{"name":"Bentoscan","base_url":"https://bentoscan.com","supports_latest":true,"item_url":"https://bentoscan.com/manga/","categories":[{"id":"1","name":"Action"},{"id":"2","name":"Aventure"},{"id":"3","name":"Comédie"},{"id":"4","name":"Crime"},{"id":"5","name":"Drame"},{"id":"6","name":"Ecchi"},{"id":"7","name":"Fantasy"},{"id":"8","name":"Fantastique"},{"id":"9","name":"Harem"},{"id":"10","name":"Gangster"},{"id":"11","name":"Erotique"},{"id":"12","name":"Historique"},{"id":"13","name":"Arts Martiaux"},{"id":"14","name":"Mature"},{"id":"15","name":"Horreur"},{"id":"16","name":"Mystère"},{"id":"17","name":"One Shot"},{"id":"18","name":"Psychologique"},{"id":"19","name":"Romance"},{"id":"20","name":"School Life"},{"id":"21","name":"Science-fiction"},{"id":"22","name":"Seinen"},{"id":"23","name":"Suspense"},{"id":"24","name":"Biographique"},{"id":"25","name":"Social"},{"id":"26","name":"Tranche-de-vie"},{"id":"27","name":"Slice of Life"},{"id":"28","name":"Sport"},{"id":"29","name":"Surnaturel"},{"id":"30","name":"Thriller"},{"id":"31","name":"Tragique"},{"id":"32","name":"Webtoon"}]}""" - else -> "" - } -}