Rewrite MMRCMS (#931)
* Rewrite MMRCMS * linting * Mangas.in: Fix latest, search by query, manga details * use HashSet instead of Set for manga detail keys * use buildList for building filter list * mangas.in: Copy over changes to MangasInDto * Use a better metric for determining if filter fetching failed. Also merged types and tags. * Move to using named constructor parameters instead of open vals This improves the discoverability of configurable stuff. * use normal try/catch instead of runCatching * Elaborate on the reason for not using Nothing * Make most configuration options private * forbidden -> useNamedArgumentsBelow * Address lint failures * Close the thingies * <:shitting:1130237162105876490> * <:shitting:1130237162105876490>
| @ -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 <manga_name> <chapter_number> VF: <chapter_number> | ||||
|         // Now    -> Chapitre <chapter_number> : <chapter_title> OR Chapitre <chapter_number> | ||||
|         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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| Before Width: | Height: | Size: 4.0 KiB | 
| Before Width: | Height: | Size: 2.1 KiB | 
| Before Width: | Height: | Size: 5.8 KiB | 
| Before Width: | Height: | Size: 11 KiB | 
| Before Width: | Height: | Size: 18 KiB | 
							
								
								
									
										10
									
								
								multisrc/overrides/mmrcms/jpmangas/src/Jpmangas.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -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, | ||||
| ) | ||||
							
								
								
									
										10
									
								
								multisrc/overrides/mmrcms/komikid/src/Komikid.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -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, | ||||
| ) | ||||
							
								
								
									
										10
									
								
								multisrc/overrides/mmrcms/lelscanvf/src/LelscanVF.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -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, | ||||
| ) | ||||
| @ -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 <manga_name> <chapter_number> VF: <chapter_number> | ||||
|         // Now    -> Chapitre <chapter_number> : <chapter_title> OR Chapitre <chapter_number> | ||||
|         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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -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() | ||||
| 
 | ||||
|  | ||||
| @ -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<LatestUpdateResponse>(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<List<SuggestionDto>>(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<UriFilter>() | ||||
|                     .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<List<SearchResult>>(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<SChapter> { | ||||
|         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}") | ||||
|             } | ||||
|  | ||||
| @ -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<LatestManga>, | ||||
|     val totalPages: Int, | ||||
| ) | ||||
|  | ||||
							
								
								
									
										32
									
								
								multisrc/overrides/mmrcms/onma/src/Onma.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -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 | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| Before Width: | Height: | Size: 10 KiB | 
| Before Width: | Height: | Size: 4.7 KiB | 
| Before Width: | Height: | Size: 17 KiB | 
| Before Width: | Height: | Size: 36 KiB | 
| Before Width: | Height: | Size: 59 KiB | 
| @ -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", | ||||
| ) | ||||
| Before Width: | Height: | Size: 11 KiB | 
| Before Width: | Height: | Size: 4.9 KiB | 
| Before Width: | Height: | Size: 18 KiB | 
| Before Width: | Height: | Size: 40 KiB | 
| Before Width: | Height: | Size: 69 KiB | 
							
								
								
									
										10
									
								
								multisrc/overrides/mmrcms/scanvf/src/ScanVF.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -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, | ||||
| ) | ||||
| @ -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) | ||||
|     } | ||||
|  | ||||
| @ -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() | ||||
| @ -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<JsonObject>(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<Pair<String, String>> = 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<UriFilter>() | ||||
|                     .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<MangasPage> { | ||||
|         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<String>() | ||||
| 
 | ||||
|     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<JsonObject>(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<String>() | ||||
| 
 | ||||
|     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<SuggestionDto>() | ||||
| 
 | ||||
|     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<MangasPage> { | ||||
|         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<UriFilter>().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<SearchResultDto>(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<SManga>() | ||||
|         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<SChapter> { | ||||
|         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<*>>( | ||||
|         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<Pair<String, String>>() -> { | ||||
|                 FilterList( | ||||
|                     getInitialFilterList() + UriSelectFilter( | ||||
|                         "Tag", | ||||
|                         "tag", | ||||
|         val filters = buildList<Filter<*>> { | ||||
|             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: <name, display> | ||||
|     open class UriSelectFilter( | ||||
|         displayName: String, | ||||
|         private val uriParam: String, | ||||
|         private val vals: Array<Pair<String, String>>, | ||||
|         private val firstIsUnspecified: Boolean = true, | ||||
|         defaultValue: Int = 0, | ||||
|     ) : | ||||
|         Filter.Select<String>(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<Pair<String, String>>() | ||||
| 
 | ||||
|     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<Pair<String, String>>() | ||||
| 
 | ||||
|     private var tags = emptyList<Pair<String, String>>() | ||||
| 
 | ||||
|     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() | ||||
| } | ||||
|  | ||||
| @ -0,0 +1,14 @@ | ||||
| package eu.kanade.tachiyomi.multisrc.mmrcms | ||||
| 
 | ||||
| import kotlinx.serialization.Serializable | ||||
| 
 | ||||
| @Serializable | ||||
| data class SearchResultDto( | ||||
|     val suggestions: List<SuggestionDto>, | ||||
| ) | ||||
| 
 | ||||
| @Serializable | ||||
| data class SuggestionDto( | ||||
|     val value: String, | ||||
|     val data: String, | ||||
| ) | ||||
| @ -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<Pair<String, String>>, | ||||
|     private val firstIsUnspecified: Boolean = true, | ||||
|     defaultValue: Int = 0, | ||||
| ) : Filter.Select<String>(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<Pair<String, String>>, | ||||
| ) : Filter.Group<UriMultiSelectOption>(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", | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @ -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 { | ||||
|  | ||||
| @ -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<Map<String, String>>() | ||||
|                 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<Map<String, String>> { | ||||
|         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<Map<String, String>> { | ||||
|         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<TrustManager>( | ||||
|             object : X509TrustManager { | ||||
|                 @SuppressLint("TrustAllX509TrustManager") | ||||
|                 @Throws(CertificateException::class) | ||||
|                 override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>, authType: String) { | ||||
|                 } | ||||
| 
 | ||||
|                 @SuppressLint("TrustAllX509TrustManager") | ||||
|                 @Throws(CertificateException::class) | ||||
|                 override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>, authType: String) { | ||||
|                 } | ||||
| 
 | ||||
|                 override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> { | ||||
|                     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<Map<String, String>>, | ||||
|         val tags: List<Map<String, String>>? = 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<String>) { | ||||
|             MMRCMSJsonGen().generate() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -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") | ||||
|     } | ||||
| } | ||||
| @ -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<54>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 -> "" | ||||
|     } | ||||
| } | ||||
 beerpsi
						beerpsi