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>
This commit is contained in:
beerpsi 2024-02-03 02:58:52 +07:00 committed by Draff
parent 3c0f484afc
commit 0053823dcf
35 changed files with 680 additions and 848 deletions

View File

@ -3,58 +3,21 @@ package eu.kanade.tachiyomi.extension.fr.bentoscan
import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import okhttp3.Request 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 { override fun imageRequest(page: Page): Request {
val newHeaders = headersBuilder() val newHeaders = headersBuilder()
.set("Referer", IMG_URL) .set("Referer", "https://scansmangas.me/")
.set("Accept", "image/avif,image/webp,*/*") .set("Accept", "image/avif,image/webp,*/*")
.build() .build()
return GET(page.imageUrl!!, newHeaders) 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)
}
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View 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,
)

View 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,
)

View 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,
)

View File

@ -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)
}
}
}

View File

@ -3,21 +3,18 @@ package eu.kanade.tachiyomi.extension.fr.mangascan
import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SManga
import okhttp3.Request 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 { override fun imageRequest(page: Page): Request {
val newHeaders = headersBuilder() val newHeaders = headersBuilder()
.set("Referer", baseUrl) .set("Referer", "$baseUrl/")
.set("Accept", "image/avif,image/webp,*/*") .set("Accept", "image/avif,image/webp,*/*")
.build() .build()

View File

@ -1,10 +1,11 @@
package eu.kanade.tachiyomi.extension.es.mangasin package eu.kanade.tachiyomi.extension.es.mangasin
import android.net.Uri
import android.util.Base64 import android.util.Base64
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
import eu.kanade.tachiyomi.lib.synchrony.Deobfuscator import eu.kanade.tachiyomi.lib.synchrony.Deobfuscator
import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS 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.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
import eu.kanade.tachiyomi.source.model.FilterList 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.source.model.SManga
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import uy.kohesive.injekt.injectLazy import org.jsoup.nodes.Document
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
class MangasIn : MMRCMS("Mangas.in", "https://mangas.in", "es") { class MangasIn : MMRCMS(
"Mangas.in",
private val json: Json by injectLazy() "https://mangas.in",
"es",
supportsAdvancedSearch = false,
) {
override val client = super.client.newBuilder() override val client = super.client.newBuilder()
.rateLimitHost(baseUrl.toHttpUrl(), 1, 1) .rateLimitHost(baseUrl.toHttpUrl(), 1, 1)
.build() .build()
@ -32,6 +34,57 @@ class MangasIn : MMRCMS("Mangas.in", "https://mangas.in", "es") {
override fun headersBuilder() = super.headersBuilder() override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/") .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 var key = ""
private fun getKey(): String { 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") ?: 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> { override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup() val document = response.asJsoup()
val mangaUrl = document.location().removeSuffix("/") val mangaUrl = document.location().removeSuffix("/")
@ -100,7 +118,12 @@ class MangasIn : MMRCMS("Mangas.in", "https://mangas.in", "es") {
return chapters.map { return chapters.map {
SChapter.create().apply { 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() date_upload = it.createdAt.parseDate()
setUrlWithoutDomain("$mangaUrl/${it.slug}") setUrlWithoutDomain("$mangaUrl/${it.slug}")
} }

View File

@ -15,7 +15,13 @@ data class Chapter(
) )
@Serializable @Serializable
data class SearchResult( data class LatestManga(
@SerialName("value") val name: String, @SerialName("manga_name") val name: String,
@SerialName("data") val slug: String, @SerialName("manga_slug") val slug: String,
)
@Serializable
data class LatestUpdateResponse(
val data: List<LatestManga>,
val totalPages: Int,
) )

View 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
}
}
}
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

View File

@ -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",
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

View 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,
)

View File

@ -4,7 +4,7 @@ import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import okhttp3.Request 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 { override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/manga-list", headers) return GET("$baseUrl/manga-list", headers)
} }

View File

@ -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()

View File

@ -1,8 +1,11 @@
package eu.kanade.tachiyomi.multisrc.mmrcms package eu.kanade.tachiyomi.multisrc.mmrcms
import android.annotation.SuppressLint 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.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList 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.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga 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 eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import okhttp3.FormBody
import kotlinx.serialization.json.JsonObject import okhttp3.HttpUrl.Companion.toHttpUrl
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.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import rx.Observable import rx.Observable
import rx.Single
import rx.Subscription
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.ParseException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale 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 name: String,
override val baseUrl: String, override val baseUrl: String,
override val lang: String, final override val lang: String,
sourceInfo: String = "",
) : HttpSource() { vararg useNamedArgumentsBelow: Forbidden,
open val jsonData = if (sourceInfo == "") {
SourceData.giveMetaData(baseUrl) private val dateFormat: SimpleDateFormat = SimpleDateFormat("d MMM. yyyy", Locale.US),
} else { protected val itemPath: String = "manga",
sourceInfo 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 * A cache of all titles that have already appeared in latest updates.
*
* 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
*
*
*/ */
private val json: Json by injectLazy() private val latestTitles = mutableSetOf<String>()
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)
}
}
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/latest-release?page=$page", headers) 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 { override fun latestUpdatesParse(response: Response): MangasPage {
runCatching { fetchFilterOptions() }
val document = response.asJsoup() 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()) val manga = document.select(latestUpdatesSelector()).mapNotNull {
.let { elements -> val item = latestUpdatesFromElement(it)
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()
return MangasPage(mangas, document.selectFirst(latestUpdatesNextPageSelector()) != null) if (latestTitles.contains(item.url)) {
}
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) {
null null
} else { } else {
latestTitles.add(titleElement.text()) latestTitles.add(item.url)
SManga.create().apply { item
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"
} }
} }
} val hasNextPage = latestUpdatesNextPageSelector()?.let {
} document.selectFirst(it)
private fun gridLatestUpdatesSelector() = "div.mangalist div.manga-item, div.grid-manga tr" } != null
protected open fun gridLatestUpdatesFromElement(element: Element): SManga = SManga.create().apply {
element.select("a.chart-title").let { return MangasPage(manga, hasNextPage)
setUrlWithoutDomain(it.attr("href"))
title = it.text()
}
thumbnail_url = element.select("img").attr("abs:src")
} }
protected open fun internalMangaParse(response: Response): MangasPage { override fun latestUpdatesSelector() = "div.mangalist div.manga-item"
val document = response.asJsoup()
val internalMangaSelector = when (name) { override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element)
"Utsukushii" -> "div.content div.col-sm-6"
else -> "div[class^=col-sm], div.col-xs-6" override fun latestUpdatesNextPageSelector(): String? = popularMangaNextPageSelector()
}
return MangasPage( protected var searchDirectory = emptyList<SuggestionDto>()
document.select(internalMangaSelector).map {
SManga.create().apply { private var searchQuery = ""
val urlElement = it.getElementsByClass("chart-title")
if (urlElement.size == 0) { override fun fetchSearchManga(
url = getUrlWithoutBaseUrl(it.select("a").attr("href")) page: Int,
title = it.select("div.caption").text() query: String,
it.select("div.caption div").text().let { if (it.isNotEmpty()) title = title.substringBefore(it) } // To clean submanga's titles without breaking hentaishark's 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 { } else {
url = getUrlWithoutBaseUrl(urlElement.attr("href")) Observable.just(parseSearchDirectory(page))
title = urlElement.text().trim() }
} else {
super.fetchSearchManga(page, query, filters)
}
} }
it.select("img").let { img -> override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
thumbnail_url = when { val url = baseUrl.toHttpUrl().newBuilder().apply {
it.hasAttr("data-background-image") -> it.attr("data-background-image") // Utsukushii val filterList = filters.ifEmpty { getFilterList() }
img.hasAttr("data-src") -> coverGuess(img.attr("abs:data-src"), url)
else -> coverGuess(img.attr("abs:src"), url) 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)
}, },
document.select(".pagination a[rel=next]").isNotEmpty(),
) )
} }
// Guess thumbnails on broken websites return MangasPage(manga, endRange < searchDirectory.lastIndex)
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
}
} }
fun getUrlWithoutBaseUrl(newUrl: String): String { protected val detailAuthor = hashSetOf("author(s)", "autor(es)", "auteur(s)", "著作", "yazar(lar)", "mangaka(lar)", "pengarang/penulis", "pengarang", "penulis", "autor", "المؤلف", "перевод", "autor/autorzy")
val parsedNewUrl = Uri.parse(newUrl) protected val detailArtist = hashSetOf("artist(s)", "artiste(s)", "sanatçi(lar)", "artista(s)", "artist(s)/ilustrator", "الرسام", "seniman", "rysownik/rysownicy", "artista")
val newPathSegments = parsedNewUrl.pathSegments.toMutableList() protected val detailGenre = hashSetOf("categories", "categorías", "catégories", "ジャンル", "kategoriler", "categorias", "kategorie", "التصنيفات", "жанр", "kategori", "tagi", "género")
protected val detailStatus = hashSetOf("status", "statut", "estado", "状態", "durum", "الحالة", "статус")
for (i in parsedBaseUrl.pathSegments) { protected val detailStatusComplete = hashSetOf("complete", "مكتملة", "complet", "completo", "zakończone", "concluído", "finalizado")
if (i.trim().equals(newPathSegments.first(), true)) { protected val detailStatusOngoing = hashSetOf("ongoing", "مستمرة", "en cours", "em lançamento", "prace w toku", "ativo", "em andamento", "activo")
newPathSegments.removeAt(0) protected val detailStatusDropped = hashSetOf("dropped")
} else {
break
}
}
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
}
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
override fun mangaDetailsParse(response: Response) = SManga.create().apply { override fun mangaDetailsParse(document: Document) = SManga.create().apply {
val document = response.asJsoup() title = document.selectFirst(detailsTitleSelector)!!.text()
document.select("h2.listmanga-header, h2.widget-title").firstOrNull()?.text()?.trim()?.let { title = it } thumbnail_url = MMRCMSUtils.guessCover(
thumbnail_url = coverGuess(document.select(".row [class^=img-responsive]").firstOrNull()?.attr("abs:src"), document.location()) baseUrl,
description = document.select(".row .well p").text().trim() 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") document.select(".row .dl-horizontal dt").forEach { element ->
val detailArtist = setOf("artist(s)", "artiste(s)", "sanatçi(lar)", "artista(s)", "artist(s)/ilustrator", "الرسام", "seniman", "rysownik/rysownicy") when (element.text().lowercase().removeSuffix(":")) {
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(":")) {
in detailAuthor -> author = element.nextElementSibling()!!.text() in detailAuthor -> author = element.nextElementSibling()!!.text()
in detailArtist -> artist = element.nextElementSibling()!!.text() in detailArtist -> artist = element.nextElementSibling()!!.text()
in detailGenre -> genre = element.nextElementSibling()!!.select("a").joinToString { 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 detailStatusComplete -> SManga.COMPLETED
in detailStatusOngoing -> SManga.ONGOING in detailStatusOngoing -> SManga.ONGOING
in detailStatusDropped -> SManga.CANCELLED
else -> SManga.UNKNOWN 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> { override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup() 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)
/** val splits = initialName.split(":", limit = 2).map { it.trim() }
* 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()
try { return if (splits[0] == splits[1]) {
val titleWrapper = element.select("[class^=chapter-title-rtl]").first()!! splits[0]
// Some websites add characters after "..-rtl" thus the need of checking classes that starts with that } else {
val url = titleWrapper.getElementsByTag("a") "${splits[0]}: ${splits[1]}"
.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 override fun pageListParse(document: Document) =
document.select("#all > img.img-responsive").mapIndexed { i, it ->
Page(i, imageUrl = it.imgAttr())
} }
private fun parseDate(dateText: String): Long { override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
return try {
DATE_FORMAT.parse(dateText)?.time ?: 0 override fun getFilterList(): FilterList {
} catch (e: ParseException) { val filters = buildList<Filter<*>> {
0L 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"))
} }
override fun pageListParse(response: Response) = response.asJsoup().select("#all > .img-responsive") add(Filter.Separator())
.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) if (categories.isNotEmpty()) {
add(
UriMultiSelectFilter(
"Categories",
"categories[]",
categories.toTypedArray(),
),
)
} }
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() if (statuses.isNotEmpty()) {
add(
UriMultiSelectFilter(
"Statuses",
"status[]",
statuses.toTypedArray(),
),
)
}
private fun getInitialFilterList() = listOf<Filter<*>>( if (tags.isNotEmpty()) {
Filter.Header("NOTE: Ignored if using text search!"), add(
Filter.Separator(), UriMultiSelectFilter(
AuthorFilter(), "Types",
UriSelectFilter( "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", "Category",
"cat", "cat",
arrayOf( arrayOf(
"" to "Any", "Any" to "",
*categoryMappings.toTypedArray(), *categories.toTypedArray(),
), ),
), ),
UriSelectFilter( )
"Begins with", }
add(
UriPartFilter(
"Title begins with",
"alpha", "alpha",
arrayOf( arrayOf(
"" to "Any", "Any" to "",
*"#ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray().map { *"#ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray().map {
Pair(it.toString(), it.toString()) Pair(it.toString(), it.toString())
}.toTypedArray(), }.toTypedArray(),
), ),
), ),
SortFilter(),
) )
/** if (tags.isNotEmpty()) {
* Returns the list of filters for the source. add(
*/ UriPartFilter(
override fun getFilterList(): FilterList {
return when {
tagMappings != emptyList<Pair<String, String>>() -> {
FilterList(
getInitialFilterList() + UriSelectFilter(
"Tag", "Tag",
"tag", "tag",
arrayOf( arrayOf(
"" to "Any", "Any" to "",
*tagMappings.toTypedArray(), *tags.toTypedArray(),
), ),
), ),
) )
} }
else -> FilterList(getInitialFilterList())
add(SortFilter())
} }
} }
/** return FilterList(filters)
* 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)
} }
private var categories = emptyList<Pair<String, String>>()
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
}
fetchFiltersLock.lock()
if (fetchFiltersAttempts > 3 || (fetchFiltersAttempts > 0 && !fetchFiltersFailed)) {
fetchFiltersLock.unlock()
return@fromCallable
}
fetchFiltersFailed = try {
if (supportsAdvancedSearch) {
val document = client.newCall(GET("$baseUrl/advanced-search", headers)).execute().asJsoup()
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()
} }
} }
class AuthorFilter : Filter.Text("Author"), UriFilter { false
override fun addToUri(uri: Uri.Builder) { } catch (e: Throwable) {
uri.appendQueryParameter("author", state) Log.e(name, "Could not fetch filtering options", e)
} true
} }
class SortFilter : fetchFiltersAttempts++
Filter.Sort( fetchFiltersLock.unlock()
"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())
}
companion object {
private val sortables = arrayOf(
"name" to "Name",
"views" to "Popularity",
"last_release" to "Last update",
)
}
}
/**
* Represents a filter that is able to modify a URI.
*/
interface UriFilter {
fun addToUri(uri: Uri.Builder)
}
companion object {
private val DATE_FORMAT = SimpleDateFormat("d MMM. yyyy", Locale.US)
} }
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.subscribe()
} }

View File

@ -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,
)

View File

@ -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",
)
}
}

View File

@ -9,26 +9,21 @@ class MMRCMSGenerator : ThemeSourceGenerator {
override val themeClass = "MMRCMS" override val themeClass = "MMRCMS"
override val baseVersionCode = 7 override val baseVersionCode = 8
override val sources = listOf( 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("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("Scan VF", "https://www.scan-vf.net", "fr", overrideVersionCode = 1),
SingleLang("Komikid", "https://www.komikid.com", "id"), 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("Mangas.in", "https://mangas.in", "es", isNsfw = true, className = "MangasIn", overrideVersionCode = 2),
SingleLang("Utsukushii", "https://manga.utsukushii-bg.com", "bg", overrideVersionCode = 1), SingleLang("Utsukushii", "https://utsukushii-bg.com", "bg", overrideVersionCode = 1),
SingleLang("Phoenix-Scans", "https://phoenix-scans.pl", "pl", className = "PhoenixScans", overrideVersionCode = 1),
SingleLang("Lelscan-VF", "https://lelscanvf.cc", "fr", className = "LelscanVF", overrideVersionCode = 2), SingleLang("Lelscan-VF", "https://lelscanvf.cc", "fr", className = "LelscanVF", overrideVersionCode = 2),
SingleLang("MangaID", "https://mangaid.click", "id", overrideVersionCode = 1), SingleLang("MangaID", "https://mangaid.click", "id", overrideVersionCode = 1),
SingleLang("Jpmangas", "https://jpmangas.xyz", "fr", overrideVersionCode = 2), 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("Manga-Scan", "https://mangascan-fr.com", "fr", className = "MangaScan", overrideVersionCode = 4),
SingleLang("Bentoscan", "https://bentoscan.com", "fr"), 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 { companion object {

View File

@ -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()
}
}
}

View File

@ -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")
}
}

View File

@ -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 -> ""
}
}