diff --git a/README.md b/README.md
index 10228b04..f4b9f274 100644
--- a/README.md
+++ b/README.md
@@ -3,17 +3,8 @@
[![Stars][stars-shield]][stars-url]
[![Issues][issues-shield]][issues-url]
[![License][license-shield]][license-url]
-
-# Sources Requests
-
-Open up an Issue with these contents:
-
-- URL
-- Name
-- language?
-- NSFW yes/no
-
-Or join the Support Server or contact me over Telegram (see Contact Info)
+### Please give the repo a ⭐
+It motivates me. =^..^=
---
@@ -31,65 +22,35 @@ Or join the Support Server or contact me over Telegram (see Contact Info)
# Tutorial
-
-How to add a Plugin in Usagi
-
-
-
-There are actually two ways, one which you manually update the Plugin from time to time **or** let it do automatically.
+> [!TIP]
+> Two ways how to add the plugin:
+> 1. Automatically (recommended)
+> 2. Manually after every new release.
-
----
+> [!IMPORTANT]
+> The plugin will not update automatically unless you enable this feature (see `❓ Where?` in automatic category).
+> You can manually check anytime in `Manage Plugins` for an `Install` button to update.
-
-Manually way
-
-📥 How to download .jar File
-
-![Screenshot][img3]
+How to add a Plugin in Usagi
-
-📷 Image Turoial
-
-![Screenshot][img5]
-
-
-
-1. Download the latest version of the Plugin from the [Releases](https://github.com/InvalidDavid/UMA/releases) page.
-Click on the blue Name or press the three dots on the right side and press Download.
-
-
-2. Go back to the App and open up `Explore`
-
-
-3. Click on `three Dots in the top right corner` and select `Manage Sources`
-
-
-4. Go to `Manage Plugins` and press the `Add` / `+` button in the top right corner.
-
-
-5. Select `Import from local storage` and select `UMA.jar` file which you downloaded in the previous step.
-
-And that's it! Select your new sources in the catalog and have fun reading.
-
+Automatically (recommended)
----
-
-Automatically
+📷 Image Tutorial
-
+![Screenshot][img_automatic_tutorial]
+
-📷 Image Tutorial
+❓ Where?
-![Screenshot][img]
+![Screenshot][img_activate_auto]
@@ -120,22 +81,50 @@ InvalidDavid/UMA
And that's it! Select your new sources in the catalog and have fun reading.
-> **Note:** The plugin will not update automatically unless you enable this feature (see screenshot).
-> You can manually check anytime in `Manage Plugins` for an `Install` button to update.
+
+
+
+
+---
-❓ Where?
+Manually way
-![Screenshot][img4]
+
+
+📥 How to download .jar File
+
+![Screenshot][img_how_to_download]
+
+📷 Image Turoial
+![Screenshot][img_manual_tutorial]
+1. Download the latest version of the Plugin from the [Releases](https://github.com/InvalidDavid/UMA/releases) page.
+ Click on the blue Name or press the three dots on the right side and press Download.
+
+
+2. Go back to the App and open up `Explore`
+
+
+3. Click on `three Dots in the top right corner` and select `Manage Sources`
+
+
+4. Go to `Manage Plugins` and press the `Add` / `+` button in the top right corner.
+
+
+5. Select `Import from local storage` and select `UMA.jar` file which you downloaded in the previous step.
+
+And that's it! Select your new sources in the catalog and have fun reading.
+
----
+
+
# Setup
@@ -176,6 +165,31 @@ chmod +x gradlew && ./gradlew buildJar
---
+# Sources Requests
+
+> [!NOTE]
+> Open up an [Issue](https://github.com/InvalidDavid/UMA/issues/new) with these contents:
+> Title Source Name
+> - URL
+> - language?
+> - NSFW yes/no
+>
+> Or via Contact Info (see above)
+
+---
+
+## Star History
+
+
+
+
+
+
+
+
+
+---
+
# Credits
* Thanks to [KotatsuApp](https://github.com/KotatsuApp) for providing some parsers and the core library.
@@ -205,9 +219,7 @@ You may copy, distribute and modify the software as long as you track changes/da
[telegram-shield]: https://img.shields.io/badge/-Telegram-black.svg?style=for-the-badge&logo=telegram&colorB=555
[telegram-url]: https://t.me/invalidxdavid
-[img]: https://cdn.discordapp.com/attachments/1518283800830939176/1519723800327880826/image.png?ex=6a3e9895&is=6a3d4715&hm=818321ffb9a46f31c9148b5f4a234cac44b5a1ca559dc77c84206d451937ab80&
-[img1]: https://cdn.discordapp.com/attachments/1518283800830939176/1519672301883363409/image.png?ex=6a3e689e&is=6a3d171e&hm=fca40dfdf33ea99b07e7237b4d9a44afea1991be7b59f750a6bc9a906146c53e&
-[img2]: https://cdn.discordapp.com/attachments/1518283800830939176/1519672539532628068/image.png?ex=6a3e68d7&is=6a3d1757&hm=1551bf57fde5d3e8c7cb53c182347bfcb3b2ff58fa297c88148338e88c2b5fe1&
-[img3]: https://cdn.discordapp.com/attachments/1518283800830939176/1519673238316126268/image.png?ex=6a3e697e&is=6a3d17fe&hm=ed8e12ca85af9b8aa461463bf8095d958e46614e5478b97f95363875a81ab62b&
-[img4]: https://cdn.discordapp.com/attachments/1518283800830939176/1519673924554588321/image.png?ex=6a3e6a21&is=6a3d18a1&hm=2321b2d2a632080101312427741a790be7b1c3c15fd32cb82e5e3ba86fcfbe39&
-[img5]: https://cdn.discordapp.com/attachments/1518283800830939176/1519813607251116074/image.png?ex=6a3eec38&is=6a3d9ab8&hm=3cc4e454a7afd4233000cd0cf8efb8aa423841312b7399573e6ac5dbc90abdfd&
+[img_automatic_tutorial]: https://github.com/user-attachments/assets/1e4059f4-9c13-4bf9-9962-a9db28d2736d
+[img_manual_tutorial]: https://github.com/user-attachments/assets/a587ebfa-1cd8-4ce7-a437-108c0a5340cd
+[img_how_to_download]: https://github.com/user-attachments/assets/3539fb68-f417-4aac-a6d7-f7fdd7f5b7bb
+[img_activate_auto]: https://github.com/user-attachments/assets/69d807be-3e2f-44ef-9cef-329b520f1437
diff --git a/build.gradle.kts b/build.gradle.kts
index c0ab7906..81ed09e1 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -10,7 +10,7 @@ plugins {
}
group = "org.usagi"
-version = "1.0.0"
+version = "1.0.1"
tasks.test {
useJUnitPlatform()
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt
new file mode 100644
index 00000000..cabf4992
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt
@@ -0,0 +1,373 @@
+package org.koitharu.kotatsu.parsers.parsers
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import okhttp3.Headers
+import org.json.JSONArray
+import org.json.JSONObject
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.core.PagedMangaParser
+import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.util.*
+import java.net.URLDecoder
+import java.time.Instant
+import java.util.*
+import kotlin.time.Duration.Companion.milliseconds
+
+internal abstract class DoujinDesuParser(
+ context: MangaLoaderContext,
+ source: MangaParserSource
+): PagedMangaParser(context, source, pageSize = 18) {
+
+ protected abstract val defaultTypes: String
+ protected abstract val availableContentTypes: Set
+
+ override val configKeyDomain = ConfigKey.Domain("doujin.desu.xxx")
+
+ override fun onCreateConfig(keys: MutableCollection>) {
+ super.onCreateConfig(keys)
+ keys.add(userAgentKey)
+ }
+
+ @Volatile
+ private var genresCache: Set? = null
+ private val genresMutex = Mutex()
+
+ @get:Synchronized
+ private val detailsCache = object : LinkedHashMap(16, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 10
+ }
+
+ override val defaultSortOrder: SortOrder = SortOrder.UPDATED
+
+ override val availableSortOrders: Set = EnumSet.of(
+ SortOrder.UPDATED, SortOrder.NEWEST, SortOrder.ALPHABETICAL, SortOrder.POPULARITY
+ )
+
+ override val filterCapabilities = MangaListFilterCapabilities(
+ isMultipleTagsSupported = true,
+ isSearchSupported = true,
+ isSearchWithFiltersSupported = true,
+ isAuthorSearchSupported = false,
+ )
+
+ override suspend fun getFilterOptions() = MangaListFilterOptions(
+ availableTags = getOrFetchGenres(),
+ availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED),
+ availableContentTypes = availableContentTypes,
+ )
+
+ private suspend fun getOrFetchGenres(): Set {
+ genresCache?.let { return it }
+ return genresMutex.withLock {
+ genresCache ?: run {
+ genresCache = fetchAvailableTags()
+ genresCache!!
+ }
+ }
+ }
+
+ private suspend fun fetchAvailableTags(): Set {
+ val url = "/api/terms?taxonomy=genre".toAbsoluteUrl(domain)
+ val jsonResponse = executeWithCloudflareRetry(url, getRequestHeaders()).parseJson()
+ val decrypted = decrypt(jsonResponse.getString("_enc_resp_"))
+ val array = JSONArray(decrypted)
+ return (0 until array.length()).mapTo(mutableSetOf()) { i ->
+ val obj = array.getJSONObject(i)
+ MangaTag(key = obj.getString("slug"), title = obj.getString("name"), source = source)
+ }
+ }
+
+ override fun getRequestHeaders() = Headers.Builder()
+ .add("X-Requested-With", "XMLHttpRequest")
+ .add("Referer", "https://$domain/")
+ .add("X-App-Secret", "dfdf72051dbfdc7d76889ebd31324e74")
+ .build()
+
+ private suspend fun executeWithCloudflareRetry(url: String, headers: Headers): okhttp3.Response {
+ var response = webClient.httpGet(url, headers)
+ if (response.code == 403) {
+ val bodyString = response.body?.string().orEmpty()
+ if ("Unauthorized" in bodyString || "Access denied" in bodyString) {
+ response.close()
+ refreshCloudflare()
+ response = webClient.httpGet(url, headers)
+ }
+ }
+ if (response.code in 500..599) {
+ response.close()
+ kotlinx.coroutines.delay(1000L.milliseconds)
+ response = webClient.httpGet(url, headers)
+ }
+ return response
+ }
+
+ private suspend fun refreshCloudflare() {
+ webClient.httpGet("https://$domain/", getRequestHeaders()).close()
+ }
+
+ override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ val limit = pageSize
+ val offset = (page - 1) * limit
+
+ val url = urlBuilder().apply {
+ addPathSegment("api")
+ addPathSegment("manga")
+ addQueryParameter("limit", limit.toString())
+ addQueryParameter("offset", offset.toString())
+
+ filter.query?.let { addQueryParameter("search", it) }
+
+ val sortParam = when (order) {
+ SortOrder.POPULARITY -> "rating"
+ SortOrder.ALPHABETICAL -> "title_asc"
+ SortOrder.NEWEST -> "newest"
+ SortOrder.UPDATED -> "latest_chapter"
+ else -> "latest_chapter"
+ }
+ addQueryParameter("sort", sortParam)
+
+ filter.types.oneOrThrowIfMany()?.let {
+ when (it) {
+ ContentType.MANGA -> "manga"
+ ContentType.MANHWA -> "manhwa"
+ ContentType.DOUJINSHI -> "doujinshi"
+ else -> null
+ }?.let { type -> addQueryParameter("type", type) }
+ }
+
+ filter.states.oneOrThrowIfMany()?.let {
+ when (it) {
+ MangaState.ONGOING -> "ongoing"
+ MangaState.FINISHED -> "completed"
+ else -> null
+ }?.let { status -> addQueryParameter("status", status) }
+ }
+
+ if (filter.tags.isNotEmpty()) {
+ addQueryParameter("genre", filter.tags.joinToString(",") { it.key })
+ }
+ }.build()
+
+ val jsonResponse = executeWithCloudflareRetry(url.toString(), getRequestHeaders()).parseJson()
+ val decrypted = decrypt(jsonResponse.getString("_enc_resp_"))
+ val array = JSONArray(decrypted)
+
+ return (0 until array.length()).map { i ->
+ val obj = array.getJSONObject(i)
+ val slug = obj.getString("slug")
+ val href = "/manga/$slug"
+ Manga(
+ id = generateUid(href),
+ title = obj.getString("title"),
+ altTitles = emptySet(),
+ url = href,
+ publicUrl = href.toAbsoluteUrl(domain),
+ rating = obj.optDouble("rating", 0.0).toFloat() / 10f,
+ contentRating = ContentRating.ADULT,
+ coverUrl = obj.optString("cover_url").takeIf { it.isNotEmpty() }?.let { cover ->
+ if (cover.startsWith("http")) cover else cover.toAbsoluteUrl(domain)
+ },
+ tags = emptySet(),
+ state = null,
+ authors = emptySet(),
+ largeCoverUrl = null,
+ description = null,
+ source = source,
+ )
+ }
+ }
+
+ override suspend fun getDetails(manga: Manga): Manga {
+ detailsCache[manga.url]?.let { return it }
+
+ val slug = manga.url.removePrefix("/manga/").removeSuffix("/")
+ val url = "/api/manga/$slug".toAbsoluteUrl(domain)
+
+ val jsonResponse = executeWithCloudflareRetry(url, getRequestHeaders()).parseJson()
+ val decrypted = decrypt(jsonResponse.getString("_enc_resp_"))
+ val obj = JSONObject(decrypted)
+
+ val state = when (obj.optString("status")) {
+ "completed" -> MangaState.FINISHED
+ "ongoing" -> MangaState.ONGOING
+ else -> null
+ }
+
+ val author = obj.optString("author").takeIf { it.isNotEmpty() && it != "null" }
+
+ val tags = mutableSetOf()
+ val genresArray = obj.optJSONArray("manga_genres")
+ if (genresArray != null) {
+ for (i in 0 until genresArray.length()) {
+ val genreObj = genresArray.getJSONObject(i).getJSONObject("genres")
+ tags.add(
+ MangaTag(
+ key = genreObj.getString("slug"),
+ title = genreObj.getString("name"),
+ source = source,
+ )
+ )
+ }
+ }
+
+ val chaptersArray = obj.getJSONArray("chapters")
+ val chapters = ArrayList(chaptersArray.length())
+ for (i in 0 until chaptersArray.length()) {
+ val chapObj = chaptersArray.getJSONObject(i)
+ val chNum = chapObj.optDouble("chapter_number", 0.0).toFloat()
+ val chTitle = if (chNum == chNum.toLong().toFloat()) {
+ "Chapter ${chNum.toLong()}"
+ } else {
+ "Chapter $chNum"
+ }
+ chapters.add(
+ MangaChapter(
+ id = generateUid("/reader/${chapObj.getString("id")}"),
+ title = chTitle,
+ number = chNum,
+ volume = 0,
+ url = "/reader/${chapObj.getString("id")}",
+ scanlator = null,
+ uploadDate = runCatching { Instant.parse(chapObj.optString("created_at")).toEpochMilli() }.getOrDefault(0L),
+ branch = null,
+ source = source,
+ )
+ )
+ }
+
+ val rawDesc = obj.optString("description").takeIf { it.isNotEmpty() && it != "null" }
+ val cleanDesc = rawDesc?.let { html ->
+ val document = org.jsoup.Jsoup.parseBodyFragment(html)
+ val fullText = document.text()
+ val cutoffRegex = Regex("""download\s+(batch|volume)""", RegexOption.IGNORE_CASE)
+ val cutoffIndex = cutoffRegex.find(fullText)?.range?.first
+ val textBeforeDownload = if (cutoffIndex != null) fullText.substring(0, cutoffIndex) else fullText
+ textBeforeDownload
+ .replace(Regex("""^Sinopsis:\s*""", RegexOption.IGNORE_CASE), "")
+ .replace(Regex("""https?://\S+"""), "")
+ .replace(Regex("""\[url=.*?]|\[/url]"""), "")
+ .trim()
+ }
+
+ val coverUrl = obj.optString("cover_url").takeIf { it.isNotEmpty() }?.let { cover ->
+ if (cover.startsWith("http")) cover else cover.toAbsoluteUrl(domain)
+ }
+
+ val fullManga = manga.copy(
+ authors = setOfNotNull(author),
+ description = cleanDesc,
+ state = state,
+ rating = obj.optDouble("rating", 0.0).toFloat() / 10f,
+ tags = tags,
+ coverUrl = coverUrl ?: manga.coverUrl,
+ chapters = chapters.reversed()
+ )
+
+ detailsCache[manga.url] = fullManga
+ return fullManga
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val chId = chapter.url.removePrefix("/reader/").removeSuffix("/")
+ val url = "/api/chapters/$chId".toAbsoluteUrl(domain)
+
+ val jsonResponse = executeWithCloudflareRetry(url, getRequestHeaders()).parseJson()
+ val decrypted = decrypt(jsonResponse.getString("_enc_resp_"))
+ val obj = JSONObject(decrypted)
+
+ val pagesList = mutableListOf()
+ val contentUrls = obj.optJSONArray("content_urls")
+ if (contentUrls != null && contentUrls.length() > 0) {
+ for (i in 0 until contentUrls.length()) {
+ pagesList.add(
+ MangaPage(
+ id = generateUid(contentUrls.getString(i)),
+ url = transformPageUrl(contentUrls.getString(i)),
+ preview = null,
+ source = source,
+ )
+ )
+ }
+ } else {
+ val signedUrlsStr = obj.optString("signed_content_urls")
+ if (signedUrlsStr.isNotEmpty()) {
+ val signedUrls = JSONArray(signedUrlsStr)
+ for (i in 0 until signedUrls.length()) {
+ pagesList.add(
+ MangaPage(
+ id = generateUid(signedUrls.getString(i)),
+ url = transformPageUrl(signedUrls.getString(i)),
+ preview = null,
+ source = source,
+ )
+ )
+ }
+ }
+ }
+
+ return pagesList
+ }
+
+ private fun toSigned32(x: Long): Int {
+ var y = x and 0xFFFFFFFFL
+ if (y >= 0x80000000L) y -= 0x100000000L
+ return y.toInt()
+ }
+
+ private fun wH(e: Int): String {
+ val t = "doujindesu-scrapers-cannot-read-this-super-secret-salt-2026-v2_$e"
+ var a = 0
+ for (ch in t) {
+ a = (a shl 5) - a + ch.code
+ a = toSigned32(a.toLong())
+ }
+ var l = if (a != 0) kotlin.math.abs(a).toLong() else 123456789L
+ return buildString {
+ repeat(32) {
+ l = (l * 1664525L + 1013904223L) % 4294967296L
+ append((33 + (l % 93)).toInt().toChar())
+ }
+ }
+ }
+
+ private fun lU(): List {
+ val now = System.currentTimeMillis()
+ val t = now / 3600000L
+ return listOf(wH(t.toInt()), wH((t - 1).toInt()), wH((t + 1).toInt()))
+ }
+
+ private fun yre(encryptedHex: String, key: String): String {
+ val bytes = encryptedHex.chunked(2).mapNotNull { it.toIntOrNull(16) }
+ var d = 42
+ val keyLen = key.length
+ return buildString {
+ for ((idx, byteVal) in bytes.withIndex()) {
+ val keyChar = key[idx % keyLen].code
+ val k = byteVal xor keyChar xor (idx * 13) xor d
+ append((k and 0xFF).toChar())
+ d = (d + byteVal) % 256
+ }
+ }
+ }
+
+ private fun decrypt(encryptedHex: String): String {
+ for (key in lU()) {
+ try {
+ val decoded = yre(encryptedHex, key)
+ return URLDecoder.decode(decoded, "UTF-8")
+ } catch (_: Exception) {
+ }
+ }
+ throw RuntimeException("Decryption failed")
+ }
+
+ private fun transformPageUrl(page: String): String = when {
+ page.contains("/uploads/") && !page.contains("/storage/uploads/") ->
+ page.replace("/uploads/", "/storage/uploads/")
+ page.contains("/upload/") && !page.contains("/storage/upload/") ->
+ page.replace("/upload/", "/storage/upload/")
+ else -> page
+ }
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/HiperParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/HiperParser.kt
new file mode 100644
index 00000000..4b3b2a74
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/HiperParser.kt
@@ -0,0 +1,380 @@
+package org.koitharu.kotatsu.parsers.parsers
+
+import okhttp3.Headers
+import okhttp3.Request
+import org.json.JSONArray
+import org.json.JSONObject
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.core.PagedMangaParser
+import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.network.OkHttpWebClient
+import org.koitharu.kotatsu.parsers.util.*
+import java.text.SimpleDateFormat
+import java.util.*
+
+abstract class HiperParser(
+ context: MangaLoaderContext,
+ source: MangaParserSource,
+ protected val domainName: String,
+) : PagedMangaParser(context, source, pageSize = 30) {
+
+ protected open val availableContentTypes: Set = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA)
+
+ protected open val availableStates: Set = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED, MangaState.ABANDONED)
+
+ protected open val availableContentRating: Set = EnumSet.of(ContentRating.SAFE, ContentRating.SUGGESTIVE, ContentRating.ADULT)
+
+ protected open val mangaPath = "manga"
+
+ override val configKeyDomain = ConfigKey.Domain(domainName)
+ override val availableSortOrders: Set = EnumSet.of(
+ SortOrder.POPULARITY,
+ SortOrder.UPDATED,
+ SortOrder.NEWEST,
+ SortOrder.NEWEST_ASC,
+ SortOrder.RATING,
+ SortOrder.ALPHABETICAL,
+ SortOrder.RELEVANCE
+ )
+ override val filterCapabilities = MangaListFilterCapabilities(
+ isSearchSupported = true,
+ isSearchWithFiltersSupported = true,
+ isMultipleTagsSupported = true,
+ )
+
+ val client: OkHttpWebClient by lazy {
+ val httpClient = context.httpClient.newBuilder()
+ .addInterceptor { chain ->
+ val request = chain.request()
+ val newRequest = request.newBuilder()
+ .header("Referer", "https://$domainName/")
+ .header("Origin", "https://$domainName")
+ .build()
+ var response = chain.proceed(newRequest)
+ if (response.code == 401) {
+ response.close()
+ val acceptHeaders = Headers.Builder()
+ .set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
+ .build()
+ val homeRequest = Request.Builder()
+ .url("https://$domainName/")
+ .headers(acceptHeaders)
+ .build()
+ chain.proceed(homeRequest).close()
+ response = chain.proceed(request)
+ }
+ response
+ }
+ .build()
+ OkHttpWebClient(httpClient, source)
+ }
+
+ @Volatile
+ private var genresListCache: List? = null
+
+ override suspend fun getFilterOptions(): MangaListFilterOptions {
+ if (genresListCache == null) {
+ try {
+ val input = "%7B%220%22%3A%7B%22json%22%3Anull%2C%22meta%22%3A%7B%22values%22%3A%5B%22undefined%22%5D%7D%7D%7D"
+ val res = client.httpGet(apiUrl("search.genres", input), getRequestHeaders())
+ val body = res.body?.string()
+ if (body != null) {
+ val root = JSONArray(body)
+ val jsonArray = root.optJSONObject(0)
+ ?.optJSONObject("result")
+ ?.optJSONObject("data")
+ ?.optJSONArray("json")
+ if (jsonArray != null) {
+ val tags = mutableListOf()
+ for (i in 0 until jsonArray.length()) {
+ val obj = jsonArray.optJSONObject(i) ?: continue
+ val name = obj.optString("name", "")
+ if (name.isNotEmpty()) tags.add(MangaTag(name, name, source))
+ }
+ if (tags.isNotEmpty()) genresListCache = tags
+ }
+ }
+ } catch (_: Exception) {}
+ }
+ return MangaListFilterOptions(
+ availableTags = genresListCache?.toSet() ?: emptySet(),
+ availableContentTypes = availableContentTypes,
+ availableStates = availableStates,
+ availableContentRating = availableContentRating,
+ )
+ }
+
+ private fun apiUrl(trpcPath: String, input: String) = "https://$domainName/api/trpc/$trpcPath?batch=1&input=${input.urlEncoded()}"
+
+ private fun searchPayload(query: String, sort: String, limit: Int, offset: Int, filter: MangaListFilter): String {
+ val genres = filter.tags.map { it.key }
+ val types = filter.types.map { it.name.lowercase() }
+ val statusStr = filter.states.firstOrNull()?.let {
+ when (it) {
+ MangaState.ONGOING -> "ongoing"
+ MangaState.FINISHED -> "completed"
+ MangaState.PAUSED -> "hiatus"
+ MangaState.ABANDONED -> "cancelled"
+ else -> null
+ }
+ }
+ val contentRatingStr = filter.contentRating.firstOrNull()?.let {
+ when (it) {
+ ContentRating.SAFE -> "safe"
+ ContentRating.SUGGESTIVE -> "suggestive"
+ ContentRating.ADULT -> "pornographic"
+ }
+ }
+ return JSONObject().apply {
+ put("0", JSONObject().apply {
+ put("json", JSONObject().apply {
+ put("q", query)
+ put("sort", sort)
+ put("filters", JSONObject().apply {
+ if (genres.isNotEmpty()) put("genres", JSONArray(genres)) else put("genres", JSONObject.NULL)
+ if (types.isNotEmpty()) put("type", types.first()) else put("type", JSONObject.NULL)
+ if (statusStr != null) put("status", statusStr) else put("status", JSONObject.NULL)
+ if (contentRatingStr != null) put("contentRating", contentRatingStr) else put("contentRating", JSONObject.NULL)
+ put("author", JSONObject.NULL)
+ put("artist", JSONObject.NULL)
+ put("year", JSONObject.NULL)
+ })
+ put("limit", limit)
+ put("offset", offset)
+ put("maxRating", "pornographic")
+ })
+ put("meta", JSONObject().apply {
+ put("values", JSONObject().apply {
+ if (genres.isEmpty()) put("filters.genres", JSONArray().put("undefined"))
+ if (types.isEmpty()) put("filters.type", JSONArray().put("undefined"))
+ if (statusStr == null) put("filters.status", JSONArray().put("undefined"))
+ if (contentRatingStr == null) put("filters.contentRating", JSONArray().put("undefined"))
+ put("filters.author", JSONArray().put("undefined"))
+ put("filters.artist", JSONArray().put("undefined"))
+ put("filters.year", JSONArray().put("undefined"))
+ })
+ })
+ })
+ }.toString()
+ }
+
+ private fun detailsPayload(slug: String) = JSONObject().apply {
+ put("0", JSONObject().apply {
+ put("meta", JSONObject().apply { put("values", JSONArray().put("undefined")) })
+ })
+ put("1", JSONObject().apply {
+ put("json", JSONObject().apply { put("slug", slug) })
+ })
+ }.toString()
+
+ private fun chaptersPayload(mangaId: Long) = JSONObject().apply {
+ put("0", JSONObject().apply {
+ put("json", JSONObject().apply { put("values", JSONArray().put("undefined")) })
+ })
+ put("1", JSONObject().apply {
+ put("json", JSONObject().apply {
+ put("seriesId", mangaId)
+ put("sort", "best")
+ put("page", 1)
+ put("limit", 20)
+ })
+ put("meta", JSONObject().apply {
+ put("values", JSONObject().apply { put("chapterId", JSONArray().put("undefined")) })
+ })
+ })
+ put("2", JSONObject().apply {
+ put("json", JSONObject().apply { put("seriesId", mangaId) })
+ })
+ }.toString()
+
+ private fun pagesPayload(slug: String, number: Float) = JSONObject().apply {
+ put("0", JSONObject().apply {
+ put("meta", JSONObject().apply { put("values", JSONArray().put("undefined")) })
+ })
+ put("1", JSONObject().apply {
+ put("json", JSONObject().apply { put("slug", slug) })
+ })
+ put("2", JSONObject().apply {
+ put("json", JSONObject().apply {
+ put("seriesSlug", slug)
+ put("chapterNumber", number.toDouble())
+ })
+ })
+ put("3", JSONObject().apply {
+ put("json", JSONObject().apply { put("position", "footer_bottom") })
+ })
+ }.toString()
+
+ private fun parseLastItemJsonArray(body: String): JSONArray? = try {
+ val root = JSONArray(body)
+ root.optJSONObject(root.length() - 1)
+ ?.optJSONObject("result")
+ ?.optJSONObject("data")
+ ?.optJSONArray("json")
+ } catch (_: Exception) { null }
+
+ override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ val q = filter.query?.trim().orEmpty()
+ val sort = when (order) {
+ SortOrder.POPULARITY -> "popular"
+ SortOrder.UPDATED -> "recent"
+ SortOrder.NEWEST -> "newest"
+ SortOrder.NEWEST_ASC -> "oldest"
+ SortOrder.RATING -> "score"
+ SortOrder.ALPHABETICAL -> "alphabetical"
+ SortOrder.RELEVANCE -> "relevance"
+ else -> "newest"
+ }
+ val input = searchPayload(q, sort, pageSize, (page - 1) * pageSize, filter)
+ val res = client.httpGet(apiUrl("search.query", input), getRequestHeaders())
+ val body = res.body?.string() ?: return emptyList()
+ val root = JSONArray(body)
+ val first = root.optJSONObject(0) ?: return emptyList()
+ val hits = first.optJSONObject("result")
+ ?.optJSONObject("data")
+ ?.optJSONObject("json")
+ ?.optJSONArray("hits") ?: return emptyList()
+ return (0 until hits.length()).mapNotNull { i ->
+ hits.optJSONObject(i)?.let { obj ->
+ val id = obj.optLong("id", -1).takeIf { it > 0 } ?: return@mapNotNull null
+ val slug = obj.optString("slug", "").takeIf { it.isNotEmpty() } ?: return@mapNotNull null
+ val title = obj.optString("title", "")
+ val cover = obj.optString("coverUrl", "")
+ Manga(
+ id = generateUid("/$mangaPath/$slug#$id"),
+ url = "/$mangaPath/$slug#$id",
+ publicUrl = "https://$domainName/$mangaPath/$slug#$id",
+ title = title, coverUrl = cover,
+ altTitles = emptySet(), authors = emptySet(), tags = emptySet(),
+ rating = RATING_UNKNOWN, state = null,
+ contentRating = ContentRating.ADULT, source = source
+ )
+ }
+ }
+ }
+
+ override suspend fun getDetails(manga: Manga): Manga {
+ val slug = manga.url.substringAfterLast("$mangaPath/").substringBefore("#")
+ .takeIf { it.isNotBlank() } ?: return manga
+ val res = client.httpGet(apiUrl("auth.me,series.bySlugWithGenres", detailsPayload(slug)), getRequestHeaders())
+ val body = res.body?.string() ?: return manga
+ val detailJson = try {
+ val arr = JSONArray(body)
+ arr.getJSONObject(arr.length() - 1)
+ .optJSONObject("result")?.optJSONObject("data")?.optJSONObject("json")
+ } catch (_: Exception) { null } ?: return manga
+
+ val title = detailJson.optString("title", manga.title)
+ val desc = detailJson.optString("synopsis", manga.description)
+ val cover = detailJson.optString("coverUrl", manga.coverUrl)
+ val authors = detailJson.optJSONArray("authors")?.let { arr ->
+ (0 until arr.length()).map { arr.getString(it) }
+ } ?: emptyList()
+ val genres = detailJson.optJSONArray("genres")?.let { arr ->
+ (0 until arr.length()).map { arr.getString(it) }
+ } ?: emptyList()
+ val status = detailJson.optString("status", null)
+ val chapters = loadChapters(manga)
+
+ return manga.copy(
+ title = title, description = desc, coverUrl = cover,
+ authors = authors.toSet(),
+ tags = genres.map { MangaTag(it.lowercase(), it, source) }.toSet(),
+ state = when (status?.lowercase()) {
+ "ongoing" -> MangaState.ONGOING
+ "completed" -> MangaState.FINISHED
+ "hiatus" -> MangaState.PAUSED
+ "cancelled" -> MangaState.ABANDONED
+ else -> null
+ },
+ chapters = chapters,
+ )
+ }
+
+ private suspend fun loadChapters(manga: Manga): List {
+ val mangaId = manga.url.substringAfterLast("#").toLongOrNull() ?: return emptyList()
+ val slug = manga.url.substringAfterLast("$mangaPath/").substringBefore("#").ifBlank { return emptyList() }
+ val res = client.httpGet(apiUrl("auth.me,comments.list,series.chapters", chaptersPayload(mangaId)), getRequestHeaders())
+ val body = res.body?.string() ?: return emptyList()
+ val chaptersArray = parseLastItemJsonArray(body) ?: return emptyList()
+ return (0 until chaptersArray.length()).mapNotNull { i ->
+ chaptersArray.optJSONObject(i)?.let { obj ->
+ val number = obj.optDouble("number", 0.0).toFloat()
+ val createdAt = obj.optString("createdAt", null)
+ val uploadDate = createdAt?.let { dateFormat.parse(it)?.time } ?: 0L
+ MangaChapter(
+ id = generateUid("/$mangaPath/$slug/$number"),
+ url = "/$mangaPath/$slug/$number",
+ title = "Chapter ${number.formatForTitle()}",
+ number = number, volume = 0, uploadDate = uploadDate,
+ scanlator = null, branch = null, source = source
+ )
+ }
+ }.sortedBy { it.number }
+ }
+
+ // triyng first tRPC API if it fails then chapter html scraping
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val path = chapter.url.removePrefix("/$mangaPath/")
+ val slug = path.substringBefore("/")
+ val num = path.substringAfter("/").toFloatOrNull() ?: return emptyList()
+
+ val input = pagesPayload(slug, num)
+ val res = client.httpGet(apiUrl("auth.me,series.bySlug,reader.chapterPages", input), getRequestHeaders())
+ val body = res.body?.string() ?: return emptyList()
+ val apiPages = parsePages(body)
+ if (!apiPages.isNullOrEmpty()) return apiPages
+
+ val chapterUrl = "https://$domainName/$mangaPath/$slug/$num"
+ val doc = client.httpGet(chapterUrl).parseHtml()
+
+ // search in json results image URLs
+ val script = doc.select("script").find { it.data().contains("\"webpUrl\"") }
+ ?: doc.select("script").find { it.data().contains("\"avifUrl\"") }
+ if (script != null) {
+ val arrayRegex = Regex("""\[\s*\{.*?\"(?:webp|avif)Url\".*?\}\s*\]""", RegexOption.DOT_MATCHES_ALL)
+ val match = arrayRegex.find(script.data())?.value
+ if (match != null) {
+ return try {
+ val jsonArray = JSONArray(match)
+ (0 until jsonArray.length()).mapNotNull { i ->
+ val page = jsonArray.optJSONObject(i) ?: return@mapNotNull null
+ val url = page.optString("webpUrl", "").ifEmpty {
+ page.optString("avifUrl", "")
+ }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
+ MangaPage(id = generateUid(url), url = url, preview = null, source = source)
+ }
+ } catch (_: Exception) { emptyList() }
+ }
+ }
+
+ return emptyList()
+ }
+
+ private fun parsePages(body: String): List? {
+ val elements = try { JSONArray(body) } catch (_: Exception) { return null }
+ for (i in 0 until elements.length()) {
+ if (elements.optJSONObject(i)?.has("error") == true) return null
+ }
+ val last = elements.optJSONObject(elements.length() - 1) ?: return null
+ val items = last.optJSONObject("result")
+ ?.optJSONObject("data")
+ ?.optJSONArray("json") ?: return null
+ return (0 until items.length()).mapNotNull { i ->
+ val page = items.optJSONObject(i) ?: return@mapNotNull null
+ val url = page.optString("webpUrl", "").ifEmpty {
+ page.optString("avifUrl", "")
+ }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
+ MangaPage(id = generateUid(url), url = url, preview = null, source = source)
+ }
+ }
+
+ override suspend fun getPageUrl(page: MangaPage) = page.url
+
+ private fun Float.formatForTitle() = if (this == toLong().toFloat()) toLong().toString() else toString()
+
+ companion object {
+ private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT)
+ }
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/Manga18Parser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/Manga18Parser.kt
new file mode 100644
index 00000000..31033fd4
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/Manga18Parser.kt
@@ -0,0 +1,248 @@
+package org.koitharu.kotatsu.parsers.parsers
+
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import okhttp3.Headers
+import org.jsoup.nodes.Document
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.core.PagedMangaParser
+import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.util.*
+import java.text.SimpleDateFormat
+import java.util.*
+
+internal abstract class Manga18Parser(
+ context: MangaLoaderContext,
+ source: MangaParserSource,
+ domain: String,
+ pageSize: Int = 20,
+) : PagedMangaParser(context, source, pageSize) {
+
+ override val configKeyDomain = ConfigKey.Domain(domain)
+
+ override fun onCreateConfig(keys: MutableCollection>) {
+ super.onCreateConfig(keys)
+ keys.add(userAgentKey)
+ }
+
+ override val availableSortOrders: Set = EnumSet.of(
+ SortOrder.UPDATED,
+ SortOrder.POPULARITY,
+ SortOrder.ALPHABETICAL,
+ )
+
+ override val filterCapabilities: MangaListFilterCapabilities
+ get() = MangaListFilterCapabilities(
+ isSearchSupported = true,
+ isSearchWithFiltersSupported = true,
+ isMultipleTagsSupported = false,
+ )
+
+ @Volatile
+ private var genresCache: Set? = null
+ private val genresMutex = Mutex()
+
+ private suspend fun getOrFetchGenres(): Set {
+ genresCache?.let { return it }
+ return genresMutex.withLock {
+ genresCache ?: run {
+ genresCache = fetchAvailableTags()
+ genresCache!!
+ }
+ }
+ }
+
+ private suspend fun fetchAvailableTags(): Set {
+ val doc = webClient.httpGet("https://$domain/$listUrl/").parseHtml()
+ return doc.select("div.grid_cate li").mapNotNullToSet { li ->
+ val a = li.selectFirst("a") ?: return@mapNotNullToSet null
+ val href = a.attr("href").removeSuffix('/').substringAfterLast('/')
+ MangaTag(
+ key = href,
+ title = a.text(),
+ source = source,
+ )
+ }
+ }
+
+ override fun getRequestHeaders() = Headers.Builder()
+ .add("Referer", "https://$domain/")
+ .build()
+
+ override suspend fun getFilterOptions() = MangaListFilterOptions(
+ availableTags = getOrFetchGenres(),
+ )
+
+ init {
+ paginator.firstPage = 1
+ searchPaginator.firstPage = 1
+ }
+
+ protected open val ongoing: Set = setOf("On Going")
+ protected open val finished: Set = setOf("Completed")
+
+ protected open val listUrl = "list-manga/"
+ protected open val tagUrl = "manga-list/"
+ protected open val datePattern = "dd-MM-yyyy"
+
+ protected open val selectDesc = "div.detail_reviewContent"
+ protected open val selectState = "div.item:contains(Status) div.info_value"
+ protected open val selectAlt = "div.item:contains(Other name) div.info_value"
+ protected open val selectTag = "div.info_value > a[href*=/manga-list/]"
+ protected open val selectAuthor = "div.info_label:contains(author) + div.info_value, div.info_label:contains(autor) + div.info_value"
+ protected open val selectArtist = "div.info_label:contains(artist) + div.info_value"
+ protected open val selectDate = "div.item p"
+ protected open val selectChapter = "div.chapter_box li"
+
+ override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ val query = filter.query?.trim().orEmpty()
+ val tag = filter.tags.firstOrNull()
+
+ val url = buildString(120) {
+ append("https://")
+ append(domain)
+ append('/')
+
+ when {
+ tag != null && query.isNotEmpty() -> {
+ throw IllegalArgumentException("Search is not supported with tags")
+ }
+ tag != null -> {
+ append(tagUrl)
+ append(tag.key)
+ append('/')
+ append(page)
+ }
+ query.isNotEmpty() -> {
+ append(listUrl)
+ append(page)
+ append("?search=")
+ append(query.urlEncoded())
+ append("&order_by=latest")
+ }
+ else -> {
+ append(listUrl)
+ append(page)
+ }
+ }
+ if (query.isEmpty()) {
+ append("?order_by=")
+ when (order) {
+ SortOrder.POPULARITY -> append("views")
+ SortOrder.UPDATED -> append("latest")
+ SortOrder.ALPHABETICAL -> append("name")
+ else -> append("latest")
+ }
+ }
+ }
+
+ val doc = webClient.httpGet(url).parseHtml()
+ return parseMangaList(doc)
+ }
+
+ protected open fun parseMangaList(doc: Document): List {
+ return doc.select("div.story_item").map { div ->
+ val a = div.selectFirstOrThrow("a")
+ val href = a.attrAsRelativeUrl("href")
+ val title = div.selectFirst("div.mg_info")
+ ?.selectFirst("div.mg_name a")
+ ?.text()
+ ?: a.attr("title")
+ val cover = div.selectFirst("img")?.src()
+
+ Manga(
+ id = generateUid(href),
+ url = href,
+ publicUrl = href.toAbsoluteUrl(domain),
+ coverUrl = cover,
+ title = title,
+ altTitles = emptySet(),
+ rating = RATING_UNKNOWN,
+ tags = emptySet(),
+ authors = emptySet(),
+ state = null,
+ source = source,
+ contentRating = if (isNsfwSource) ContentRating.ADULT else null,
+ )
+ }
+ }
+
+ override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
+ val fullUrl = manga.url.toAbsoluteUrl(domain)
+ val doc = webClient.httpGet(fullUrl).parseHtml()
+ val info = doc.body().selectFirstOrThrow("div.detail_listInfo")
+ val chaptersDeferred = async { getChapters(doc) }
+ val desc = doc.selectFirst(selectDesc)?.html()?.nullIfEmpty()
+ val state = info.selectFirst(selectState)?.text()?.let {
+ when (it) {
+ in ongoing -> MangaState.ONGOING
+ in finished -> MangaState.FINISHED
+ else -> null
+ }
+ }
+ val alt = info.selectFirst(selectAlt)?.textOrNull()?.takeUnless { it == "Updating" }
+ val author = info.selectFirst(selectAuthor)?.textOrNull()?.takeUnless { it == "Updating" }
+ val artist = info.selectFirst(selectArtist)?.textOrNull()?.takeUnless { it == "Updating" }
+ val tags = doc.body().select(selectTag).mapToSet { a ->
+ val key = a.attr("href").removeSuffix('/').substringAfterLast('/')
+ MangaTag(
+ key = key,
+ title = a.text().toTitleCase(),
+ source = source,
+ )
+ }
+ manga.copy(
+ tags = tags,
+ description = desc.nullIfEmpty(),
+ altTitles = setOfNotNull(alt),
+ authors = setOfNotNull(author, artist),
+ state = state,
+ chapters = chaptersDeferred.await(),
+ )
+ }
+
+ protected open suspend fun getChapters(doc: Document): List {
+ val dateFormat = SimpleDateFormat(datePattern, sourceLocale)
+ return doc.body().select(selectChapter).mapChapters(reversed = true) { i, li ->
+ val a = li.selectFirstOrThrow("a")
+ val href = a.attrAsRelativeUrl("href")
+ val dateText = li.selectFirst(selectDate)?.text()
+ MangaChapter(
+ id = generateUid(href),
+ title = a.textOrNull(),
+ number = i + 1f,
+ volume = 0,
+ url = href,
+ uploadDate = dateFormat.parseSafe(dateText),
+ source = source,
+ scanlator = null,
+ branch = null,
+ )
+ }
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val fullUrl = chapter.url.toAbsoluteUrl(domain)
+ val doc = webClient.httpGet(fullUrl).parseHtml()
+ val script = doc.selectFirstOrThrow("script:containsData(slides_p_path)")
+ val encoded = script.data()
+ .substringAfter('[')
+ .substringBefore(",]")
+ .replace("\"", "")
+ .split(",")
+
+ return encoded.map { encodedUrl ->
+ val decoded = context.decodeBase64(encodedUrl).toString(Charsets.UTF_8)
+ val absoluteUrl = decoded.toAbsoluteUrl(domain)
+ MangaPage(
+ id = generateUid(absoluteUrl),
+ url = absoluteUrl,
+ preview = null,
+ source = source,
+ )
+ }
+ }
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaReaderParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaReaderParser.kt
new file mode 100644
index 00000000..438354da
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaReaderParser.kt
@@ -0,0 +1,417 @@
+package org.koitharu.kotatsu.parsers.parsers
+
+import androidx.collection.ArrayMap
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import okhttp3.*
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.internal.closeQuietly
+import org.json.JSONObject
+import org.jsoup.nodes.Document
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.core.PagedMangaParser
+import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.util.*
+import java.text.SimpleDateFormat
+import java.util.Base64
+import java.util.EnumSet
+
+internal abstract class MangaReaderParser(
+ context: MangaLoaderContext,
+ source: MangaParserSource,
+ domain: String,
+ pageSize: Int,
+ searchPageSize: Int,
+) : PagedMangaParser(context, source, pageSize, searchPageSize), Interceptor {
+
+ override val configKeyDomain = ConfigKey.Domain(domain)
+
+ override fun onCreateConfig(keys: MutableCollection>) {
+ super.onCreateConfig(keys)
+ keys.add(userAgentKey)
+ }
+
+ override val availableSortOrders: Set
+ get() = EnumSet.of(
+ SortOrder.UPDATED,
+ SortOrder.POPULARITY,
+ SortOrder.ALPHABETICAL,
+ SortOrder.ALPHABETICAL_DESC,
+ SortOrder.NEWEST,
+ )
+
+ override val filterCapabilities: MangaListFilterCapabilities
+ get() = MangaListFilterCapabilities(
+ isMultipleTagsSupported = true,
+ isTagsExclusionSupported = true,
+ isSearchSupported = true,
+ )
+
+ override suspend fun getFilterOptions() = MangaListFilterOptions(
+ availableTags = getOrCreateTagMap().values.toSet(),
+ availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED),
+ availableContentTypes = EnumSet.of(
+ ContentType.MANGA,
+ ContentType.MANHWA,
+ ContentType.MANHUA,
+ ContentType.COMICS,
+ ContentType.NOVEL,
+ ),
+ )
+
+ protected open val listUrl = "/manga"
+ protected open val datePattern = "MMMM d, yyyy"
+ protected open val isNetShieldProtected = false
+
+ protected var tagCache: ArrayMap? = null
+ protected val mutex = Mutex()
+
+ override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ val query = filter.query
+ val url = buildString {
+ append("https://")
+ append(domain)
+
+ when {
+
+ !query.isNullOrEmpty() -> {
+ append("/page/")
+ append(page.toString())
+ append("/?s=")
+ append(query.urlEncoded())
+ }
+
+ else -> {
+ append(listUrl)
+
+ append("/?order=")
+ append(
+ when (order) {
+ SortOrder.ALPHABETICAL -> "title"
+ SortOrder.ALPHABETICAL_DESC -> "titlereverse"
+ SortOrder.NEWEST -> "latest"
+ SortOrder.POPULARITY -> "popular"
+ SortOrder.UPDATED -> "update"
+ else -> ""
+ },
+ )
+
+ filter.tags.forEach {
+ append("&")
+ append("genre[]".urlEncoded())
+ append("=")
+ append(it.key)
+ }
+
+ filter.tagsExclude.forEach {
+ append("&")
+ append("genre[]".urlEncoded())
+ append("=-")
+ append(it.key)
+ }
+
+ if (filter.states.isNotEmpty()) {
+ filter.states.oneOrThrowIfMany()?.let {
+ append("&status=")
+ when (it) {
+ MangaState.ONGOING -> append("ongoing")
+ MangaState.FINISHED -> append("completed")
+ MangaState.PAUSED -> append("hiatus")
+ else -> append("")
+ }
+ }
+ }
+
+ filter.types.oneOrThrowIfMany()?.let {
+ append("&type=")
+ append(
+ when (it) {
+ ContentType.MANGA -> "manga"
+ ContentType.MANHWA -> "manhwa"
+ ContentType.MANHUA -> "manhua"
+ ContentType.COMICS -> "comic"
+ ContentType.NOVEL -> "novel"
+ else -> ""
+ },
+ )
+ }
+
+ append("&page=")
+ append(page.toString())
+ }
+ }
+ }
+ return parseMangaList(webClient.httpGet(url).parseHtml())
+ }
+
+ protected open val selectMangaList = ".postbody .listupd .bs .bsx"
+ protected open val selectMangaListImg = "img.ts-post-image"
+ protected open val selectMangaListTitle = "div.tt"
+
+ protected open fun parseMangaList(docs: Document): List {
+ return docs.select(selectMangaList).mapNotNull {
+ val a = it.selectFirst("a") ?: return@mapNotNull null
+ val relativeUrl = a.attrAsRelativeUrl("href")
+ val rating = it.selectFirst(".numscore")?.text()
+ ?.toFloatOrNull()?.div(10) ?: RATING_UNKNOWN
+ Manga(
+ id = generateUid(relativeUrl),
+ url = relativeUrl,
+ title = it.selectFirst(selectMangaListTitle)?.text() ?: a.attr("title"),
+ altTitles = emptySet(),
+ publicUrl = a.attrAsAbsoluteUrl("href"),
+ rating = rating,
+ contentRating = if (isNsfwSource) ContentRating.ADULT else null,
+ coverUrl = it.selectFirst(selectMangaListImg)?.src(),
+ tags = emptySet(),
+ state = null,
+ authors = emptySet(),
+ source = source,
+ )
+ }
+ }
+
+ protected open val selectChapter = "#chapterlist > ul > li"
+ override suspend fun getDetails(manga: Manga): Manga {
+ val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
+ val dateFormat = SimpleDateFormat(datePattern, sourceLocale)
+ val chapters = docs.select(selectChapter).mapChapters(reversed = true) { index, element ->
+ val url = element.selectFirst("a")?.attrAsRelativeUrlOrNull("href") ?: return@mapChapters null
+ MangaChapter(
+ id = generateUid(url),
+ title = element.selectFirst(".chapternum")?.textOrNull(),
+ url = url,
+ number = index + 1f,
+ volume = 0,
+ scanlator = null,
+ uploadDate = dateFormat.parseSafe(element.selectFirst(".chapterdate")?.text()),
+ branch = null,
+ source = source,
+ )
+ }
+ return parseInfo(docs, manga, chapters)
+ }
+
+ protected open val detailsDescriptionSelector = "div.entry-content"
+
+ open suspend fun parseInfo(docs: Document, manga: Manga, chapters: List): Manga {
+ /// set if is table
+ val tableMode =
+ docs.selectFirst("div.seriestucontent > div.seriestucontentr") ?: docs.selectFirst("div.seriestucontentr")
+ ?: docs.selectFirst("div.seriestucon")
+
+ val tagMap = getOrCreateTagMap()
+
+ val selectTag = if (tableMode != null) {
+ docs.select(".seriestugenre > a")
+ } else {
+ docs.select(".wd-full .mgen > a")
+ }
+
+ val tags = selectTag.mapNotNullToSet { tagMap[it.text()] }
+
+ val stateSelect = if (tableMode != null) {
+ tableMode.selectFirst(".infotable td:contains(Status)")
+ ?: tableMode.selectFirst(".infotable td:contains(Statut)")
+ ?: tableMode.selectFirst(".infotable td:contains(حالة العمل)")
+ ?: tableMode.selectFirst(".infotable td:contains(الحالة)")
+ ?: tableMode.selectFirst(".infotable td:contains(Estado)")
+ ?: tableMode.selectFirst(".infotable td:contains(สถานะ)")
+ ?: tableMode.selectFirst(".infotable td:contains(Stato )")
+ ?: tableMode.selectFirst(".infotable td:contains(Durum)")
+ ?: tableMode.selectFirst(".infotable td:contains(Statüsü)")
+
+ } else {
+ docs.selectFirst(".tsinfo div:contains(Status)") ?: docs.selectFirst(".tsinfo div:contains(Statut)")
+ ?: docs.selectFirst(".tsinfo div:contains(حالة العمل)") ?: docs.selectFirst(".tsinfo div:contains(Estado)")
+ ?: docs.selectFirst(".tsinfo div:contains(สถานะ)") ?: docs.selectFirst(".tsinfo div:contains(Stato )")
+ ?: docs.selectFirst(".tsinfo div:contains(Durum)") ?: docs.selectFirst(".tsinfo div:contains(Statüsü)")
+ ?: docs.selectFirst(".tsinfo div:contains(Statü)") ?: docs.selectFirst(".tsinfo div:contains(الحالة)")
+ }
+
+ val state = if (tableMode != null) {
+ stateSelect?.lastElementSibling()
+
+ } else {
+ stateSelect?.lastElementChild()
+ }
+
+ val mangaState = state?.let {
+ when (it.text()) {
+
+ "مستمرة", "En curso", "En Curso", "Ongoing", "OnGoing", "On going", "Ativo", "En Cours", "En cours", "En cours \uD83D\uDFE2",
+ "En cours de publication", "Đang tiến hành", "Em lançamento", "em lançamento", "Em Lançamento", "Онгоінг", "Publishing",
+ "Devam Ediyor", "Em Andamento", "In Corso", "Güncel", "Berjalan", "Продолжается", "Updating", "Lançando", "In Arrivo", "Emision",
+ "En emision", "مستمر", "Curso", "En marcha", "Publicandose", "Publicando", "连载中", "Devam ediyor", "Devam Etmekte",
+ -> MangaState.ONGOING
+
+ "Completed", "Completo", "Complété", "Fini", "Achevé", "Terminé", "Terminé ⚫", "Tamamlandı", "Đã hoàn thành", "Hoàn Thành",
+ "مكتملة", "Завершено", "Finished", "Finalizado", "Completata", "One-Shot", "Bitti", "Tamat", "Completado", "Concluído",
+ "Concluido", "已完结", "Bitmiş",
+ -> MangaState.FINISHED
+
+ "Canceled", "Cancelled", "Cancelado", "cancellato", "Cancelados", "Dropped", "Discontinued", "abandonné", "Abandonné",
+ -> MangaState.ABANDONED
+
+ "Hiatus", "On Hold", "Pausado", "En espera", "En pause", "En Pause", "En attente",
+ -> MangaState.PAUSED
+
+ else -> null
+ }
+ }
+
+ val author = tableMode?.selectFirst(".infotable td:contains(Author)")?.lastElementSibling()?.textOrNull()
+ ?: docs.selectFirst(".tsinfo div:contains(Author)")?.lastElementChild()?.textOrNull()
+ ?: docs.selectFirst(".tsinfo div:contains(Auteur)")?.lastElementChild()?.textOrNull()
+ ?: docs.selectFirst(".tsinfo div:contains(Artist)")?.lastElementChild()?.textOrNull()
+ ?: docs.selectFirst(".tsinfo div:contains(Durum)")?.lastElementChild()?.textOrNull()
+
+ val nsfw = docs.selectFirst(".restrictcontainer") != null
+ || docs.selectFirst(".info-right .alr") != null
+ || docs.selectFirst(".postbody .alr") != null
+
+ val title = docs.selectFirst("h1.entry-title")?.text()
+ ?: docs.selectFirst("h1")?.text()
+ ?: docs.selectFirst(".entry-title")?.text()
+ ?: docs.selectFirst(".seriestucontent h1")?.text()
+ ?: docs.selectFirst(".postbody h1")?.text()
+ ?: docs.selectFirst("title")?.text()?.substringBefore(" - ")?.trim()
+ ?: manga.title
+
+ return manga.copy(
+ title = title,
+ description = docs.selectFirst(detailsDescriptionSelector)?.text(),
+ state = mangaState,
+ authors = setOfNotNull(author),
+ contentRating = if (manga.contentRating == ContentRating.ADULT || nsfw) {
+ ContentRating.ADULT
+ } else {
+ ContentRating.SAFE
+ },
+ tags = tags,
+ chapters = chapters,
+ )
+ }
+
+ override suspend fun getRelatedManga(seed: Manga): List {
+ return parseMangaList(webClient.httpGet(seed.url.toAbsoluteUrl(domain)).parseHtml())
+ }
+
+ protected open val encodedSrc = false
+ protected open val selectScript = "div.wrapper script"
+ protected open val selectPage = "div#readerarea img"
+
+ protected open val selectTestScript = "script:containsData(ts_reader)"
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val chapterUrl = chapter.url.toAbsoluteUrl(domain)
+ val docs = webClient.httpGet(chapterUrl).parseHtml()
+
+ val test = docs.select(selectTestScript)
+ if (test.isEmpty() and !encodedSrc) {
+ return docs.select(selectPage).map { img ->
+ val url = img.requireSrc().toRelativeUrl(domain)
+ MangaPage(
+ id = generateUid(url),
+ url = url,
+ preview = null,
+ source = source,
+ )
+ }
+ } else {
+ val images = if (encodedSrc) {
+ val script = docs.select(selectScript)
+ var decode = ""
+ for (i in script) {
+ if (i.attr("src").startsWith("data:text/javascript;base64,")) {
+ decode = Base64.getDecoder().decode(i.attr("src").replace("data:text/javascript;base64,", ""))
+ .decodeToString()
+ if (decode.startsWith("ts_reader.run")) {
+ break
+ }
+ }
+ }
+ JSONObject(decode.substringAfter('(').substringBeforeLast(')'))
+ .getJSONArray("sources")
+ .getJSONObject(0)
+ .getJSONArray("images")
+
+ } else {
+ val script = docs.selectFirstOrThrow(selectTestScript)
+ JSONObject(script.data().substringAfter('(').substringBeforeLast(')'))
+ .getJSONArray("sources")
+ .getJSONObject(0)
+ .getJSONArray("images")
+ }
+
+ val pages = ArrayList(images.length())
+ for (i in 0 until images.length()) {
+ pages.add(
+ MangaPage(
+ id = generateUid(images.getString(i)),
+ url = images.getString(i),
+ preview = null,
+ source = source,
+ ),
+ )
+ }
+ return pages
+ }
+ }
+
+ protected open suspend fun getOrCreateTagMap(): Map = mutex.withLock {
+ tagCache?.let { return@withLock it }
+ val url = listUrl.toAbsoluteUrl(domain)
+ val tagElements = webClient.httpGet(url).parseHtml().select("ul.genrez > li")
+ val tagMap = ArrayMap(tagElements.size)
+ for (el in tagElements) {
+ tagMap[el.text()] = MangaTag(
+ title = el.textOrNull()?.toTitleCase(sourceLocale) ?: continue,
+ key = el.selectFirst("input")?.attrOrNull("value") ?: continue,
+ source = source,
+ )
+ }
+ tagCache = tagMap
+ return@withLock tagMap
+ }
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val response = chain.proceed(chain.request())
+ if (!isNetShieldProtected) {
+ return response
+ }
+ val contentType = response.mimeType
+ if (
+ contentType?.endsWith("/html") != false &&
+ context.cookieJar.getCookies(domain).none { it.name.contains("NetShield") }
+ ) {
+ val cookie = runBlocking { response.copy().parseHtml().getNetShieldCookie() } ?: return response
+ context.cookieJar.insertCookie(domain, cookie)
+ return chain.proceed(response.request.newBuilder().build()).also {
+ response.closeQuietly()
+ }
+ }
+ return response
+ }
+
+ @Suppress("DEPRECATION")
+ private suspend fun Document.getNetShieldCookie(): Cookie? = runCatchingCancellable {
+ val script = select("script").firstNotNullOfOrNull { s ->
+ s.html().takeIf { x -> x.contains("slowAES.decrypt") }
+ } ?: return@runCatchingCancellable null
+ val min = webClient.httpGet("https://$domain/min.js").parseRaw()
+ val res = context.evaluateJs(min + "\n\n" + script.replace(Regex("document.cookie\\s*=\\s*"), "return "))
+ res?.let {
+ Cookie.parse(baseUri().toHttpUrl(), it)
+ }
+ }.getOrNull()
+
+ override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? {
+ val mangaSlug = link.pathSegments[1].nullIfEmpty() ?: return null
+ val url = "/manga/$mangaSlug/"
+ return resolver.resolveManga(
+ this, url, generateUid(url),
+ )
+ }
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt
index 7f7e784f..86436d32 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt
@@ -1,10 +1,11 @@
package org.koitharu.kotatsu.parsers.parsers
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.json.jsonArray
-import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.json.JSONArray
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
+import org.jsoup.select.Elements
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
@@ -17,635 +18,374 @@ abstract class MangaThemesia(
context: MangaLoaderContext,
source: MangaParserSource,
domain: String,
-) : PagedMangaParser(
- context,
- source,
- 20,
-) {
+ pageSize: Int = 20,
+) : PagedMangaParser(context, source, pageSize) {
- private val domainName = domain
+ override val configKeyDomain = ConfigKey.Domain(domain)
- override val configKeyDomain =
- ConfigKey.Domain(domainName)
-
-
- protected open val dateFormat =
- SimpleDateFormat(
- "MMMM dd, yyyy",
- Locale.US,
- )
-
-
- override val availableSortOrders =
- EnumSet.of(
- SortOrder.UPDATED,
- SortOrder.POPULARITY,
- SortOrder.NEWEST,
- )!!
-
-
- override val filterCapabilities =
- MangaListFilterCapabilities(
- isSearchSupported = true,
- isMultipleTagsSupported = true,
- isAuthorSearchSupported = true,
- )
-
-
- protected open val mangaDirectory =
- "manga"
+ override fun onCreateConfig(keys: MutableCollection>) {
+ super.onCreateConfig(keys)
+ keys.add(userAgentKey)
+ }
- protected open val relatedSelector =
- ".related-posts .bsx, .bixbox .bsx, .related-manga .related-reading-wrap"
+ override fun getRequestHeaders() = super.getRequestHeaders().newBuilder()
+ .set("Referer", "https://$domain/")
+ .build()
+
+ protected open val mangaDirectory = "manga"
+ protected open val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US)
+ protected open val withoutAjax = false
+ protected open val searchSelector = ".utao .uta .imgu, .listupd .bs .bsx, .listo .bs .bsx"
+ protected open val relatedSelector = ".related-posts .bsx, .bixbox .bsx, .related-manga .related-reading-wrap"
+ protected open val chapterListSelector = "div.bxcl li, div.cl li, #chapterlist li, ul li:has(div.chbox):has(div.eph-num), div.bxcl li:not(a[data-bs-target='#lockedChapterModal'])"
+ protected open val pageSelector = "div#readerarea img"
+
+ override val availableSortOrders: EnumSet = EnumSet.of(
+ SortOrder.ALPHABETICAL,
+ SortOrder.ALPHABETICAL_DESC,
+ SortOrder.UPDATED,
+ SortOrder.ADDED,
+ SortOrder.POPULARITY,
+ )
+
+ override val filterCapabilities = MangaListFilterCapabilities(
+ isSearchSupported = true,
+ isMultipleTagsSupported = true,
+ isTagsExclusionSupported = true,
+ )
+
+ @Volatile
+ private var genreCache: Set? = null
+ private val genreMutex = Mutex()
+
+ override suspend fun getFilterOptions() = MangaListFilterOptions(
+ availableTags = getOrFetchGenres(),
+ availableStates = EnumSet.of(
+ MangaState.ONGOING,
+ MangaState.FINISHED,
+ MangaState.PAUSED,
+ MangaState.ABANDONED,
+ ),
+ availableContentTypes = EnumSet.of(
+ ContentType.MANGA,
+ ContentType.MANHWA,
+ ContentType.MANHUA,
+ ContentType.COMICS,
+ ContentType.NOVEL,
+ ),
+ )
+
+ private suspend fun getOrFetchGenres(): Set {
+ genreCache?.let { return it }
+ return genreMutex.withLock {
+ genreCache ?: fetchGenres().also { genreCache = it }
+ }
+ }
- protected open val searchSelector =
- ".utao .uta .imgu, .listupd .bs .bsx, .listo .bs .bsx"
+ protected open suspend fun fetchGenres(): Set {
+ val doc = webClient.httpGet("https://$domain/$mangaDirectory/").parseHtml()
+ val genrez = doc.selectFirst("ul.genrez") ?: return emptySet()
+ return genrez.select("li").mapNotNull { li ->
+ val label = li.selectFirst("label")?.text()?.trim() ?: return@mapNotNull null
+ val input = li.selectFirst("input[type=checkbox]") ?: return@mapNotNull null
+ val value = input.attr("value").trim()
+ if (label.isBlank() || value.isBlank()) null
+ else MangaTag(key = value, title = label, source = source)
+ }.toSet()
+ }
- override suspend fun getListPage(
- page: Int,
- order: SortOrder,
- filter: MangaListFilter,
- ): List {
- val query = filter.query
+ override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ val query = filter.query?.trim().orEmpty()
val url = buildString {
- append("https://")
- append(domainName)
- if(query?.isNotBlank() == true) {
- append("/?s=")
- append(query.urlEncoded())
+ append("https://$domain/$mangaDirectory/")
+ if (query.isNotEmpty()) {
+ append("?s=${query.urlEncoded()}")
} else {
- append("/")
- append(mangaDirectory)
- append("?page=")
- append(page)
- }
- filter.author
- ?.takeIf { it.isNotBlank() }
- ?.let {
- append("&author=")
- append(it.urlEncoded())
- }
-
- if (filter.year != -1) {
- append("&yearx=")
- append(filter.year)
+ append("?page=$page")
}
-
filter.states.firstOrNull()?.let {
append("&status=")
- append(
- when(it) {
- MangaState.ONGOING -> "ongoing"
- MangaState.FINISHED -> "completed"
- MangaState.PAUSED -> "hiatus"
- MangaState.ABANDONED -> "dropped"
- else -> ""
- }
- )
+ append(when (it) {
+ MangaState.ONGOING -> "ongoing"
+ MangaState.FINISHED -> "completed"
+ MangaState.PAUSED -> "hiatus"
+ MangaState.ABANDONED -> "dropped"
+ else -> ""
+ })
}
-
filter.types.firstOrNull()?.let {
append("&type=")
- append(
- when(it) {
- ContentType.MANGA -> "Manga"
- ContentType.MANHWA -> "Manhwa"
- ContentType.MANHUA -> "Manhua"
- else -> ""
- }
- )
- }
- filter.tags.forEach {
- append("&genre[]=")
- append(it.key.urlEncoded())
- }
- filter.tagsExclude.forEach {
- append("&genre[]=-")
- append(it.key.urlEncoded())
- }
-
- when(order) {
- SortOrder.UPDATED ->
- append("&order=update")
- SortOrder.POPULARITY ->
- append("&order=popular")
- SortOrder.NEWEST ->
- append("&order=latest")
- else -> {}
+ append(when (it) {
+ ContentType.MANGA -> "manga"
+ ContentType.MANHWA -> "manhwa"
+ ContentType.MANHUA -> "manhua"
+ ContentType.COMICS -> "comic"
+ ContentType.NOVEL -> "novel"
+ else -> ""
+ })
}
+ filter.tags.forEach { append("&genre[]=${it.key.urlEncoded()}") }
+ filter.tagsExclude.forEach { append("&genre[]=-${it.key.urlEncoded()}") }
+ append("&order=")
+ append(when (order) {
+ SortOrder.UPDATED -> "update"
+ SortOrder.POPULARITY -> "popular"
+ SortOrder.ADDED -> "latest"
+ SortOrder.ALPHABETICAL -> "title"
+ SortOrder.ALPHABETICAL_DESC -> "titlereverse"
+ else -> "update"
+ })
}
-
- val document =
- webClient
- .httpGet(
- url,
- )
- .parseHtml()
-
-
- return document
- .select(searchSelector)
- .mapNotNull {
- runCatching {
- parseManga(it)
- }.getOrNull()
- }
+ val doc = webClient.httpGet(url).parseHtml()
+ return parseMangaList(doc)
}
-
- protected open fun parseManga(
- element: Element,
- ): Manga {
- val link =
- element.selectFirst("a")
- ?: return Manga(
- id = generateUid("empty"),
- url = "",
- publicUrl = "",
- title = "",
- altTitles = emptySet(),
- authors = emptySet(),
- tags = emptySet(),
- coverUrl = null,
- rating = RATING_UNKNOWN,
- state = null,
- contentRating =
- if(isNsfwSource)
- ContentRating.ADULT
- else
- null,
- source = source,
- )
-
- val url =
- link.attr("href")
- .toRelativeUrl(domainName)
-
- return Manga(
- id = generateUid(url),
- url = url,
- publicUrl = url.toAbsoluteUrl(domainName),
- title =
- link.attr("title")
- .ifBlank {
- link.text()
- },
- altTitles = emptySet(),
- authors = emptySet(),
- coverUrl =
- element
- .selectFirst("img, source")
- ?.imgAttr(),
- tags = emptySet(),
- rating = RATING_UNKNOWN,
- state = null,
- contentRating =
- if(isNsfwSource)
- ContentRating.ADULT
- else
- null,
- source = source,
- )
+ protected open fun parseMangaList(doc: Document): List {
+ return doc.select(searchSelector).mapNotNull { element ->
+ parseMangaElement(element)
+ }
}
+ override suspend fun getDetails(manga: Manga): Manga {
+ val fullUrl = manga.url.toAbsoluteUrl(domain)
+ val doc = webClient.httpGet(fullUrl).parseHtml()
+
+ val title = doc.selectFirst("h1.entry-title, .ts-breadcrumb li:last-child span, .infomanga h1, .animefull h1")
+ ?.text() ?: manga.title
+
+ val description = doc.selectFirst(
+ ".desc, .entry-content[itemprop=description], .summary__content, .sinopsis, .entry-content p"
+ )?.text()?.trim().orEmpty()
+
+ val coverUrl = doc.selectFirst(
+ ".thumb img, .infomanga img, .summary_image img, .cover img, img.wp-post-image"
+ )?.imgAttr() ?: manga.coverUrl
+
+ val table = doc.selectFirst("table.infotable")
+ val hasTable = table != null
+
+ val authors = if (hasTable) {
+ val authorCell = table.selectFirst("tr:has(td:contains(Author)) td:last-child")
+ val artistCell = table.selectFirst("tr:has(td:contains(Artist)) td:last-child")
+ listOfNotNull(
+ authorCell?.text()?.trim()?.takeIf { it.isNotBlank() && it != "n/a" && it != "N/A" },
+ artistCell?.text()?.trim()?.takeIf { it.isNotBlank() && it != "n/a" && it != "N/A" }
+ ).toSet()
+ } else {
+ doc.select(".author, .artist, .fmed span, .tsinfo .imptdt:contains(Author) i, .spe span:contains(Author) a")
+ .map { it.text().trim() }
+ .filter { it.isNotBlank() }
+ .toSet()
+ }
- override suspend fun getRelatedManga(
- seed: Manga,
- ): List {
- val document =
- webClient
- .httpGet(
- seed.url.toAbsoluteUrl(domainName),
- )
- .parseHtml()
- return document
- .select(relatedSelector)
- .mapNotNull {
- runCatching {
- val link =
- it.selectFirst("a")
- ?: return@runCatching null
- val url =
- link
- .attr("href")
- .toRelativeUrl(domainName)
- Manga(
- id = generateUid(url),
- url = url,
- publicUrl = url.toAbsoluteUrl(domainName),
- title =
- link.attr("title")
- .ifBlank {
- link.text()
- },
- altTitles = emptySet(),
- authors = emptySet(),
- coverUrl =
- it
- .selectFirst("img")
- ?.imgAttr(),
- tags = emptySet(),
- rating = RATING_UNKNOWN,
- state = null,
- contentRating =
- if(isNsfwSource)
- ContentRating.ADULT
- else
- null,
- source = source,
- )
+ val tags = doc.select(".mgen a, .gnr a, .seriestugenre a, .genres-content a")
+ .mapNotNull { a ->
+ val text = a.text().trim()
+ if (text.isNotBlank()) MangaTag(key = text.lowercase(), title = text, source = source) else null
+ }.toSet()
+
+ val statusText = if (hasTable) {
+ table.selectFirst("tr:has(td:contains(Status)) td:last-child")?.text()
+ } else {
+ doc.select(".imptdt:contains(Status) i, .tsinfo .imptdt:contains(Status) a, .fmed b:contains(Status)+span span")
+ .text()
+ .ifEmpty {
+ doc.selectFirst("div.post-content_item:contains(Status) div.summary-content")?.text()
}
- .getOrNull()
- }
- }
+ }
+ val state = parseStatus(statusText)
+ val rating = doc.selectFirst(".num[itemprop=ratingValue]")?.attr("content")?.toFloatOrNull()
+ ?: doc.selectFirst(".num")?.text()?.toFloatOrNull()
+ val normalizedRating = if (rating != null && rating > 0) rating / 10f else RATING_UNKNOWN
+
+ val chapters = loadChapters(doc, manga.url)
- override suspend fun getDetails(
- manga: Manga,
- ): Manga {
- val document =
- webClient
- .httpGet(
- manga.url.toAbsoluteUrl(domainName),
- )
- .parseHtml()
-
- val chapters =
- loadChapters(
- document,
- manga,
- )
return manga.copy(
- title =
- document
- .selectFirst(
- "h1.entry-title, .ts-breadcrumb li:last-child span"
- )
- ?.text()
- ?: manga.title,
- description =
- document
- .selectFirst(
- ".desc, .entry-content[itemprop=description]"
- )
- ?.text()
- ?.trim()
- .orEmpty(),
- coverUrl =
- document
- .selectFirst(
- ".thumb img, .infomanga img, .summary_image img, .cover img, img.wp-post-image"
- )
- ?.imgAttr()
- ?: manga.coverUrl,
- authors =
- document
- .select(
- ".author, .artist, .fmed span"
- )
- .map {
- it.text()
- }
- .filter {
- it.isNotBlank()
- }
- .toSet(),
- tags =
- document
- .select(
- ".mgen a, .gnr a, .seriestugenre a"
- )
- .map {
-
- MangaTag(
- key = it.text().lowercase(),
- title = it.text(),
- source = source,
- )
-
- }
- .toSet(),
- state =
- parseState(
- document
- .select(
- ".imptdt, .status"
- )
- .text()
- ),
+ title = title,
+ description = description,
+ coverUrl = coverUrl,
+ authors = authors,
+ tags = tags,
+ state = state,
+ rating = normalizedRating,
chapters = chapters,
)
}
-
- protected open suspend fun loadChapters(
- document: Document,
- manga: Manga,
- ): List {
-
- return document
- .select(
- "div.bxcl li, div.cl li, #chapterlist li, ul li:has(div.chbox)"
- )
- .mapNotNull { element ->
-
- val url =
- element
- .selectFirst("a")
- ?.attr("href")
- ?: return@mapNotNull null
-
- val title =
- cleanChapterTitle(
- element
- .selectFirst("a")
- ?.text()
- .orEmpty()
- )
-
- val chapterNumber =
- extractChapterNumber(title)
- ?: return@mapNotNull null
-
- MangaChapter(
- id = generateUid(url),
- url = url.toRelativeUrl(domainName),
- title = title,
- number = chapterNumber,
- volume = 0,
- branch = null,
- uploadDate =
- element
- .selectFirst(".chapterdate")
- ?.text()
- ?.let {
- parseDate(it)
- }
- ?: 0,
- scanlator = null,
- source = source,
- )
- }
- .sortedBy { it.number }
+ protected open fun parseStatus(text: String?): MangaState? {
+ val status = text?.lowercase() ?: return null
+ return when (status) {
+ in ongoing -> MangaState.ONGOING
+ in finished -> MangaState.FINISHED
+ in paused -> MangaState.PAUSED
+ in abandoned -> MangaState.ABANDONED
+ else -> null
+ }
}
- private fun parseDate(
- value:String,
- ):Long {
- return try {
- dateFormat
- .parse(value)
- ?.time
- ?: 0
- } catch(_:Exception){
- 0
+ protected open suspend fun loadChapters(doc: Document, mangaUrl: String): List {
+ val chapterElements = doc.select(chapterListSelector)
+ if (chapterElements.isEmpty() && !withoutAjax) {
+ return loadChaptersAjax(mangaUrl)
}
+ return parseChapters(chapterElements)
}
+ protected open suspend fun loadChaptersAjax(mangaUrl: String): List {
+ val ajaxUrl = mangaUrl.toAbsoluteUrl(domain).removeSuffix("/") + "/ajax/chapters/"
+ val doc = webClient.httpPost(ajaxUrl, emptyMap()).parseHtml()
+ return parseChapters(doc.select(chapterListSelector))
+ }
- override suspend fun getPages(
- chapter: MangaChapter,
- ): List {
- val document =
- webClient
- .httpGet(
- chapter.url.toAbsoluteUrl(domainName),
- )
- .parseHtml()
-
- val images =
- Regex(
- "\"images\"\\s*:\\s*(\\[.*?])",
- setOf(
- RegexOption.DOT_MATCHES_ALL
- )
+ private fun parseChapters(elements: Elements): List {
+ return elements.mapChapters(reversed = true) { i, element ->
+ val a = element.selectFirst("a") ?: return@mapChapters null
+ val href = a.attrAsRelativeUrl("href")
+ val name = a.selectFirst(".chapternum")?.text() ?: a.ownText()
+ val dateStr = element.selectFirst(".chapterdate")?.text()
+ val number = extractChapterNumber(name) ?: (i + 1).toFloat()
+ MangaChapter(
+ id = generateUid(href),
+ url = href,
+ title = name,
+ number = number,
+ volume = 0,
+ uploadDate = parseChapterDate(dateStr),
+ scanlator = null,
+ branch = null,
+ source = source,
)
- .find(document.html())
- ?.groupValues
- ?.getOrNull(1)
-
- if(!images.isNullOrBlank()) {
- val pages =
- runCatching {
- Json
- .parseToJsonElement(images)
- .jsonArray
- .map {
- val url =
- it.jsonPrimitive.content
- MangaPage(
- id = generateUid(url),
- url = url,
- preview = null,
- source = source,
- )
- }
- }
- .getOrNull()
- if(!pages.isNullOrEmpty())
- return pages
}
+ }
- return document
- .select(pageSelector)
- .mapNotNull {
- val url =
- it.imgAttr()
- if(url.isBlank())
- null
- else
- MangaPage(
- id = generateUid(url),
- url = url,
- preview = null,
- source = source,
- )
- }
+ private fun extractChapterNumber(title: String): Float? {
+ return Regex("""(?i)(?:chapter|ch\.?)\s*([0-9]+(?:\.[0-9]+)?)""")
+ .find(title)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.toFloatOrNull()
}
+ protected open fun parseChapterDate(date: String?): Long {
+ if (date.isNullOrBlank()) return 0L
+ val clean = date.trim()
+ return when {
+ WordSet(" ago", "atrás", " hace", " назад", " önce", " trước", "مضت", "قبل").endsWith(clean) -> parseRelativeDate(clean)
+ WordSet("há ", "منذ", "il y a", "hace", "giờ", "phút").startsWith(clean) -> parseRelativeDate(clean)
+ WordSet("yesterday", "يوم واحد").startsWith(clean) -> yesterday()
+ WordSet("today").startsWith(clean) -> today()
+ WordSet("يومين").startsWith(clean) -> dayBeforeYesterday()
+ else -> dateFormat.parseSafe(clean)
+ }
+ }
- override suspend fun getPageUrl(
- page:MangaPage,
- ):String {
- return page.url
+ private fun parseRelativeDate(text: String): Long {
+ val number = Regex("""(\d+)""").find(text)?.value?.toIntOrNull() ?: return 0L
+ val cal = Calendar.getInstance()
+ return when {
+ WordSet("detik", "segundo", "second", "วินาที", "giây", "ثوان").anyWordIn(text) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
+ WordSet("menit", "dakika", "min", "minute", "minuto", "mins", "นาที", "دقائق", "phút", "минут").anyWordIn(text) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
+ WordSet("jam", "saat", "heure", "hora", "hour", "hours", "ชั่วโมง", "giờ", "ore", "ساعة", "小时").anyWordIn(text) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
+ WordSet("hari", "gün", "jour", "día", "dia", "day", "days", "días", "วัน", "ngày", "giorni", "أيام", "天", "день").anyWordIn(text) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
+ WordSet("week", "semana", "tuần", "أسابيع", "أسبوع").anyWordIn(text) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis
+ WordSet("month", "months", "mes", "meses", "tháng", "أشهر", "mois").anyWordIn(text) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
+ WordSet("year", "año", "năm").anyWordIn(text) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
+ else -> 0L
+ }
}
+ private fun yesterday() = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) }.timeInMillis
+ private fun today() = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0) }.timeInMillis
+ private fun dayBeforeYesterday() = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -2) }.timeInMillis
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
+
+ // json image list
+ val jsonRegex = Regex(""""images"\s*:\s*(\[.*?])""", RegexOption.DOT_MATCHES_ALL)
+ val jsonMatch = jsonRegex.find(doc.html())
+ if (jsonMatch != null) {
+ val jsonArray = JSONArray(jsonMatch.groupValues[1])
+ return (0 until jsonArray.length()).map { i ->
+ val url = jsonArray.getString(i)
+ MangaPage(id = generateUid(url), url = url, preview = null, source = source)
+ }
+ }
- protected open fun parseState(
- value:String?,
- ):MangaState? {
- val text =
- value
- ?.lowercase()
- ?: return null
-
- return when{
- listOf(
- "ongoing",
- "on going",
- "publishing",
- "updating",
- )
- .any {
- text.contains(it)
- } ->
- MangaState.ONGOING
-
- listOf(
- "completed",
- "complete",
- "finished",
- )
- .any {
- text.contains(it)
- } ->
- MangaState.FINISHED
-
- listOf(
- "hiatus",
- "paused",
- "on hold",
- )
- .any {
- text.contains(it)
- } ->
- MangaState.PAUSED
-
- listOf(
- "dropped",
- "cancelled",
- "canceled",
- )
- .any {
- text.contains(it)
- } ->
- MangaState.ABANDONED
- else ->
- null
+ // direct
+ return doc.select(pageSelector).mapNotNull { img ->
+ val url = img.imgAttr().takeIf { it.isNotBlank() } ?: return@mapNotNull null
+ MangaPage(id = generateUid(url), url = url, preview = null, source = source)
}
}
+ override suspend fun getRelatedManga(seed: Manga): List {
+ val doc = webClient.httpGet(seed.url.toAbsoluteUrl(domain)).parseHtml()
+ return doc.select(relatedSelector).mapNotNull { element ->
+ parseMangaElement(element)
+ }
+ }
- override suspend fun getFilterOptions():
- MangaListFilterOptions {
- val tags =
- listOf(
- "Action",
- "Adventure",
- "Comedy",
- "Drama",
- "Fantasy",
- "Historical",
- "Horror",
- "Isekai",
- "Romance",
- "School Life",
- "Sci-Fi",
- "Shoujo",
- "Slice of Life",
- "Supernatural",
- "Thriller",
- )
- .map {
- MangaTag(
- key = it.lowercase(),
- title = it,
- source = source,
- )
- }
- .toSet()
+ protected open fun parseMangaElement(element: Element): Manga? {
+ val a = element.selectFirst("a") ?: return null
+ val href = a.attrAsRelativeUrl("href")
+ val title = a.attr("title").ifBlank { a.text() }
+ val coverUrl = element.selectFirst("img")?.imgAttr()
+ return makeManga(href, title, coverUrl)
+ }
- return MangaListFilterOptions(
- availableTags =
- tags,
- availableStates =
- EnumSet.of(
- MangaState.ONGOING,
- MangaState.FINISHED,
- MangaState.PAUSED,
- MangaState.ABANDONED,
- ),
- availableContentTypes =
- EnumSet.of(
- ContentType.MANGA,
- ContentType.MANHWA,
- ContentType.MANHUA,
- ),
+ private fun makeManga(href: String, title: String, coverUrl: String?): Manga {
+ return Manga(
+ id = generateUid(href),
+ url = href,
+ publicUrl = href.toAbsoluteUrl(domain),
+ title = title,
+ altTitles = emptySet(),
+ authors = emptySet(),
+ coverUrl = coverUrl,
+ rating = RATING_UNKNOWN,
+ tags = emptySet(),
+ state = null,
+ contentRating = if (isNsfwSource) ContentRating.ADULT else null,
+ source = source,
)
}
protected fun Element.imgAttr(): String {
- val attributes = listOf(
- "data-src",
- "data-lazy-src",
- "data-original",
- "data-cfsrc",
- "data-image",
- "data-bg",
- "src"
- )
-
- for(attribute in attributes) {
- val value = attr(attribute)
- if(value.isNotBlank()) {
- return fixImageExtension(
- value.toAbsoluteUrl(domainName)
- )
- }
+ for (attr in listOf("data-src", "data-lazy-src", "data-original", "data-cfsrc", "data-image", "src")) {
+ val value = attr(attr).trim().takeIf { it.isNotEmpty() } ?: continue
+ return value.toAbsoluteUrl(domain).substringBefore("?") // remove WP resize params
}
-
-
val srcset = attr("srcset")
- if(srcset.isNotBlank()) {
- return fixImageExtension(
- srcset
- .split(",")
- .last()
- .trim()
- .split(" ")
- .first()
- .toAbsoluteUrl(domainName)
- )
+ if (srcset.isNotBlank()) {
+ return srcset.split(",").last().trim().split(" ").first().toAbsoluteUrl(domain)
}
return ""
}
- private fun fixImageExtension(url: String): String {
- var result = url
- // Remove WordPress image proxy
- result = result.replace(
- Regex(
- "https://i[0-3]\\.wp\\.com/"
- ),
- "https://"
- )
- // Remove WordPress resize params if they exist
- result = result.substringBefore("?")
- return result
- }
-
+ private fun SimpleDateFormat.parseSafe(date: String): Long =
+ runCatching { parse(date)?.time ?: 0L }.getOrDefault(0L)
-
- private fun cleanChapterTitle(
- value: String,
- ): String {
- return value
- .replace(Regex("\\s+(January|February|March|April|May|June|July|August|September|October|November|December).*", RegexOption.IGNORE_CASE,), "",)
- .replace(Regex("\\s+\\d{1,2}[./-]\\d{1,2}[./-]\\d{2,4}.*",), "",)
- .trim()
- }
-
- private fun extractChapterNumber(title: String): Float? {
- return Regex(
- """(?:chapter|ch\.?)\s*([0-9]+(?:\.[0-9]+)?)""",
- RegexOption.IGNORE_CASE,
+ companion object {
+ val ongoing = setOf(
+ "ongoing", "on going", "publishing", "updating", "en curso", "ativo", "en cours",
+ "đang tiến hành", "em lançamento", "devam ediyor", "in corso", "güncel", "berjalan",
+ "продолжается", "lançando", "in arrivo", "连载中", "devam etmekte", "مستمرة",
+ )
+ val finished = setOf(
+ "completed", "complete", "finished", "finalizado", "terminé", "tamamlandı",
+ "đã hoàn thành", "hoàn thành", "مكتملة", "завершено", "completato", "one-shot",
+ "bitti", "tamat", "concluído", "已完结", "bitmiş", "achevé",
+ )
+ val paused = setOf(
+ "hiatus", "on hold", "pausado", "en espera", "en pause", "en attente",
+ "durduruldu", "beklemede", "đang chờ", "متوقف", "заморожено",
+ )
+ val abandoned = setOf(
+ "canceled", "cancelled", "cancelado", "cancellato", "dropped", "discontinued",
+ "abandonné", "iptal edildi", "đã hủy", "ملغي", "заброшено",
)
- .find(title)
- ?.groupValues
- ?.getOrNull(1)
- ?.toFloatOrNull()
}
-
- abstract val pageSelector: String
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/ManhwaZ.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/ManhwaZ.kt
deleted file mode 100644
index 5a6471fa..00000000
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/ManhwaZ.kt
+++ /dev/null
@@ -1,287 +0,0 @@
-package org.koitharu.kotatsu.parsers.parsers
-
-import org.koitharu.kotatsu.parsers.MangaLoaderContext
-import org.koitharu.kotatsu.parsers.config.ConfigKey
-import org.koitharu.kotatsu.parsers.core.PagedMangaParser
-import org.koitharu.kotatsu.parsers.model.ContentRating
-import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.model.MangaChapter
-import org.koitharu.kotatsu.parsers.model.MangaListFilter
-import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
-import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
-import org.koitharu.kotatsu.parsers.model.MangaPage
-import org.koitharu.kotatsu.parsers.model.MangaParserSource
-import org.koitharu.kotatsu.parsers.model.MangaState
-import org.koitharu.kotatsu.parsers.model.MangaTag
-import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
-import org.koitharu.kotatsu.parsers.model.SortOrder
-import org.koitharu.kotatsu.parsers.network.CommonHeaders
-import org.koitharu.kotatsu.parsers.util.attrAsRelativeUrl
-import org.koitharu.kotatsu.parsers.util.generateUid
-import org.koitharu.kotatsu.parsers.util.mapChapters
-import org.koitharu.kotatsu.parsers.util.mapToSet
-import org.koitharu.kotatsu.parsers.util.parseHtml
-import org.koitharu.kotatsu.parsers.util.parseSafe
-import org.koitharu.kotatsu.parsers.util.selectOrThrow
-import org.koitharu.kotatsu.parsers.util.src
-import org.koitharu.kotatsu.parsers.util.textOrNull
-import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
-import org.koitharu.kotatsu.parsers.util.toTitleCase
-import org.koitharu.kotatsu.parsers.util.urlEncoded
-import java.text.SimpleDateFormat
-import java.util.EnumSet
-import java.util.Locale
-
-internal abstract class ManhwaZ(
- context: MangaLoaderContext,
- source: MangaParserSource,
- domain: String,
- pageSize: Int = 20,
-) : PagedMangaParser(context, source, pageSize) {
-
- override val configKeyDomain = ConfigKey.Domain(domain)
-
- override fun onCreateConfig(keys: MutableCollection>) {
- super.onCreateConfig(keys)
- keys.add(userAgentKey)
- }
-
- override fun getRequestHeaders() = super.getRequestHeaders().newBuilder()
- .add(CommonHeaders.ORIGIN, "https://$domain")
- .add(CommonHeaders.REFERER, "https://$domain/")
- .build()
-
- override val availableSortOrders: Set = EnumSet.of(
- SortOrder.UPDATED,
- SortOrder.POPULARITY,
- SortOrder.ALPHABETICAL,
- SortOrder.RATING,
- )
-
- override val filterCapabilities: MangaListFilterCapabilities
- get() = MangaListFilterCapabilities(
- isSearchSupported = true,
- )
-
- override suspend fun getFilterOptions(): MangaListFilterOptions {
- return MangaListFilterOptions(
- availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED),
- availableTags = fetchTags(),
- )
- }
-
- protected open val searchPath = "/search"
-
- override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
- val url = buildString {
- append("https://")
- append(domain)
- if (!filter.query.isNullOrEmpty()) {
- append("$searchPath?s=")
- append((filter.query?.urlEncoded() ?: ""))
- append("&page=")
- append(page.toString())
- } else {
- if (filter.tags.isNotEmpty()) {
- append("/genre/")
- append(filter.tags.first().key)
- append("/")
- } else {
- append("/")
- }
- append("?page=")
- append(page.toString())
- val sortQuery = getSortOrderQuery(order, filter.tags.isNotEmpty())
- if (sortQuery.isNotEmpty()) {
- append("&")
- append(sortQuery)
- }
- }
- }
-
- val doc = webClient.httpGet(url).parseHtml()
- return doc.select(".page-item-detail").mapNotNull { element ->
- val href = element.selectFirst(".item-summary a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null
- Manga(
- id = generateUid(href),
- url = href,
- publicUrl = href.toAbsoluteUrl(domain),
- title = element.selectFirst(".item-summary a")?.text().orEmpty(),
- coverUrl = element.selectFirst(".item-thumb img")?.src().orEmpty(),
- altTitles = emptySet(),
- rating = RATING_UNKNOWN,
- tags = emptySet(),
- authors = emptySet(),
- state = null,
- source = source,
- contentRating = if (isNsfwSource) ContentRating.ADULT else null,
- )
- }
- }
-
- protected open val selectAuthor =
- "div.summary-heading:contains(Tác giả) + div.summary-content"
- protected open val selectState =
- "div.summary-heading:contains(Trạng thái) + div.summary-content"
-
- override suspend fun getDetails(manga: Manga): Manga {
- val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
- val author = doc.selectFirst(selectAuthor)?.textOrNull()
- return manga.copy(
- altTitles = setOfNotNull(doc.selectFirst("h2.other-name")?.textOrNull()),
- authors = setOfNotNull(author),
- tags = doc.select("div.genres-content a[rel=tag]").mapToSet { a ->
- MangaTag(
- key = a.attr("href").substringAfterLast('/'),
- title = a.text().toTitleCase(sourceLocale),
- source = source,
- )
- },
- description = doc.selectFirst("div.summary__content")?.html(),
- state = when (doc.selectFirst(selectState)?.text()?.lowercase()) {
- "đang ra" -> MangaState.ONGOING
- "hoàn thành" -> MangaState.FINISHED
- else -> null
- },
- chapters = doc.select("li.wp-manga-chapter").mapChapters(reversed = true) { i, element ->
- val a = element.selectFirst("a") ?: return@mapChapters null
- MangaChapter(
- id = generateUid(a.attrAsRelativeUrl("href")),
- title = a.text(),
- number = i + 1f,
- url = a.attrAsRelativeUrl("href"),
- uploadDate = parseChapterDate(
- element.selectFirst("span.chapter-release-date")?.text()
- ),
- branch = null,
- scanlator = null,
- source = source,
- volume = 0,
- )
- },
- )
- }
-
- override suspend fun getPages(chapter: MangaChapter): List {
- val fullUrl = chapter.url.toAbsoluteUrl(domain)
- val doc = webClient.httpGet(fullUrl).parseHtml()
- return doc.selectOrThrow("div.page-break img").map { img ->
- val url = img.src().orEmpty()
- MangaPage(
- id = generateUid(url),
- url = url,
- preview = null,
- source = source,
- )
- }
- }
-
- @JvmField
- protected val secondAgo: Set = setOf(
- "giây trước",
- "second ago",
- "seconds ago"
- )
-
- @JvmField
- protected val minuteAgo: Set = setOf(
- "phút trước",
- "minute ago",
- "minutes ago"
- )
-
- @JvmField
- protected val hourAgo: Set = setOf(
- "giờ trước",
- "hour ago",
- "hours ago"
- )
-
- @JvmField
- protected val dayAgo: Set = setOf(
- "ngày trước",
- "day ago",
- "days ago"
- )
-
- @JvmField
- protected val weekAgo: Set = setOf(
- "tuần trước",
- "week ago",
- "weeks ago"
- )
-
- @JvmField
- protected val monthAgo: Set = setOf(
- "tháng trước",
- "month ago",
- "months ago"
- )
-
- @JvmField
- protected val yearAgo: Set = setOf(
- "năm trước",
- "year ago",
- "years ago"
- )
-
- private fun parseChapterDate(date: String?): Long {
- if (date == null) return 0L
-
- return when {
- secondAgo.any { date.contains(it) } ->
- System.currentTimeMillis() - date.removeSuffixes(secondAgo).toLong() * 1000
-
- minuteAgo.any { date.contains(it) } ->
- System.currentTimeMillis() - date.removeSuffixes(minuteAgo).toLong() * 60 * 1000
-
- hourAgo.any { date.contains(it) } ->
- System.currentTimeMillis() - date.removeSuffixes(hourAgo).toLong() * 60 * 60 * 1000
-
- dayAgo.any { date.contains(it) } ->
- System.currentTimeMillis() - date.removeSuffixes(dayAgo).toLong() * 24 * 60 * 60 * 1000
-
- weekAgo.any { date.contains(it) } ->
- System.currentTimeMillis() - date.removeSuffixes(weekAgo).toLong() * 7 * 24 * 60 * 60 * 1000
-
- monthAgo.any { date.contains(it) } ->
- System.currentTimeMillis() - date.removeSuffixes(monthAgo).toLong() * 30 * 24 * 60 * 60 * 1000
-
- yearAgo.any { date.contains(it) } ->
- System.currentTimeMillis() - date.removeSuffixes(yearAgo).toLong() * 365 * 24 * 60 * 60 * 1000
-
- else ->
- SimpleDateFormat("dd/MM/yyyy", Locale.US).parseSafe(date)
- }
- }
-
- private fun String.removeSuffixes(suffixes: Set): String =
- suffixes.fold(this) { text, suffix ->
- text.replace(suffix, "")
- }.trim()
-
- private fun getSortOrderQuery(order: SortOrder, hasTags: Boolean): String {
- if (!hasTags) return ""
- return when (order) {
- SortOrder.UPDATED -> "m_orderby=latest"
- SortOrder.POPULARITY -> "m_orderby=views"
- SortOrder.ALPHABETICAL -> "m_orderby=alphabet"
- SortOrder.RATING -> "m_orderby=rating"
- else -> "m_orderby=latest"
- }
- }
-
- protected open val tagPath = "genre"
-
- private suspend fun fetchTags(): Set = webClient.httpGet("https://$domain/$tagPath").parseHtml()
- .select("ul.page-genres li a")
- .mapToSet { a ->
- val title = a.ownText().toTitleCase(sourceLocale)
- MangaTag(
- key = a.attr("href").substringAfterLast('/'),
- title = title,
- source = source,
- )
- }
-}
-
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt
index 32b94f81..c9f84c20 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.ContentType
+import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
@@ -18,13 +19,13 @@ import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
-import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.network.OkHttpWebClient
import org.koitharu.kotatsu.parsers.util.generateUid
import org.koitharu.kotatsu.parsers.util.parseJson
-import org.koitharu.kotatsu.parsers.model.Demographic
import java.util.EnumSet
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
internal abstract class MangaFireParser(
context: MangaLoaderContext,
@@ -35,12 +36,14 @@ internal abstract class MangaFireParser(
override val configKeyDomain = ConfigKey.Domain("mangafire.to")
override val availableSortOrders: Set = EnumSet.of(
- SortOrder.UPDATED,
- SortOrder.POPULARITY,
- SortOrder.RATING,
- SortOrder.NEWEST,
- SortOrder.ALPHABETICAL,
- SortOrder.RELEVANCE,
+ SortOrder.UPDATED, // chapter update
+ SortOrder.POPULARITY, // most views
+ SortOrder.RATING, // rating score
+ SortOrder.NEWEST, // created manga
+ SortOrder.ALPHABETICAL, // title asc
+ SortOrder.RELEVANCE, // relevance sc
+ SortOrder.POPULARITY_WEEK,
+ SortOrder.POPULARITY_MONTH,
)
private val apiClient by lazy {
@@ -67,27 +70,64 @@ internal abstract class MangaFireParser(
isTagsExclusionSupported = true,
isSearchSupported = true,
isSearchWithFiltersSupported = true,
- isYearSupported = false, // idk how to do the year support
- isYearRangeSupported = false,
+ isYearRangeSupported = true,
)
- // ---------- Tag ----------
companion object {
val GENRE_MAP = mapOf(
- "Action" to "1", "Adventure" to "78", "Avant Garde" to "3",
- "Boys Love" to "4", "Comedy" to "5", "Demons" to "77",
- "Drama" to "6", "Ecchi" to "7", "Fantasy" to "79",
- "Girls Love" to "9", "Gourmet" to "10", "Harem" to "11",
- "Horror" to "530", "Isekai" to "13", "Iyashikei" to "531",
- "Josei" to "15", "Kids" to "532", "Magic" to "539",
- "Mahou Shoujo" to "533", "Martial Arts" to "534",
- "Mecha" to "19", "Military" to "535", "Music" to "21",
- "Mystery" to "22", "Parody" to "23", "Psychological" to "536",
- "Reverse Harem" to "25", "Romance" to "26", "School" to "73",
- "Sci-Fi" to "28", "Seinen" to "537", "Shoujo" to "30",
- "Shounen" to "31", "Slice of Life" to "538", "Space" to "33",
- "Sports" to "34", "Super Power" to "75", "Supernatural" to "76",
- "Suspense" to "37", "Thriller" to "38", "Vampire" to "39"
+ "Action" to "1",
+ "Adult" to "268929",
+ "Adventure" to "78",
+ "Avant Garde" to "3",
+ "Boys Love" to "4",
+ "Comedy" to "5",
+ "Crime" to "268921",
+ "Demons" to "77",
+ "Drama" to "6",
+ "Ecchi" to "7",
+ "Fantasy" to "79",
+ "Girls Love" to "9",
+ "Gourmet" to "10",
+ "Harem" to "11",
+ "Hentai" to "268930",
+ "Historical" to "268922",
+ "Horror" to "530",
+ "Isekai" to "13",
+ "Iyashikei" to "531",
+ "Josei" to "15",
+ "Kids" to "532",
+ "Magic" to "539",
+ "Magical Girls" to "268923",
+ "Mahou Shoujo" to "533",
+ "Martial Arts" to "534",
+ "Mature" to "268931",
+ "Mecha" to "19",
+ "Medical" to "268924",
+ "Military" to "535",
+ "Music" to "21",
+ "Mystery" to "22",
+ "Parody" to "23",
+ "Philosophical" to "268925",
+ "Psychological" to "536",
+ "Reverse Harem" to "25",
+ "Romance" to "26",
+ "School" to "73",
+ "Sci-Fi" to "28",
+ "Seinen" to "537",
+ "Shoujo" to "30",
+ "Shounen" to "31",
+ "Slice of Life" to "538",
+ "Smut" to "268932",
+ "Space" to "33",
+ "Sports" to "34",
+ "Super Power" to "75",
+ "Superhero" to "268926",
+ "Supernatural" to "76",
+ "Suspense" to "37",
+ "Thriller" to "38",
+ "Tragedy" to "268927",
+ "Vampire" to "39",
+ "Wuxia" to "268928"
)
}
@@ -106,20 +146,30 @@ internal abstract class MangaFireParser(
availableContentTypes = EnumSet.of(
ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER,
),
- availableDemographics = EnumSet.of(Demographic.SHOUNEN, Demographic.SHOUJO, Demographic.SEINEN, Demographic.JOSEI,
- ),
+ availableDemographics = EnumSet.of(Demographic.SHOUNEN, Demographic.SHOUJO, Demographic.SEINEN, Demographic.JOSEI),
)
- // ---------- Listing ----------
override suspend fun getListPage(
page: Int,
order: SortOrder,
filter: MangaListFilter,
): List {
- val url = StringBuilder("https://$domain/api/titles?page=$page&limit=50")
+ val urlBuilder = okhttp3.HttpUrl.Builder()
+ .scheme("https")
+ .host(domain)
+ .addPathSegments("api/titles")
+ .addQueryParameter("page", page.toString())
+ .addQueryParameter("limit", "50")
if (!filter.query.isNullOrBlank()) {
- url.append("&keyword=").append(filter.query)
+ urlBuilder.addQueryParameter("keyword", filter.query)
+ }
+
+ if (filter.yearFrom > 0) {
+ urlBuilder.addQueryParameter("year_from", filter.yearFrom.toString())
+ }
+ if (filter.yearTo > 0) {
+ urlBuilder.addQueryParameter("year_to", filter.yearTo.toString())
}
filter.types.forEach { type ->
@@ -130,7 +180,7 @@ internal abstract class MangaFireParser(
ContentType.OTHER -> "other"
else -> null
}
- value?.let { url.append("&types[]=$it") }
+ value?.let { urlBuilder.addQueryParameter("types[]", it) }
}
filter.demographics.forEach { demo ->
@@ -141,40 +191,43 @@ internal abstract class MangaFireParser(
Demographic.SHOUNEN -> "268918"
else -> null
}
- id?.let { url.append("&demographics[]=$it") }
+ id?.let { urlBuilder.addQueryParameter("demographics[]", it) }
}
- filter.tags.forEach { url.append("&genres_in[]=").append(it.key) }
- filter.tagsExclude.forEach { url.append("&genres_ex[]=").append(it.key) }
-
- // --- Statuses ---
- filter.states.forEach {
- url.append("&statuses[]=").append(
- when (it) {
- MangaState.ONGOING -> "releasing"
- MangaState.FINISHED -> "finished"
- MangaState.ABANDONED -> "discontinued"
- MangaState.PAUSED -> "on_hiatus"
- MangaState.UPCOMING -> "not_yet_released"
- else -> ""
- }
- )
+ filter.tags.forEach { urlBuilder.addQueryParameter("genres_in[]", it.key) }
+ filter.tagsExclude.forEach { urlBuilder.addQueryParameter("genres_ex[]", it.key) }
+
+ filter.states.forEach { state ->
+ val apiState = when (state) {
+ MangaState.ONGOING -> "releasing"
+ MangaState.FINISHED -> "finished"
+ MangaState.ABANDONED -> "discontinued"
+ MangaState.PAUSED -> "on_hiatus"
+ MangaState.UPCOMING -> "not_yet_released"
+ else -> null
+ }
+ apiState?.let { urlBuilder.addQueryParameter("statuses[]", it) }
}
- // --- Sort order ---
val sortParam = when (order) {
- SortOrder.UPDATED -> "order[chapter_updated_at]=desc"
- SortOrder.POPULARITY -> "order[views_total]=desc"
- SortOrder.RATING -> "order[score]=desc"
- SortOrder.NEWEST -> "order[created_at]=desc"
- SortOrder.ALPHABETICAL -> "order[title]=asc"
- SortOrder.RELEVANCE -> "order[relevance]=desc"
- else -> ""
+ SortOrder.UPDATED -> "chapter_updated_at" to "desc"
+ SortOrder.POPULARITY -> "views_total" to "desc"
+ SortOrder.RATING -> "score" to "desc"
+ SortOrder.NEWEST -> "created_at" to "desc"
+ SortOrder.ALPHABETICAL -> "title" to "asc"
+ SortOrder.RELEVANCE -> "relevance" to "desc"
+ SortOrder.POPULARITY_WEEK -> "views_7d" to "desc"
+ SortOrder.POPULARITY_MONTH -> "views_30d" to "desc"
+ else -> null
+ }
+
+ sortParam?.let { (field, dir) ->
+ urlBuilder.addQueryParameter("order[$field]", dir)
}
- if (sortParam.isNotEmpty()) url.append("&").append(sortParam)
- // --- Execute request ---
- val response = apiClient.httpGet(url.toString()).parseJson()
+ val url = urlBuilder.build().toString()
+
+ val response = apiClient.httpGet(url).parseJson()
val items = response.getJSONArray("items")
val mangas = mutableListOf()
for (i in 0 until items.length()) {
@@ -208,102 +261,118 @@ internal abstract class MangaFireParser(
return mangas
}
- // ---------- Details ----------
override suspend fun getDetails(manga: Manga): Manga {
- val hid = manga.url.substringAfter("/title/").substringBefore("-").substringBefore("/")
- val detailsJson = apiClient.httpGet("https://$domain/api/titles/$hid").parseJson()
- val data = detailsJson.getJSONObject("data")
-
- val title = data.getString("title")
- val poster = data.optJSONObject("poster")
- val cover = poster?.optString("large")
- ?: poster?.optString("medium")
- ?: poster?.optString("small")
- val synopsisHtml = data.optString("synopsisHtml", null)
- val status = data.optString("status", null)
- val type = data.optString("type", null)
- val authors = data.optJSONArray("authors")?.let { arr ->
- (0 until arr.length()).map { arr.getJSONObject(it).getString("title") }
- }?.joinToString()
- val genres = data.optJSONArray("genres")?.let { arr ->
- (0 until arr.length()).map { arr.getJSONObject(it).getString("title") }
- }
- val themes = data.optJSONArray("themes")?.let { arr ->
- (0 until arr.length()).map { arr.getJSONObject(it).getString("title") }
- }
- val altTitlesArray = data.optJSONArray("altTitles")?.let { arr ->
- (0 until arr.length()).map { arr.getString(it) }
- } ?: emptyList()
+ detailsCache[manga.url]?.let { return it }
+
+ val result = coroutineScope {
+ val hid = extractHid(manga.url)
+
+ val detailsJson = apiClient.httpGet("https://$domain/api/titles/$hid").parseJson()
+ val data = detailsJson.getJSONObject("data")
+
+ val hasVolumes = data.optBoolean("hasVolumes", false)
+
+ val chaptersDeferred = async { fetchChapters(hid, hasVolumes) }
+
+ val title = data.getString("title")
+ val poster = data.optJSONObject("poster")
+ val cover = poster?.optString("large")
+ ?: poster?.optString("medium")
+ ?: poster?.optString("small")
+ val synopsisHtml = data.optString("synopsisHtml", null)
+ val status = data.optString("status", null)
+ val type = data.optString("type", null)
+ val authorsList = data.optJSONArray("authors")?.let { arr ->
+ (0 until arr.length()).map { arr.getJSONObject(it).getString("title") }
+ }.orEmpty()
+ val artistsList = data.optJSONArray("artists")?.let { arr ->
+ (0 until arr.length()).map { arr.getJSONObject(it).getString("title") }
+ }.orEmpty()
+ val genres = data.optJSONArray("genres")?.let { arr ->
+ (0 until arr.length()).map { arr.getJSONObject(it).getString("title") }
+ }
+ val themes = data.optJSONArray("themes")?.let { arr ->
+ (0 until arr.length()).map { arr.getJSONObject(it).getString("title") }
+ }
+ val altTitlesArray = data.optJSONArray("altTitles")?.let { arr ->
+ (0 until arr.length()).map { arr.getString(it) }
+ } ?: emptyList()
+
+ val rawRating = data.optDouble("rating", -1.0)
+ val rating = if (rawRating >= 0.0) (rawRating / 10.0).toFloat() else RATING_UNKNOWN
- val description = synopsisHtml?.let { Jsoup.parseBodyFragment(it).text() }
+ val synopsisText = synopsisHtml?.let { Jsoup.parseBodyFragment(it).text() } ?: ""
- val genreList = buildList {
- type?.let { add(it.replaceFirstChar { c -> c.uppercase() }) }
- genres?.let { addAll(it) }
- themes?.let { addAll(it) }
+ val genreList = buildList {
+ type?.let { add(it.replaceFirstChar { c -> c.uppercase() }) }
+ genres?.let { addAll(it) }
+ themes?.let { addAll(it) }
+ }
+ val genreTags = genreList.mapNotNull { name ->
+ tags.find { it.title == name }
+ }.toSet()
+
+ val chapters = chaptersDeferred.await()
+
+ manga.copy(
+ title = title,
+ coverUrl = cover ?: manga.coverUrl,
+ authors = (authorsList + artistsList).toSet(),
+ description = synopsisText.trim(),
+ rating = rating,
+ state = when (status?.lowercase()) {
+ "releasing" -> MangaState.ONGOING
+ "finished" -> MangaState.FINISHED
+ "discontinued" -> MangaState.ABANDONED
+ "on_hiatus" -> MangaState.PAUSED
+ "not_yet_released" -> MangaState.UPCOMING
+ else -> null
+ },
+ tags = genreTags,
+ altTitles = altTitlesArray.toSet(),
+ chapters = chapters,
+ )
}
- val genreTags = genreList.mapNotNull { name ->
- tags.find { it.title == name }
- }.toSet()
+ detailsCache.put(manga.url, result)
+ return result
+ }
-// val ratingValue = if (data.has("rating")) {
-// data.getDouble("rating").toFloat()
-// } else {
-// RATING_UNKNOWN
-// }
- // they use rating till 10 stars so idk
-
- val chapters = fetchChapters(hid)
-
- return manga.copy(
- title = title,
- coverUrl = cover ?: manga.coverUrl,
- authors = setOfNotNull(authors),
- description = description,
- state = when (status) {
- "releasing" -> MangaState.ONGOING
- "finished" -> MangaState.FINISHED
- "discontinued" -> MangaState.ABANDONED
- "on_hiatus" -> MangaState.PAUSED
- "not_yet_released" -> MangaState.UPCOMING
- else -> null
- },
- tags = genreTags,
- altTitles = altTitlesArray.toSet(),
- chapters = chapters,
- )
+ @get:Synchronized
+ private val detailsCache = object : LinkedHashMap(16, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean {
+ return size > 10
+ }
}
- // ---------- Chapters ----------
- private suspend fun fetchChapters(hid: String): List {
+ private suspend fun fetchChapters(hid: String, hasVolumes: Boolean): List {
val chapters = mutableListOf()
+ val base = "https://$domain/api/titles/$hid"
+
var page = 1
var lastPage: Int
do {
- val url = "https://$domain/api/titles/$hid/chapters?language=$siteLang&sort=number&order=desc&page=$page&limit=200"
- val response = apiClient.httpGet(url).parseJson()
- val items = response.getJSONArray("items")
- val meta = response.optJSONObject("meta")
+ val json = apiClient.httpGet(
+ "$base/chapters?language=$siteLang&sort=number&order=desc&page=$page&limit=200"
+ ).parseJson()
+ val items = json.getJSONArray("items")
+ val meta = json.optJSONObject("meta")
lastPage = meta?.optInt("lastPage", 1) ?: 1
for (i in 0 until items.length()) {
- val chObj = items.getJSONObject(i)
-
- // Only chapters for the current language
- if (chObj.getString("language") != siteLang) continue
-
- val id = chObj.getInt("id")
- val number = chObj.getDouble("number").toFloat()
- val name = chObj.optString("name", null)
- val createdAt = chObj.optLong("createdAt", 0L) * 1000L
+ val ch = items.getJSONObject(i)
+ if (ch.getString("language") != siteLang) continue
+
+ val id = ch.getInt("id")
+ val number = ch.getDouble("number").toFloat()
+ val name = ch.optString("name", null)
+ val createdAt = ch.optLong("createdAt", 0L) * 1000L
+ val type = ch.getString("type")
val chapterUrl = "/title/$hid/$id"
val displayName = buildString {
append("Ch. ")
append(number.toString().removeSuffix(".0"))
- if (!name.isNullOrBlank()) {
- append(" - $name")
- }
+ if (!name.isNullOrBlank()) append(" - $name")
}
chapters.add(
MangaChapter(
@@ -314,7 +383,7 @@ internal abstract class MangaFireParser(
url = chapterUrl,
scanlator = null,
uploadDate = createdAt,
- branch = null,
+ branch = type,
source = source,
)
)
@@ -322,13 +391,62 @@ internal abstract class MangaFireParser(
page++
} while (page <= lastPage)
- return chapters.sortedBy { it.number }
+ if (hasVolumes) {
+ try {
+ val volJson = apiClient.httpGet("$base/volumes?language=$siteLang").parseJson()
+ val volItems = volJson.getJSONArray("items")
+ for (i in 0 until volItems.length()) {
+ val vol = volItems.getJSONObject(i)
+ if (vol.getString("language") != siteLang) continue
+
+ val volId = vol.getInt("id")
+ val volNumber = vol.getDouble("number").toFloat()
+ val volName = vol.optString("name", "").takeIf { it.isNotBlank() }
+ val chapterCount = vol.optInt("chapterCount", 0)
+
+ val title = buildString {
+ append("Vol. ")
+ append(volNumber.toString().removeSuffix(".0"))
+ if (volName != null) append(" - $volName")
+ }
+ val name = if (chapterCount > 0) "$chapterCount chapters" else ""
+
+ chapters.add(
+ MangaChapter(
+ id = generateUid("/title/$hid/vol/$volId"),
+ title = title,
+ number = volNumber,
+ volume = 0,
+ url = "/title/$hid/vol/$volId",
+ scanlator = name,
+ uploadDate = 0L,
+ branch = "Volume",
+ source = source,
+ )
+ )
+ }
+ } catch (_: Exception) {
+ }
+ }
+
+ val distinctBranches = chapters.map { it.branch }.distinct()
+ val useGroups = distinctBranches.size > 1
+
+ return chapters
+ .map { chapter ->
+ chapter.copy(
+ branch = if (useGroups) (chapter.branch ?: "").replaceFirstChar { it.uppercase() } else null
+ )
+ }
+ .sortedBy { it.number }
}
- // ---------- Pages ----------
override suspend fun getPages(chapter: MangaChapter): List {
- val chapterId = chapter.url.substringAfterLast("/")
- val response = apiClient.httpGet("https://$domain/api/chapters/$chapterId").parseJson()
+ val chapterId = chapter.url.substringAfterLast("/") // numeric ID
+ val isVolume = chapter.url.contains("/vol/")
+ val endpoint = if (isVolume) "volumes" else "chapters"
+
+ val response = apiClient.httpGet("https://$domain/api/$endpoint/$chapterId").parseJson()
val pagesArray = response.getJSONObject("data").getJSONArray("pages")
val pages = ArrayList(pagesArray.length())
for (i in 0 until pagesArray.length()) {
@@ -352,6 +470,16 @@ internal abstract class MangaFireParser(
override suspend fun isAuthorized(): Boolean = true
override suspend fun getUsername(): String = ""
+
+ private fun extractHid(url: String): String {
+ val lastPart = url.removeSuffix("/").substringAfterLast("/")
+ return when {
+ lastPart.contains(".") -> lastPart.substringAfterLast(".")
+ lastPart.contains("-") -> lastPart.substringBefore("-")
+ else -> lastPart
+ }
+ }
+
@MangaSourceParser("MANGAFIRE_EN", "MangaFire (English)", "en")
class English(context: MangaLoaderContext) : MangaFireParser(context, MangaParserSource.MANGAFIRE_EN, "en")
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaPlus.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaPlus.kt
new file mode 100644
index 00000000..bf7ea2fc
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaPlus.kt
@@ -0,0 +1,293 @@
+package org.koitharu.kotatsu.parsers.site.kotatsu.all
+
+import okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Interceptor
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.json.JSONArray
+import org.json.JSONObject
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.parsers.MangaSourceParser
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.core.SinglePageMangaParser
+import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.util.*
+import org.koitharu.kotatsu.parsers.util.json.asTypedList
+import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
+import org.koitharu.kotatsu.parsers.util.json.mapJSON
+import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
+import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
+import java.util.*
+
+internal abstract class MangaPlusParser(
+ context: MangaLoaderContext,
+ source: MangaParserSource,
+ private val sourceLang: String
+):
+ SinglePageMangaParser(context, source), Interceptor {
+
+ private val apiUrl = "https://jumpg-webapi.tokyo-cdn.com/api"
+ override val configKeyDomain = ConfigKey.Domain("mangaplus.shueisha.co.jp")
+
+ override fun onCreateConfig(keys: MutableCollection>) {
+ super.onCreateConfig(keys)
+ keys.add(userAgentKey)
+ }
+
+ override val availableSortOrders: Set = EnumSet.of(
+ SortOrder.POPULARITY,
+ SortOrder.UPDATED,
+ SortOrder.ALPHABETICAL,
+ )
+
+ override val filterCapabilities: MangaListFilterCapabilities
+ get() = MangaListFilterCapabilities(
+ isSearchSupported = true,
+ )
+
+ override suspend fun getFilterOptions() = MangaListFilterOptions()
+
+ private val extraHeaders = Headers.headersOf("Session-Token", UUID.randomUUID().toString())
+
+ override suspend fun getList(order: SortOrder, filter: MangaListFilter): List {
+ return when {
+ filter.query.isNullOrEmpty() -> {
+ when (order) {
+ SortOrder.POPULARITY -> getPopularList()
+ SortOrder.UPDATED -> getLatestList()
+ else -> getAllTitleList()
+ }
+ }
+ else -> getAllTitleList(filter.query)
+ }
+ }
+
+ private suspend fun getPopularList(): List {
+ val json = apiCall("/title_list/ranking")
+
+ return json.getJSONObject("titleRankingView")
+ .getJSONArray("titles")
+ .asTypedList()
+ .toMangaList()
+ }
+
+ private suspend fun getLatestList(): List {
+ val json = apiCall("/title_list/updated")
+
+ return json.getJSONObject("titleUpdatedView")
+ .getJSONArray("latestTitle")
+ .mapJSON { it.getJSONObject("title") }
+ .toMangaList()
+ }
+
+ private val allTitleCache = suspendLazy {
+ apiCall("/title_list/allV2")
+ .getJSONObject("allTitlesViewV2")
+ .getJSONArray("AllTitlesGroup")
+ .mapJSON { it.getJSONArray("titles").asTypedList() }
+ .flatten()
+ }
+
+ private suspend fun getAllTitleList(query: String? = null): List {
+ return allTitleCache.get().toMangaList(query)
+ }
+
+ private fun List.toMangaList(query: String? = null): List {
+ return mapNotNull {
+ val language = it.getStringOrNull("language") ?: "ENGLISH"
+
+ if (language != sourceLang) {
+ return@mapNotNull null
+ }
+
+ val name = it.getString("name")
+ val author = it.getString("author")
+ .split('/')
+ .joinToString(transform = String::trim)
+
+ if (query != null && !(name.contains(query, true) || author.contains(query, true))) {
+ return@mapNotNull null
+ }
+
+ val titleId = it.getInt("titleId").toString()
+
+ Manga(
+ id = generateUid(titleId),
+ url = titleId,
+ publicUrl = "/titles/$titleId".toAbsoluteUrl(domain),
+ title = name,
+ coverUrl = it.getString("portraitImageUrl"),
+ altTitles = emptySet(),
+ authors = setOf(author),
+ contentRating = null,
+ rating = RATING_UNKNOWN,
+ state = null,
+ source = source,
+ tags = emptySet(),
+ )
+ }
+ }
+
+ override suspend fun getDetails(manga: Manga): Manga {
+ val json = apiCall("/title_detailV3?title_id=${manga.url}")
+ .getJSONObject("titleDetailView")
+ val title = json.getJSONObject("title")
+
+ val completed = json.getJSONObject("titleLabels")
+ .getString("releaseSchedule").let {
+ it == "DISABLED" || it == "COMPLETED"
+ }
+
+ val hiatus = json.getStringOrNull("nonAppearanceInfo")?.contains("on a hiatus") == true
+ val author = title.getString("author")
+ .split("/").joinToString(transform = String::trim)
+
+ return manga.copy(
+ title = title.getString("name"),
+ publicUrl = "/titles/${title.getInt("titleId")}".toAbsoluteUrl(domain),
+ coverUrl = title.getString("portraitImageUrl"),
+ authors = setOf(author),
+ description = buildString {
+ json.getString("overview").let(::append)
+ json.getStringOrNull("viewingPeriodDescription")
+ ?.takeIf { !completed }
+ ?.let { append("
", it) }
+ },
+ chapters = parseChapters(
+ json.getJSONArray("chapterListGroup"),
+ title.getStringOrNull("language") ?: "ENGLISH",
+ ),
+ state = when {
+ completed -> MangaState.FINISHED
+ hiatus -> MangaState.PAUSED
+ else -> MangaState.ONGOING
+ },
+ )
+ }
+
+ private fun parseChapters(chapterListGroup: JSONArray, language: String): List {
+ val chapterList = chapterListGroup
+ .asTypedList()
+ .flatMap {
+ it.optJSONArray("firstChapterList")?.asTypedList().orEmpty() +
+ it.optJSONArray("lastChapterList")?.asTypedList().orEmpty()
+ }
+
+ return chapterList.mapChapters { _, chapter ->
+ val chapterId = chapter.getInt("chapterId").toString()
+ val subtitle = chapter.getStringOrNull("subTitle") ?: return@mapChapters null
+
+ MangaChapter(
+ id = generateUid(chapterId),
+ url = chapterId,
+ title = subtitle,
+ number = chapter.getString("name")
+ .substringAfter("#")
+ .toFloatOrNull() ?: -1f,
+ volume = 0,
+ uploadDate = chapter.getInt("startTimeStamp") * 1000L,
+ branch = when (language) {
+ "PORTUGUESE_BR" -> "Portuguese (Brazil)"
+ else -> language.lowercase().toTitleCase()
+ },
+ scanlator = null,
+ source = source,
+ )
+ }
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val pages = apiCall("/manga_viewer?chapter_id=${chapter.url}&split=yes&img_quality=super_high")
+ .getJSONObject("mangaViewer")
+ .getJSONArray("pages")
+
+ return pages.mapJSONNotNull {
+ val mangaPage = it.optJSONObject("mangaPage")
+ ?: return@mapJSONNotNull null
+ val url = mangaPage.getString("imageUrl")
+ val encryptionKey = mangaPage.getStringOrNull("encryptionKey")
+ MangaPage(
+ id = generateUid(url),
+ url = url + if (encryptionKey == null) "" else "#$encryptionKey",
+ preview = null,
+ source = source,
+ )
+ }
+ }
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ val response = chain.proceed(request)
+ val encryptionKey = request.url.fragment
+
+ if (encryptionKey.isNullOrEmpty()) {
+ return response
+ }
+
+ return response.map { responseBody ->
+ val contentType = response.headers["Content-Type"] ?: "image/jpeg"
+ val image = responseBody.bytes().decodeXorCipher(encryptionKey)
+ image.toResponseBody(contentType.toMediaTypeOrNull())
+ }
+ }
+
+ private fun ByteArray.decodeXorCipher(key: String): ByteArray {
+ val keyStream = key.chunked(2)
+ .map { it.toInt(16) }
+
+ return mapIndexed { i, byte -> byte.toInt() xor keyStream[i % keyStream.size] }
+ .map(Int::toByte)
+ .toByteArray()
+ }
+
+ private suspend fun apiCall(url: String): JSONObject {
+ val newUrl = "$apiUrl$url".toHttpUrl().newBuilder()
+ .addQueryParameter("format", "json")
+ .build()
+ val response = webClient.httpGet(newUrl, extraHeaders).parseJson()
+
+ val success = response.optJSONObject("success")
+
+ return checkNotNull(success) {
+ val error = response.getJSONObject("error")
+ val reason = error.getJSONArray("popups")
+ .asTypedList()
+ .firstOrNull { it.getStringOrNull("language") == null }
+
+ if (reason?.getStringOrNull("subject") == "Not Found" && url.contains("manga_viewer")) {
+ "This chapter has expired"
+ } else {
+ reason?.getStringOrNull("body") ?: "Unknown Error"
+ }
+ }
+ }
+
+ @MangaSourceParser("MANGAPLUSPARSER_EN", "MANGA Plus (English)", "en")
+ class English(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_EN, "ENGLISH")
+
+ @MangaSourceParser("MANGAPLUSPARSER_ES", "MANGA Plus (Spanish)", "es")
+ class Spanish(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_ES, "SPANISH")
+
+ @MangaSourceParser("MANGAPLUSPARSER_FR", "MANGA Plus (French)", "fr")
+ class French(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_FR, "FRENCH")
+
+ @MangaSourceParser("MANGAPLUSPARSER_ID", "MANGA Plus (Indonesian)", "id")
+ class Indonesian(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_ID, "INDONESIAN")
+
+ @MangaSourceParser("MANGAPLUSPARSER_PTBR", "MANGA Plus Portuguese (Brazil)", "pt")
+ class Portuguese(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_PTBR, "PORTUGUESE_BR")
+
+ @MangaSourceParser("MANGAPLUSPARSER_RU", "MANGA Plus (Russian)", "ru")
+ class Russian(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_RU, "RUSSIAN")
+
+ @MangaSourceParser("MANGAPLUSPARSER_TH", "MANGA Plus (Thai)", "th")
+ class Thai(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_TH, "THAI")
+
+ @MangaSourceParser("MANGAPLUSPARSER_VI", "MANGA Plus (Vietnamese)", "vi")
+ class Vietnamese(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_VI, "VIETNAMESE")
+
+ @MangaSourceParser("MANGAPLUSPARSER_DE", "MANGA Plus (German)", "de")
+ class German(context: MangaLoaderContext) : MangaPlusParser(context, MangaParserSource.MANGAPLUSPARSER_DE, "GERMAN")
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Mangaball.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Mangaball.kt
index 31abb075..34b940db 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Mangaball.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Mangaball.kt
@@ -134,6 +134,8 @@ internal abstract class MangaBallParser(
}
override suspend fun getDetails(manga: Manga): Manga {
+ detailsCache[manga.url]?.let { return it }
+
ensureAdultCookie()
val doc = client.httpGet(getMangaUrl(manga.url)).parseHtml()
updateCsrf(doc)
@@ -165,7 +167,7 @@ internal abstract class MangaBallParser(
else -> manga.contentRating
}
- return manga.copy(
+ val result = manga.copy(
title = title,
altTitles = if (altTitles.isEmpty()) manga.altTitles else altTitles,
publicUrl = getMangaUrl(manga.url),
@@ -178,6 +180,8 @@ internal abstract class MangaBallParser(
chapters = getChapterList(manga.url),
contentRating = contentRating,
)
+ detailsCache[manga.url] = result
+ return result
}
override suspend fun getPages(chapter: MangaChapter): List {
@@ -543,21 +547,32 @@ internal abstract class MangaBallParser(
private fun getCsrf(): String {
if (csrfToken.isNullOrEmpty()) {
refreshCsrf()
+ if (csrfToken.isNullOrEmpty()) {
+ refreshCsrf()
+ }
+ if (csrfToken.isNullOrEmpty()) {
+ error("CSRF token not found after two attempts")
+ }
}
- return csrfToken ?: error("CSRF token not found")
+ return csrfToken!!
}
@Synchronized
private fun refreshCsrf(document: Document? = null) {
ensureAdultCookie()
- updateCsrf(
- document ?: rawHttpClient.newCall(
- Request.Builder()
- .url(BASE_URL)
- .headers(getHeadersWithReferer())
- .build(),
- ).execute().parseHtml(),
- )
+ if (document != null) {
+ updateCsrf(document)
+ if (!csrfToken.isNullOrEmpty()) return
+ }
+
+ val request = Request.Builder()
+ .url(BASE_URL)
+ .headers(getHeadersWithReferer())
+ .header("Cache-Control", "no-cache")
+ .build()
+ val response = rawHttpClient.newCall(request).execute()
+ updateCsrf(response.parseHtml())
+ response.close()
}
@Synchronized
@@ -567,6 +582,11 @@ internal abstract class MangaBallParser(
}
}
+ @get:Synchronized
+ private val detailsCache = object : LinkedHashMap(16, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 10
+ }
+
private fun MutableList.addEncoded(key: String, value: String) {
add("${key.urlEncoded()}=${value.urlEncoded()}")
}
@@ -889,4 +909,4 @@ internal abstract class MangaBallParser(
MangaParserSource.MANGABALL_ZH,
setOf("zh", "zh-cn", "zh-hk", "zh-mo", "zh-sg", "zh-tw"),
)
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt
index 3665034d..08fd7947 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt
@@ -32,6 +32,8 @@ import java.util.EnumSet
import java.util.LinkedHashMap
import java.util.LinkedHashSet
import java.util.Locale
+import java.text.SimpleDateFormat
+import java.util.Calendar
@MangaSourceParser("PROJECTSUKI", "ProjectSuki")
internal class ProjectSuki(context: MangaLoaderContext) :
@@ -44,7 +46,6 @@ internal class ProjectSuki(context: MangaLoaderContext) :
override val availableSortOrders: Set = EnumSet.of(
SortOrder.UPDATED,
SortOrder.POPULARITY,
- SortOrder.RELEVANCE,
)
override val filterCapabilities: MangaListFilterCapabilities
@@ -178,6 +179,11 @@ internal class ProjectSuki(context: MangaLoaderContext) :
val bookId = manga.url.toBookId() ?: document.location().toBookId() ?: manga.url.substringAfterLast('/')
+ val fallbackTimestamp = document.selectFirst("meta[property='og:updated_time']")
+ ?.attr("content")
+ ?.toLongOrNull()
+ ?.times(1000L)
+
val details = parseDetailsTable(document)
val title = document.selectFirst("h2[itemprop=title]")?.text()?.nullIfEmpty()
?: document.selectFirst("h2")?.text()?.nullIfEmpty()
@@ -191,7 +197,7 @@ internal class ProjectSuki(context: MangaLoaderContext) :
val state = parseState(details["status"])
val genres = parseGenres(document)
val altTitles = setOfNotNull(details["alt titles"] ?: details["alternative titles"])
- val chapters = parseChapters(document)
+ val chapters = parseChapters(document, fallbackTimestamp)
return manga.copy(
title = title,
@@ -264,35 +270,115 @@ internal class ProjectSuki(context: MangaLoaderContext) :
}
}
- private fun parseChapters(document: Document): List {
+ private fun parseChapters(document: Document, fallbackTimestamp: Long? = null): List {
val chaptersMap = LinkedHashMap()
- document.select("a[href*=/read/]").forEach { anchor ->
- val parts = anchor.absUrl("href").toChapterParts() ?: return@forEach
- val key = "${parts.bookId}/${parts.chapterId}"
- if (key in chaptersMap) return@forEach
-
- val parentText = anchor.parent()?.text().orEmpty()
- val title = anchor.text().trim().nullIfEmpty()
- val chapterNumber = parseChapterNumber(title ?: parentText)
-
- val chapter = MangaChapter(
- id = generateUid(key),
- title = title,
- number = chapterNumber,
- volume = 0,
- url = "/read/${parts.bookId}/${parts.chapterId}/1",
- scanlator = null,
- uploadDate = 0L,
- branch = null,
- source = source,
- )
- chaptersMap[key] = chapter
+ val table = document.select("table").firstOrNull { table ->
+ table.select("tr").firstOrNull()?.let { row ->
+ row.select("td, th").any { cell ->
+ cell.text().trim().matches(Regex("added|date", RegexOption.IGNORE_CASE))
+ }
+ } ?: false
+ }
+
+ if (table != null) {
+ val headerRow = table.select("tr").first()
+ val headers = headerRow!!.select("td, th").map { it.text().trim().lowercase() }
+
+ val dateCol = headers.indexOfFirst { it == "added" || it == "date" }
+ val chapterCol = headers.indexOfFirst { it.matches(Regex("chapter|ch\\.|name|#")) }
+ .takeIf { it >= 0 } ?: 0
+
+ val dataRows = table.select("tbody tr").ifEmpty { table.select("tr").drop(1) }
+ for (row in dataRows) {
+ val cells = row.select("td, th")
+ if (cells.size <= maxOf(chapterCol, dateCol)) continue
+
+ val chapterCell = cells[chapterCol]
+ val link = chapterCell.selectFirst("a[href]") ?: continue
+ val parts = link.absUrl("href").toChapterParts() ?: continue
+ val key = "${parts.bookId}/${parts.chapterId}"
+ if (key in chaptersMap) continue
+
+ val title = link.text().trim().nullIfEmpty()
+ ?: chapterCell.text().trim().nullIfEmpty()
+ val number = parseChapterNumber(title ?: "")
+
+ val dateText = if (dateCol >= 0) cells[dateCol].text().trim() else ""
+ val uploadDate = parseChapterDate(dateText) ?: fallbackTimestamp ?: 0L
+
+ chaptersMap[key] = MangaChapter(
+ id = generateUid(key),
+ title = title,
+ number = number,
+ volume = 0,
+ url = "/read/${parts.bookId}/${parts.chapterId}/1",
+ scanlator = null,
+ uploadDate = uploadDate,
+ branch = null,
+ source = source,
+ )
+ }
+ } else {
+ document.select("a[href*=/read/]").forEach { anchor ->
+ val parts = anchor.absUrl("href").toChapterParts() ?: return@forEach
+ val key = "${parts.bookId}/${parts.chapterId}"
+ if (key in chaptersMap) return@forEach
+
+ val title = anchor.text().trim().nullIfEmpty()
+ val number = parseChapterNumber(title ?: "")
+ chaptersMap[key] = MangaChapter(
+ id = generateUid(key),
+ title = title,
+ number = number,
+ volume = 0,
+ url = "/read/${parts.bookId}/${parts.chapterId}/1",
+ scanlator = null,
+ uploadDate = fallbackTimestamp ?: 0L,
+ branch = null,
+ source = source,
+ )
+ }
}
return chaptersMap.values.sortedBy { it.number }
}
+ private fun parseChapterDate(text: String): Long? {
+ if (text.isBlank()) return null
+
+ val relativeRegex = Regex("""(\d+)\s+(years?|months?|weeks?|days?|hours?|mins?|minutes?|seconds?|sec)\s+ago""", RegexOption.IGNORE_CASE)
+ relativeRegex.matchEntire(text.trim())?.let { match ->
+ val number = match.groupValues[1].toInt()
+ val unit = match.groupValues[2].lowercase()
+ val cal = Calendar.getInstance()
+ when {
+ unit.startsWith("year") -> cal.add(Calendar.YEAR, -number)
+ unit.startsWith("month") -> cal.add(Calendar.MONTH, -number)
+ unit.startsWith("week") -> cal.add(Calendar.DAY_OF_MONTH, -number * 7)
+ unit.startsWith("day") -> cal.add(Calendar.DAY_OF_MONTH, -number)
+ unit.startsWith("hour") -> cal.add(Calendar.HOUR, -number)
+ unit.startsWith("min") -> cal.add(Calendar.MINUTE, -number)
+ unit.startsWith("sec") -> cal.add(Calendar.SECOND, -number)
+ }
+ return cal.timeInMillis
+ }
+
+ val formats = listOf(
+ SimpleDateFormat("MMMM dd, yyyy", Locale.US),
+ SimpleDateFormat("MMM dd, yyyy", Locale.US),
+ SimpleDateFormat("MMM d, yyyy", Locale.US),
+ SimpleDateFormat("yyyy-MM-dd", Locale.US),
+ )
+ for (fmt in formats) {
+ try {
+ return fmt.parse(text)?.time
+ } catch (_: Exception) {}
+ }
+
+ return null
+ }
+
private fun parseChapterNumber(text: String): Float {
val match = CHAPTER_NUMBER_REGEX.find(text) ?: return 0f
val main = match.groupValues.getOrNull(1)?.toFloatOrNull() ?: return 0f
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Webtoons.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Webtoons.kt
index eb1e87dd..500ae46c 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Webtoons.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Webtoons.kt
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.all
import androidx.collection.arraySetOf
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
+import okhttp3.HttpUrl.Companion.toHttpUrl
import org.jsoup.nodes.Element
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
@@ -14,6 +15,11 @@ import org.koitharu.kotatsu.parsers.util.*
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import java.util.EnumSet
+private const val ID_SEARCH_PREFIX = "id:"
+
+private const val webtoons = "webtoons"
+private const val canvas = "canvas"
+
internal abstract class WebtoonsParser(
context: MangaLoaderContext,
source: MangaParserSource,
@@ -24,6 +30,14 @@ internal abstract class WebtoonsParser(
private val mobileApiDomain = "m.webtoons.com"
private val staticDomain = "webtoon-phinf.pstatic.net"
+ private val contentKey = ConfigKey.PreferredImageServer(
+ presetValues = mapOf(
+ webtoons to "Webtoons",
+ canvas to "Canvas",
+ ),
+ defaultValue = webtoons,
+ )
+
override val availableSortOrders: EnumSet = EnumSet.of(
SortOrder.POPULARITY,
SortOrder.RATING,
@@ -35,8 +49,7 @@ internal abstract class WebtoonsParser(
isSearchSupported = true,
)
- override val userAgentKey =
- ConfigKey.UserAgent("Mozilla/5.0 (Linux; Android 12; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36")
+ override val userAgentKey = ConfigKey.UserAgent("Mozilla/5.0 (Linux; Android 12; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36")
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = availableTags(),
@@ -45,6 +58,7 @@ internal abstract class WebtoonsParser(
override fun onCreateConfig(keys: MutableCollection>) {
super.onCreateConfig(keys)
keys.add(userAgentKey)
+ keys.add(contentKey)
}
override suspend fun getPageUrl(page: MangaPage): String {
@@ -59,7 +73,12 @@ internal abstract class WebtoonsParser(
}
private suspend fun fetchEpisodes(titleNo: Long, type: String): List {
- val url = "https://$mobileApiDomain/api/v1/$type/$titleNo/episodes?pageSize=99999"
+ val baseUrl = "https://$mobileApiDomain/api/v1/$type/$titleNo/episodes"
+ val url = if (type == "canvas") {
+ "$baseUrl?pageSize=99999&readingLanguageCode=$languageCode"
+ } else {
+ "$baseUrl?pageSize=99999"
+ }
val json = webClient.httpGet(url).parseJson()
val episodeList = json.optJSONObject("result")?.optJSONArray("episodeList")
@@ -69,10 +88,15 @@ internal abstract class WebtoonsParser(
val episodeTitle = jo.getStringOrNull("episodeTitle") ?: ""
val episodeNo = jo.getInt("episodeNo")
val viewerLink = jo.getString("viewerLink")
+ val hasBgm = jo.optBoolean("hasBgm", false)
MangaChapter(
id = generateUid("$titleNo-$episodeNo"),
- title = episodeTitle,
+ title = buildString {
+ append(org.jsoup.parser.Parser.unescapeEntities(episodeTitle, false))
+ append(" (ch. $episodeNo)")
+ if (hasBgm) append(" ♫")
+ },
number = episodeNo.toFloat(),
volume = 0,
url = viewerLink,
@@ -81,8 +105,7 @@ internal abstract class WebtoonsParser(
scanlator = null,
source = source,
)
- }.sortedBy(MangaChapter::number)
-
+ }.sortedBy { it.number }
}
override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
@@ -108,10 +131,15 @@ internal abstract class WebtoonsParser(
val author = listOf(
doc.select("meta[property='com-linewebtoon:webtoon:author']").attr("content"),
- doc.select(".detail_header .info .author").firstOrNull()?.text(),
+ doc.select(".detail_header .info .author:nth-of-type(1)").firstOrNull()?.ownText(),
doc.select(".author_area").text(),
).firstOrNull { !it.isNullOrBlank() && it != "null" }
+ val artist = listOf(
+ doc.select(".detail_header .info .author:nth-of-type(2)").firstOrNull()?.ownText(),
+ doc.select(".author_area").text().takeIf { it != author },
+ ).firstOrNull { !it.isNullOrBlank() } ?: author
+
val genreElements = doc.select(".detail_header .info .genre").ifEmpty {
doc.select("h2.genre")
}
@@ -140,7 +168,7 @@ internal abstract class WebtoonsParser(
coverUrl = coverUrl,
largeCoverUrl = null,
tags = genres.map { genre -> MangaTag(title = genre, key = genre.lowercase(), source = source) }.toSet(),
- authors = setOfNotNull(author.takeIf { it != "null" }),
+ authors = setOfNotNull(author, artist).filter { it != "null" }.toSet(),
description = description,
state = state,
chapters = chapters,
@@ -182,39 +210,132 @@ internal abstract class WebtoonsParser(
}
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List {
- val query = filter.query
- val document = when {
- !query.isNullOrEmpty() -> {
- val searchUrl = "https://$domain/$languageCode/search?keyword=${query.urlEncoded()}"
- webClient.httpGet(searchUrl).parseHtml()
+ val query = filter.query.orEmpty()
+ val currentContent = config[contentKey] ?: webtoons
+
+ var directTitleNo: Long? = null
+ var directType: String? = null
+
+ when {
+ query.startsWith("https://") -> {
+ val url = query.toHttpUrl()
+ if (!url.host.endsWith("webtoons.com")) {
+ throw Exception("Unsupported domain: ${url.host}. Only webtoons.com and m.webtoons.com are allowed.")
+ }
+ directTitleNo = url.queryParameter("title_no")?.toLongOrNull()
+ ?: throw Exception("Missing 'title_no' parameter in URL")
+ val path = url.pathSegments.filter(String::isNotEmpty)
+ if (path.size < 2) throw Exception("Invalid URL path")
+ if (path[0] != languageCode) {
+ throw Exception("URL language (${path[0]}) does not match parser language ($languageCode)")
+ }
+ directType = if (path.getOrNull(1) == "canvas") canvas else webtoons
+ }
+
+ query.startsWith(ID_SEARCH_PREFIX) -> {
+ val idPart = query.removePrefix(ID_SEARCH_PREFIX)
+ val parts = idPart.split(":")
+ val (type, lang, titleNo) = when (parts.size) {
+ 1 -> Triple(currentContent, languageCode, parts[0].toLongOrNull()
+ ?: throw Exception("Invalid title number: ${parts[0]}"))
+ 2 -> Triple(parts[0], languageCode, parts[1].toLongOrNull()
+ ?: throw Exception("Invalid title number: ${parts[1]}"))
+ 3 -> Triple(parts[0], parts[1], parts[2].toLongOrNull()
+ ?: throw Exception("Invalid title number: ${parts[2]}"))
+ else -> throw Exception("Invalid id search format. Use id:type:lang:titleNo, id:type:titleNo, or id:titleNo")
+ }
+ if (lang != languageCode) return emptyList()
+ if (type !in listOf(webtoons, canvas)) {
+ throw Exception("Unknown type '$type'. Use 'webtoons' or 'canvas'.")
+ }
+ directTitleNo = titleNo
+ directType = type
}
- filter.tags.isNotEmpty() -> {
- val selectedGenre = filter.tags.first()
- val genreUrlPath = genreUrlMap[selectedGenre.key] ?: selectedGenre.key
- val sortParam = getSortOrderParam(order)
- val genreUrl = "https://$domain/$languageCode/genres/$genreUrlPath?sortOrder=$sortParam"
- webClient.httpGet(genreUrl).parseHtml()
+ query.toLongOrNull() != null -> {
+ directTitleNo = query.toLong()
+ directType = currentContent
}
+ }
+
+ if (directTitleNo != null && directType != null) {
+ val detailsUrl = "https://$domain/$languageCode/$directType/placeholder/list?title_no=$directTitleNo"
+ val manga = getDetails(Manga(
+ id = generateUid(directTitleNo),
+ title = "",
+ altTitles = emptySet(),
+ url = directTitleNo.toString(),
+ publicUrl = detailsUrl,
+ rating = RATING_UNKNOWN,
+ contentRating = null,
+ coverUrl = null,
+ tags = emptySet(),
+ state = null,
+ authors = emptySet(),
+ source = source,
+ ))
+ return listOf(manga)
+ }
- else -> {
- val rankingType = when (order) {
- SortOrder.POPULARITY -> "popular"
- SortOrder.RATING -> "trending"
- SortOrder.UPDATED -> "originals"
- else -> "popular"
+ val page = offset / 20 + 1
+ val inPageOffset = offset % 20
+ val selectedGenre = filter.tags.firstOrNull()
+
+ val url = buildString {
+ append("https://$domain/$languageCode")
+ when {
+ query.isNotBlank() -> {
+ append("/search")
+ var cleanQuery = query
+ var searchType: String? = null
+ when {
+ cleanQuery.startsWith("type:originals ") -> {
+ searchType = "originals"
+ cleanQuery = cleanQuery.removePrefix("type:originals ").trim()
+ }
+ cleanQuery.startsWith("type:canvas ") -> {
+ searchType = "canvas"
+ cleanQuery = cleanQuery.removePrefix("type:canvas ").trim()
+ }
+ }
+ if (searchType == null) {
+ searchType = when (currentContent) {
+ webtoons -> "originals"
+ canvas -> "canvas"
+ else -> null
+ }
+ }
+ if (searchType != null) append("/$searchType")
+ append("?keyword=${cleanQuery.urlEncoded()}")
+ if (page > 1) append("&page=$page")
+ }
+ selectedGenre != null -> {
+ val genreUrlPath = genreUrlMap[selectedGenre.key] ?: selectedGenre.key
+ append("/genres/$genreUrlPath")
+ append("?sortOrder=${getSortOrderParam(order)}")
+ if (page > 1) append("&page=$page")
+ }
+ else -> {
+ val rankingType = when {
+ currentContent == canvas -> "canvas"
+ else -> when (order) {
+ SortOrder.POPULARITY -> "popular"
+ SortOrder.RATING -> "trending"
+ SortOrder.UPDATED -> "originals"
+ else -> "popular"
+ }
+ }
+ append("/ranking/$rankingType")
+ if (page > 1) append("?page=$page")
}
- val rankingUrl = "https://$domain/$languageCode/ranking/$rankingType"
- webClient.httpGet(rankingUrl).parseHtml()
}
}
- val selectedGenreForManga = if (filter.tags.isNotEmpty()) filter.tags.first() else null
+ val doc = webClient.httpGet(url).parseHtml()
+ val items = doc.select(".webtoon_list li a, .card_wrap .card_item a")
+ .map { element -> createMangaFromElement(element, source as MangaParserSource, selectedGenre) }
- return document.select(".webtoon_list li a, .card_wrap .card_item a")
- .map { element -> createMangaFromElement(element, source as MangaParserSource, selectedGenreForManga) }
- .drop(offset)
- .take(20)
+ return items.drop(inPageOffset).take(20)
}
private fun createMangaFromElement(
@@ -236,11 +357,11 @@ internal abstract class WebtoonsParser(
rating = RATING_UNKNOWN,
contentRating = null,
coverUrl = thumbnailUrl.toAbsoluteUrl(staticDomain),
- largeCoverUrl = null,
tags = selectedGenre?.let { setOf(it) } ?: emptySet(),
+ state = null,
authors = emptySet(),
+ largeCoverUrl = null,
description = null,
- state = null,
source = source,
)
}
@@ -258,44 +379,77 @@ internal abstract class WebtoonsParser(
throw ParseException("Failed to get pages for chapter: ${chapter.title}", chapter.url, e)
}
- fun extractImages(selector: String, attr: String = "data-url"): List {
- return doc.select(selector).mapIndexedNotNull { i, element ->
- val url = element.attr(attr).takeIf { it.isNotBlank() }
- ?: element.attr("src").takeIf { it.contains(staticDomain) }
- ?: return@mapIndexedNotNull null
- MangaPage(
- id = generateUid("${chapter.id}-$i"),
- url = url,
- preview = null,
- source = source,
- )
+ val images = doc.select("div#_imageList > img").mapIndexedNotNull { i, element ->
+ val rawUrl = element.attr("data-url").takeIf { it.isNotBlank() }
+ ?: element.attr("src").takeIf { it.contains(staticDomain) }
+ ?: return@mapIndexedNotNull null
+
+ MangaPage(
+ id = generateUid("${chapter.id}-$i"),
+ url = rawUrl,
+ preview = null,
+ source = source,
+ )
+ }.toMutableList()
+
+ if (images.isEmpty()) {
+ val motionPages = fetchMotionToonPages(doc)
+ if (motionPages.isNotEmpty()) {
+ images.addAll(motionPages)
}
}
- return extractImages("div#_imageList > img")
- .ifEmpty { extractImages("canvas[data-url]") }
- .ifEmpty { extractImages("img[src*='$staticDomain'], img[data-url*='$staticDomain']") }
- .ifEmpty { throw ParseException("No images found in chapter.", chapter.url) }
+ if (images.isEmpty()) {
+ throw ParseException("No images found in chapter.", chapter.url)
+ }
+ return images
+ }
+
+ private suspend fun fetchMotionToonPages(doc: org.jsoup.nodes.Document): List {
+ val docString = doc.toString()
+ val docUrlRegex = Regex("documentURL:.*?'(.*?)'")
+ val motionToonPathRegex = Regex("jpg:.*?'(.*?)\\{")
+
+ val docUrl = docUrlRegex.find(docString)?.groupValues?.get(1) ?: return emptyList()
+ val motionToonPath = motionToonPathRegex.find(docString)?.groupValues?.get(1) ?: return emptyList()
+
+ return try {
+ val motionToonResponse = webClient.httpGet(docUrl).parseJson()
+ val imagesMap = motionToonResponse.optJSONObject("assets")?.optJSONObject("images")
+ imagesMap?.let { map ->
+ map.keySet().filter { it.contains("layer") }.mapIndexedNotNull { i, key ->
+ val relPath = map.optString(key, null) ?: return@mapIndexedNotNull null
+ MangaPage(
+ id = generateUid("motion-$i"),
+ url = motionToonPath + relPath,
+ preview = null,
+ source = source,
+ )
+ }
+ } ?: emptyList()
+ } catch (_: Exception) {
+ emptyList()
+ }
}
- @MangaSourceParser("WEBTOONS_EN", "Webtoons English", "en", type = ContentType.MANGA)
+ @MangaSourceParser("WEBTOONS_EN", "Webtoons (English)", "en", type = ContentType.MANGA)
class English(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_EN)
- @MangaSourceParser("WEBTOONS_ID", "Webtoons Indonesia", "id", type = ContentType.MANGA)
+ @MangaSourceParser("WEBTOONS_ID", "Webtoons (Indonesia)", "id", type = ContentType.MANGA)
class Indonesian(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_ID)
- @MangaSourceParser("WEBTOONS_ES", "Webtoons Spanish", "es", type = ContentType.MANGA)
+ @MangaSourceParser("WEBTOONS_ES", "Webtoons (Spanish)", "es", type = ContentType.MANGA)
class Spanish(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_ES)
- @MangaSourceParser("WEBTOONS_FR", "Webtoons French", "fr", type = ContentType.MANGA)
+ @MangaSourceParser("WEBTOONS_FR", "Webtoons (French)", "fr", type = ContentType.MANGA)
class French(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_FR)
- @MangaSourceParser("WEBTOONS_TH", "Webtoons Thai", "th", type = ContentType.MANGA)
+ @MangaSourceParser("WEBTOONS_TH", "Webtoons (Thai)", "th", type = ContentType.MANGA)
class Thai(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_TH)
- @MangaSourceParser("WEBTOONS_ZH", "Webtoons Chinese", "zh", type = ContentType.MANGA)
+ @MangaSourceParser("WEBTOONS_ZH", "Webtoons (Chinese)", "zh", type = ContentType.MANGA)
class Chinese(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_ZH)
- @MangaSourceParser("WEBTOONS_DE", "Webtoons German", "de", type = ContentType.MANGA)
+ @MangaSourceParser("WEBTOONS_DE", "Webtoons (German)", "de", type = ContentType.MANGA)
class German(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_DE)
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt
index 10737e4d..f23e3df5 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt
@@ -2,218 +2,1075 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.all.hentais
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.MangaSourceParser
-import org.koitharu.kotatsu.parsers.model.ContentRating
-import org.koitharu.kotatsu.parsers.model.ContentType
-import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.model.MangaChapter
-import org.koitharu.kotatsu.parsers.model.MangaListFilter
-import org.koitharu.kotatsu.parsers.model.MangaPage
-import org.koitharu.kotatsu.parsers.model.MangaParserSource
-import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
-import org.koitharu.kotatsu.parsers.model.SortOrder
-import org.koitharu.kotatsu.parsers.parsers.GalleryAdultsParser
-import org.koitharu.kotatsu.parsers.util.generateUid
-import org.koitharu.kotatsu.parsers.util.json.mapJSON
-import org.koitharu.kotatsu.parsers.util.parseJson
-import org.koitharu.kotatsu.parsers.util.urlBuilder
-import org.koitharu.kotatsu.parsers.util.urlEncoded
-import java.util.EnumSet
-import java.util.Locale
-import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
-import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.core.PagedMangaParser
+import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.util.*
+import java.util.*
+
+
+// removed first server, it has always problems
+private const val TWO_IMG_SERVER = "2"
+private const val THREE_IMG_SERVER = "3"
+private const val FOUR_IMG_SERVER = "4"
@MangaSourceParser("NHENTAI", "NHentai.net", type = ContentType.HENTAI)
internal class NHentaiParser(context: MangaLoaderContext) :
- GalleryAdultsParser(context, MangaParserSource.NHENTAI, "nhentai.net", 25) {
+ PagedMangaParser(context, MangaParserSource.NHENTAI, 25),
+ MangaParserAuthProvider {
+
+ override val configKeyDomain = ConfigKey.Domain("nhentai.net")
- private val apiSuffix = "api/v2"
+ private var imageServer: String = ""
+ private var thumbServer: String = ""
- // Required overrides for base class
- override val selectGallery = ""
- override val selectGalleryLink = ""
- override val selectGalleryTitle = ""
- override val selectGalleryImg = ""
- override val idImg = "none"
+ private var displayFullTitle: Boolean = true
override val availableSortOrders: Set = EnumSet.of(
- SortOrder.UPDATED,
+ SortOrder.UPDATED, // latest (default)
+ SortOrder.POPULARITY, // popular (all time)
+ SortOrder.POPULARITY_TODAY,
+ SortOrder.POPULARITY_WEEK,
+ SortOrder.POPULARITY_MONTH,
)
- private fun JSONObject.extractTags(): Set {
- return optJSONArray("tags")
- ?.mapJSON { tag ->
+ override val filterCapabilities = MangaListFilterCapabilities(
+ isSearchSupported = true,
+ isSearchWithFiltersSupported = true,
+ )
- if (tag.optString("type") != "tag") {
- return@mapJSON null
- }
+ private val preferredServerKey = ConfigKey.PreferredImageServer(
+ presetValues = mapOf(
+ TWO_IMG_SERVER to "First server",
+ THREE_IMG_SERVER to "Second server",
+ FOUR_IMG_SERVER to "Third server",
+ ),
+ defaultValue = TWO_IMG_SERVER,
+ )
- MangaTag(
- title = tag.optString("name")
- .replace("-", " ")
- .replaceFirstChar { c -> c.uppercase() },
- key = tag.optString("name"),
- source = source
- )
+ override fun onCreateConfig(keys: MutableCollection>) {
+ super.onCreateConfig(keys)
+ keys.add(userAgentKey)
+ keys.add(preferredServerKey)
+ }
+
+ private val selectedServer: String
+ get() = config[preferredServerKey].toString()
+
+ companion object {
+ val LANG_MAP = mapOf(
+ "en" to "english",
+ "ja" to "japanese",
+ "zh" to "chinese",
+ )
+ }
+
+ private val nhTagMap: Map by lazy {
+ val raw = """
+2937=big breasts
+35762=sole female
+35763=sole male
+8010=group
+14283=anal
+19440=lolicon
+24201=stockings
+10314=schoolgirl uniform
+13720=nakadashi
+29859=blowjob
+8378=glasses
+20905=full color
+32341=shotacon
+27553=rape
+15658=bondage
+23895=yaoi
+27473=mosaic censorship
+13989=ahegao
+22942=incest
+21712=males only
+1207=milf
+19018=dark skin
+22945=double penetration
+25614=paizuri
+20035=x-ray
+779=futanari
+23237=tankoubon
+21572=multi-work series
+20525=defloration
+14971=sex toys
+8653=netorare
+3735=swimsuit
+19954=yuri
+15348=ffm threesome
+8368=full censorship
+15408=femdom
+29224=impregnation
+29013=dilf
+85295=twintails
+31044=collar
+85288=ponytail
+24380=pantyhose
+9260=cheating
+28031=sister
+16828=hairy
+31880=bbm
+30555=big penis
+15782=crossdressing
+31775=tentacles
+27384=mind break
+19175=bikini
+8739=story arc
+30473=muscle
+24102=lactation
+7752=schoolboy uniform
+20617=mind control
+9083=big ass
+29023=tomgirl
+81774=kemonomimi
+1590=sweating
+9162=masturbation
+7256=mmf threesome
+28550=teacher
+190=maid
+8693=uncensored
+19899=exhibitionism
+6343=pregnant
+8050=females only
+6817=unusual pupils
+25871=lingerie
+10988=anthology
+20282=footjob
+15853=mother
+15785=harem
+14072=huge breasts
+30035=gender bender
+1643=kissing
+130025=anal intercourse
+1033=handjob
+12824=condom
+31386=catgirl
+10476=urination
+3666=garter belt
+26130=fingering
+81707=beauty mark
+22079=drugs
+105833=gloves
+4435=gag
+25601=small breasts
+5820=piercing
+12695=prostitution
+16228=demon girl
+7155=cunnilingus
+22950=tanlines
+832=elf
+31012=blindfold
+17773=kimono
+2820=scat
+29182=blackmail
+23132=bunny girl
+32484=stomach deformation
+2515=virginity
+27063=filming
+7142=bbw
+21989=inflation
+88846=horns
+104227=tail
+26953=bukkake
+28800=bloomers
+25050=gyaru
+24676=rimjob
+23632=big areolae
+16533=sleeping
+73750=bald
+18567=monster
+35972=sole dickgirl
+18328=thigh high boots
+5810=strap-on
+29565=school swimsuit
+32996=deepthroat
+370=business suit
+7550=monster girl
+1067=inseki
+50585=webtoon
+12523=bestiality
+27697=leotard
+30645=dick growth
+29631=inverted nipples
+29366=tomboy
+24412=bodysuit
+15492=scanmark
+9406=enema
+35970=dickgirl on dickgirl
+29399=daughter
+18613=military
+11941=replaced
+6525=nurse
+9661=cervix penetration
+33129=slave
+4573=corruption
+5529=urethra insertion
+10542=snuff
+683=squirting
+51399=crotch tattoo
+122908=very long hair
+7838=magical girl
+24726=apron
+23183=breast expansion
+20074=latex
+28426=hairy armpits
+27217=guro
+31285=fox girl
+106119=no penetration
+24764=drunk
+9990=prostate massage
+35968=dickgirl on male
+2956=old man
+32752=shibari
+6900=miko
+2153=wings
+706=birth
+10794=breast feeding
+14069=ryona
+25822=smell
+5357=humiliation
+5962=spanking
+2531=transformation
+21538=bike shorts
+31101=incomplete
+32745=chikan
+16236=shemale
+36957=bisexual
+26952=tall girl
+25663=oppai loli
+7995=big nipples
+32602=fisting
+106733=hair buns
+1088=bdsm
+21283=masked face
+15225=blowjob face
+2633=leg lock
+27378=artbook
+35971=male on dickgirl
+27112=tiara
+107705=facial hair
+24933=eyepatch
+4549=torture
+30206=tribadism
+1037=oni
+89056=hidden sex
+13136=facesitting
+3391=nun
+25766=gokkun
+5200=pegging
+17531=cosplaying
+28521=voyeurism
+19479=nipple fuck
+17349=tracksuit
+22221=blood
+50505=oyakodon
+50486=tail plug
+560=twins
+23965=chloroform
+15425=vore
+25457=possession
+129668=eye-covering bang
+24984=orgasm denial
+144644=extraneous ads
+28589=hotpants
+17752=foot licking
+32282=piss drinking
+19390=cousin
+32589=feminization
+11376=body modification
+20362=gyaru-oh
+28778=large insertions
+27720=smegma
+10811=double vaginal
+3614=triple penetration
+3455=chastity belt
+2452=scar
+31319=yandere
+7354=amputee
+28335=giantess
+26848=waitress
+28349=cbt
+24967=sumata
+104893=vtuber
+8516=emotionless sex
+26380=demon
+17591=robot
+17801=solo action
+13640=frottage
+25996=gaping
+23035=aunt
+23967=huge penis
+31846=body writing
+25744=cheerleader
+24708=cowgirl
+25085=swinging
+18322=brother
+101724=leash
+10354=milking
+97795=pixie cut
+11089=body swap
+32224=eggs
+10606=pasties
+3947=onahole
+14573=tall man
+10604=dog
+14362=low lolicon
+15242=lab coat
+4935=farting
+13468=shimapan
+5620=double anal
+14138=freckles
+50390=josou seme
+15119=dog girl
+93324=fishnets
+22025=prolapse
+15471=asphyxiation
+21774=human pet
+31337=kunoichi
+15712=eyemask
+30126=big clit
+92409=thick eyebrows
+109360=cumflation
+7208=catboy
+31687=randoseru
+24529=bride
+19561=big balls
+24450=chinese dress
+121738=focus anal
+22967=diaper
+29347=miniguy
+29001=parasite
+25296=armpit licking
+6220=orc
+7546=witch
+30895=sunglasses
+7372=corset
+28119=nose hook
+8429=machine
+7684=armpit sex
+14516=wolf girl
+15045=niece
+13882=tutor
+8391=public use
+30811=christmas
+104245=small penis
+266=sundress
+17501=phimosis
+17800=tickling
+25794=widow
+7288=vomit
+1215=unusual teeth
+72471=dickgirls only
+107503=soushuuhen
+138044=exposed clothing
+1352=slime
+31986=age regression
+23917=long tongue
+24115=angel
+114993=shimaidon
+13722=moral degeneration
+26898=age progression
+27120=selfcest
+7577=vampire
+17676=ghost
+88103=clothed female nude male
+13515=coach
+141098=nipple stimulation
+9116=unbirth
+5936=time stop
+18420=all the way through
+72139=clothed paizuri
+27530=ball sucking
+16518=coprophagia
+28869=stuck in wall
+2527=bandages
+24621=insect
+11399=metal armor
+106006=large tattoo
+3843=fundoshi
+20120=multiple paizuri
+8400=goblin
+129321=mesuiki
+124610=mouth mask
+10693=dougi
+31371=mecha girl
+21450=minigirl
+10685=double blowjob
+118056=petplay
+20789=policewoman
+3031=underwater
+31173=first person perspective
+78262=shaved head
+19064=pubic stubble
+14280=bunny boy
+25949=gothic lolita
+23463=wrestling
+16947=horse
+11247=skinsuit
+11073=living clothes
+30786=watermarked
+23073=assjob
+52826=dark sclera
+107478=drill hair
+23225=non-h
+109930=domination loss
+20170=poor grammar
+138200=gender change
+16759=artistcg
+80978=nudity only
+15749=oil
+30176=petrification
+25848=human cattle
+559=ttf threesome
+14010=snake girl
+11276=multiple penises
+90671=original
+18024=touhou project
+1841=kantai collection
+35605=fate grand order
+20925=the idolmaster
+972=granblue fantasy
+78245=azur lane
+17137=neon genesis evangelion
+3185=love live
+391=girls und panzer
+11219=pokemon
+15021=sailor moon
+4505=mahou shoujo lyrical nanoha
+128408=blue archive
+10222=fate stay night
+27431=to love-ru
+13159=naruto
+123503=genshin impact
+3984=sword art online
+3603=street fighter
+22174=one piece
+16285=puella magi madoka magica
+91195=princess connect
+12232=my hero academia
+3163=king of fighters
+26172=k-on
+7259=touken ranbu
+19080=code geass
+37544=love live sunshine
+17077=cardcaptor sakura
+27547=the melancholy of haruhi suzumiya
+13508=final fantasy vii
+10954=shingeki no kyojin
+25430=vocaloid
+32687=free
+4577=toheart2
+22146=dead or alive
+20025=gochuumon wa usagi desu ka
+8485=dragon ball z
+5037=bleach
+3218=bakemonogatari
+12624=ore no imouto ga konna ni kawaii wake ga nai
+37109=kono subarashii sekai ni syukufuku o
+4369=monster hunter
+127065=hololive
+74788=girls frontline
+24886=fate kaleid liner prisma illya
+6999=toaru kagaku no railgun
+22032=boku wa tomodachi ga sukunai
+18350=ragnarok online
+21674=dragon quest iii
+14345=ojamajo doremi
+7832=darkstalkers
+24135=ah my goddess
+32394=samurai spirits
+1283=queens blade
+16639=haikyuu
+13924=yu-gi-oh
+79467=kimetsu no yaiba
+18238=danganronpa
+26336=yu-gi-oh zexal
+16984=persona 4
+18569=kuroko no basuke
+1910=smile precure
+30587=sakura taisen
+16166=mahou sensei negima
+12285=ranma 12
+8470=infinite stratos
+32363=toaru majutsu no index
+22708=saki
+8708=to heart
+108082=arknights
+16707=detective conan
+22210=guilty gear
+947=gundam seed destiny
+22677=tenchi muyo
+23429=pretty cure
+18512=strike witches
+31027=lucky star
+7408=league of legends
+394=love hina
+23201=kanon
+27704=amagami
+127052=nijisanji
+70802=kemono friends
+52098=persona 5
+22215=super robot wars
+27567=hayate no gotoku
+35251=osomatsu-san
+7633=pripara
+34823=ensemble stars
+37914=re zero kara hajimeru isekai seikatsu
+74918=bang dream
+15041=martian successor nadesico
+24783=dragon ball
+120519=love live nijigasaki high school idol club
+2803=love plus
+5085=senki zesshou symphogear
+28474=zero no tsukaima
+15197=gundam build fighters
+15427=dragon quest iv
+1163=rozen maiden
+23859=yu-gi-oh arc-v
+75023=dragon quest xi
+2112=dungeon ni deai o motomeru no wa machigatteiru darou ka
+36418=voiceroid
+28281=mitsudomoe
+11624=the legend of zelda
+14694=fullmetal alchemist
+16847=dragon quest v
+2497=urusei yatsura
+5671=tengen toppa gurren lagann
+22754=amagi brilliant park
+20606=tsukihime
+5165=gundam build fighters try
+4114=macross frontier
+20763=inazuma eleven
+14550=sister princess
+19083=jojos bizarre adventure
+21052=fate hollow ataraxia
+29922=teitoku
+51810=gudao
+16643=producer
+13848=reimu hakurei
+25125=asuka langley soryu
+17279=sakuya izayoi
+10496=patchouli knowledge
+37739=shielder
+3206=shinji ikari
+38068=gran
+4675=sanae kochiya
+21779=rei ayanami
+14040=fate testarossa
+3870=flandre scarlet
+23902=remilia scarlet
+21688=atago
+11373=marisa kirisame
+35128=kashima
+17154=sakura kinomoto
+31462=satori komeiji
+30080=kaga
+10802=alice margatroid
+17017=aya shameimaru
+17862=yukari yakumo
+5340=shimakaze
+18935=nanoha takamachi
+18896=shirou emiya
+16555=rin tosaka
+16130=rito yuuki
+15890=reisen udongein inaba
+7724=takao
+27060=jeanne darc
+78989=jeanne alter
+7718=naruto uzumaki
+5337=nami
+22975=chun-li
+17502=illyasviel von einzbern
+20111=tifa lockhart
+21131=youmu konpaku
+18026=kazuto kirigaya
+92923=shikikan
+29856=saber
+71442=minamoto no raikou
+1843=asuna yuuki
+51419=gudako
+7488=mai shiranui
+9835=koishi komeiji
+16916=kasumi
+30026=maki nishikino
+143975=sensei
+26906=izuku midoriya
+37275=scathach
+7696=momiji inubashiri
+38039=astolfo
+27794=mikoto misaka
+20062=hamakaze
+78285=artoria pendragon
+34860=katsuki bakugou
+3328=homura akemi
+37687=djeeta
+32200=suzuya
+21108=rin shibuya
+35964=nico yazawa
+27494=levi ackerman
+609=eren jaeger
+11920=sakura haruno
+20427=sailor mercury
+24714=chino kafuu
+31456=mikan yuuki
+866=koyomi araragi
+12149=kyousuke kousaka
+277=haruka nanase
+19926=haruna
+3763=haruhi suzumiya
+26427=mio akiyama
+25439=hinata hyuga
+17811=ran yakumo
+14857=kongou
+18548=kotori minami
+32364=rider
+15641=madoka kaname
+2613=hong meiling
+491=makoto tachibana
+20702=koakuma
+15315=tomoyo daidouji
+10730=shigure
+14265=touma kamijou
+80311=bb
+4203=mami tomoe
+37706=kazuma satou
+33070=umi sonoda
+27172=yuyuko saigyouji
+3353=yuuka kazami
+2078=nagato
+6311=arisu tachibana
+647=belldandy
+9274=maya
+24889=sena kashiwazaki
+15125=golden darkness
+6555=sailor jupiter
+25695=mika jougasaki
+50929=shuten douji
+33077=sailor mars
+8293=minami nitta
+7451=lelouch vi britannia
+389=rika jougasaki
+22469=prinz eugen
+16108=azusa nakano
+12812=tenryuu
+7311=ami mizuno
+6642=byakuren hijiri
+7097=suwako moriya
+19172=miki hoshii
+9657=ayane
+29433=c.c.
+25220=sakura matou
+14499=tsunade
+10665=tenshi hinanai
+16564=miku hatsune
+29190=kallen stadtfeld
+3312=kirino kousaka
+1234=yuki nagato
+26261=ranma saotome
+19002=rin kaenbyou
+12748=nico robin
+32765=rin matsuoka
+4241=fumika sagisawa
+1729=tamaki kousaka
+23997=ruri gokou
+29684=sailor venus
+19160=nitori kawashiro
+27302=uzuki shimamura
+23216=android 18
+8489=hibiki
+7333=suguha kirigaya
+1267=kodaka hasegawa
+2345=morrigan aensland
+9371=yamato
+26087=inazuma
+27532=archer
+26587=miho nishizumi
+12346=utsuho reiuji
+37108=megumin
+22407=takane shijou
+15914=sasuke uchiha
+2774=kyouko sakura
+80930=abigail williams
+81288=gudao | ritsuka fujimaru
+73756=nightingale
+6109=eri ayase
+27492=akagi
+17899=sakura kasugano
+32137=cirno
+11760=yui kotegawa
+75029=eli ayase
+11740=sailor moon
+49158=narmaya
+29693=ikazuchi
+126586=aether
+20918=iori minase
+24832=misato katsuragi
+2883=kasen ibara
+6932=souji okita
+28555=tamamo-no-mae
+14428=kokoa hoto
+26783=taihou
+12763=rumia
+401=nakoruru
+72475=musashi miyamoto
+23122=maho nishizumi
+29188=eirin yagokoro
+466=usagi tsukino
+29638=kyon
+15995=makoto kino
+11744=amatsukaze
+6175=cammy white
+30331=ichika orimura
+23473=mikuru asahina
+28807=ruri hoshino
+2572=hatate himekaidou
+15291=chen
+23386=fujiwara no mokou
+9237=shoukaku
+28763=tewi inaba
+23851=gilgamesh
+10672=aqua
+9702=ro-500
+31074=keine kamishirasawa
+32443=charlotte dunois
+2196=sayaka miki
+1645=zuikaku
+5925=akatsuki
+4196=hestia
+33171=shiho nishizumi
+19534=hayate yagami
+79507=belfast
+12433=kaede takagaki
+12872=warrior
+8170=len kagamine
+50415=rem
+14409=momoka sakurai
+2211=mari illustrious makinami
+99075=kokkoro
+1907=rei hino
+15651=miyu edelfelt
+26169=musashi
+8053=lum
+50596=you watanabe
+9883=kagami hiiragi
+24509=darjeeling
+11992=lala satalin deviluke
+32683=hachiman hikigaya
+31076=kuroko shirai
+20836=red saber
+12902=isuzu sento
+10379=bianca whitaker
+16181=nozomi toujou
+27774=bismarck
+28219=yui hirasawa
+1271=momo velia deviluke
+49852=subaru natsuki
+5918=shinobu oshino
+28056=link
+25605=rangiku matsumoto
+35313=cagliostro
+18453=hero
+75102=nozomi tojo
+20722=mutsu
+29170=yuma tsukumo
+9486=nue houjuu
+33049=ritsuko akizuki
+23626=murakumo
+20323=tsumugi kotobuki
+16566=ritsu tainaka
+14016=yuu narukami
+11609=yoko ritona
+107011=chloe von einzbern
+52132=riko sakurauchi
+32114=onpu segawa
+11924=kagerou imaizumi
+ """.trimIndent()
+ raw.lineSequence()
+ .mapNotNull { line ->
+ val parts = line.split('=')
+ if (parts.size == 2 && parts[0].isNotBlank() && parts[1].isNotBlank()) {
+ parts[0] to parts[1]
+ } else null
+ }.toMap()
+ }
+
+ override suspend fun getFilterOptions() = MangaListFilterOptions(
+ availableTags = nhTagMap.map { (id, name) ->
+ MangaTag(
+ title = name.replace("-", " ").replaceFirstChar { it.uppercase() },
+ key = id, // numeric tag ID
+ source = source,
+ )
+ }.toSet(),
+ availableLocales = setOf(
+ Locale.ENGLISH,
+ Locale.JAPANESE,
+ Locale.CHINESE,
+ ),
+ )
+
+ private suspend fun ensureNhConfig() {
+ if (imageServer.isBlank() || thumbServer.isBlank()) {
+ try {
+ val json = webClient.httpGet("https://$domain/api/v2/config").parseJson()
+ val imgServers = json.getJSONArray("image_servers")
+ .let { (0 until it.length()).map { i -> it.getString(i) } }
+ val thbServers = json.getJSONArray("thumb_servers")
+ .let { (0 until it.length()).map { i -> it.getString(i) } }
+ imageServer = imgServers[selectedServer.toInt() - 1]
+ thumbServer = thbServers[selectedServer.toInt() - 1]
+ } catch (_: Exception) {
+ imageServer = "https://i$selectedServer.nhentai.net"
+ thumbServer = "https://t$selectedServer.nhentai.net"
}
- ?.filterNotNull()
- ?.toSet()
- ?: emptySet()
+ }
}
- override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
-
- filter.query?.trim()?.let { query ->
- if (query.all(Char::isDigit)) {
- return try {
- val obj = webClient
- .httpGet("https://$domain/$apiSuffix/galleries/$query")
- .parseJson()
-
- val title = obj.extractTitle()
-
- listOf(
- Manga(
- id = generateUid("/g/$query/"),
- title = title.cleanupTitle().ifEmpty { title },
- altTitles = emptySet(),
- url = "/g/$query/",
- publicUrl = "https://$domain/g/$query/",
- rating = RATING_UNKNOWN,
- contentRating = ContentRating.ADULT,
- coverUrl = "https://t.$domain/${obj.getThumbnailPath()}",
- tags = obj.extractTags(),
- state = null,
- authors = emptySet(),
- source = source
- )
- )
- } catch (_: Exception) {
- emptyList()
- }
+
+ override suspend fun getListPage(
+ page: Int,
+ order: SortOrder,
+ filter: MangaListFilter,
+ ): List {
+ ensureNhConfig()
+
+ // Direct ID search
+ val directId = extractGalleryId(filter.query)
+ if (directId != null) {
+ return try {
+ listOf(fetchMangaById(directId))
+ } catch (_: Exception) {
+ emptyList()
}
}
- val url = urlBuilder().addPathSegments(apiSuffix)
- val isDefaultHome = order == SortOrder.UPDATED
- && filter.query.isNullOrEmpty()
-
- if (isDefaultHome) {
- url.addPathSegment("galleries")
- url.addQueryParameter("page", page.toString())
- } else {
- val query = buildString {
- filter.query?.trim()?.takeIf { it.isNotEmpty() }?.let {
- append(it)
- }
+ val selectedTag = filter.tags.firstOrNull()
+ if (selectedTag != null) {
+ val url = "https://$domain/api/v2/galleries/tagged?tag_id=${selectedTag.key}&page=$page"
+ val json = webClient.httpGet(url).parseJson()
+ val items = json.getJSONArray("result")
+ return (0 until items.length()).map { i -> items.getJSONObject(i).toManga() }
+ }
+
+ val lang = filter.locale?.let { loc ->
+ LANG_MAP[loc.language]
+ }
- if (isEmpty()) {
- append("pages:>0")
+ val url = when {
+ // Search with language
+ lang != null -> {
+ val sort = when (order) {
+ SortOrder.POPULARITY -> "popular"
+ else -> "date"
}
+ "https://$domain/api/v2/search?query=language:$lang&sort=$sort&page=$page"
}
-
- val sort = when (order) {
- else -> "date"
+ // Latest
+ order == SortOrder.UPDATED -> "https://$domain/api/v2/galleries?page=$page"
+ // Popular (all / today / week / month)
+ else -> {
+ val sort = when (order) {
+ SortOrder.POPULARITY_TODAY -> "popular-today"
+ SortOrder.POPULARITY_WEEK -> "popular-week"
+ SortOrder.POPULARITY_MONTH -> "popular-month"
+ SortOrder.POPULARITY -> "popular"
+ else -> "popular"
+ }
+ "https://$domain/api/v2/search?query=%22%22&sort=$sort&page=$page"
}
-
- url.addPathSegment("search")
- url.addQueryParameter("query", query.urlEncoded())
- url.addQueryParameter("sort", sort)
- url.addQueryParameter("page", page.toString())
}
- val json = webClient.httpGet(url.build()).parseJson()
- return json.optJSONArray("result").mapJSON {
- val id = it.getInt("id")
- val title = it.extractTitle()
- Manga(
- id = generateUid("/g/$id/"),
- title = title.cleanupTitle().ifEmpty { title },
- altTitles = emptySet(),
- url = "/g/$id/",
- publicUrl = "https://$domain/g/$id/",
- rating = RATING_UNKNOWN,
- contentRating = ContentRating.ADULT,
- coverUrl = "https://t.$domain/${it.getThumbnailPath()}",
- tags = it.extractTags(),
- state = null,
- authors = emptySet(),
- source = source
- )
+ val json = webClient.httpGet(url).parseJson()
+ val items = json.getJSONArray("result")
+ return (0 until items.length()).map { i -> items.getJSONObject(i).toManga() }
+ }
+
+ private fun extractGalleryId(query: String?): Int? {
+ if (query.isNullOrBlank()) return null
+ val trimmed = query.trim()
+ return when {
+ trimmed.startsWith("id:") -> trimmed.removePrefix("id:").toIntOrNull()
+ trimmed.all(Char::isDigit) -> trimmed.toIntOrNull()
+ else -> null
}
}
- override suspend fun getFilterOptions(): MangaListFilterOptions {
- return MangaListFilterOptions(
- availableTags = emptySet(),
- )
+ private suspend fun fetchMangaById(id: Int): Manga {
+ ensureNhConfig()
+ val json = webClient.httpGet("https://$domain/api/v2/galleries/$id").parseJson()
+ return json.toMangaDetails()
}
override suspend fun getDetails(manga: Manga): Manga {
- val id = manga.url.removeSurrounding("/g/", "/")
-
- val obj = webClient
- .httpGet("https://$domain/$apiSuffix/galleries/$id")
- .parseJson()
-
- return manga.copy(
- tags = obj.extractTags(),
- authors = emptySet(),
- description = "Pages: ${obj.optInt("num_pages")}",
- coverUrl = "https://t.$domain/${obj.getCoverPath()}",
- chapters = listOf(
- MangaChapter(
- id = manga.id,
- title = manga.title,
- number = 1f,
- volume = 0,
- url = manga.url,
- scanlator = null,
- uploadDate = obj.optLong("upload_date") * 1000,
- branch = null,
- source = source
- )
- )
+ ensureNhConfig()
+ val id = manga.url.removeSurrounding("/g/", "/").toInt()
+ val json = webClient.httpGet("https://$domain/api/v2/galleries/$id").parseJson()
+ return json.toMangaDetails().copy(
+ coverUrl = manga.coverUrl?.ifBlank {
+ "${thumbServer}/${json.getJSONObject("cover").optString("path")}"
+ },
)
}
+
override suspend fun getPages(chapter: MangaChapter): List {
- val id = chapter.url.removeSurrounding("/g/", "/")
- val response = webClient.httpGet("https://$domain/$apiSuffix/galleries/$id").parseJson()
- return response.getJSONArray("pages").mapJSON { page ->
- val path = page.getString("path")
+ ensureNhConfig()
+ val id = chapter.url.removeSurrounding("/g/", "/").toInt()
+ val json = webClient.httpGet("https://$domain/api/v2/galleries/$id").parseJson()
+ val pagesArray = json.getJSONArray("pages")
+ return (0 until pagesArray.length()).map { i ->
+ val pageObj = pagesArray.getJSONObject(i)
+ val path = pageObj.getString("path")
MangaPage(
id = generateUid(path),
- url = "https://i.$domain/$path",
- preview = page.optString("thumbnail").takeIf { it.isNotBlank() }
- ?.let { "https://t.$domain/$it" },
+ url = "$imageServer/$path",
+ preview = null,
source = source,
)
}
}
override suspend fun getPageUrl(page: MangaPage): String = page.url
+ override suspend fun getRelatedManga(seed: Manga): List = emptyList()
- private fun JSONObject.extractTitle(): String {
+ private fun JSONObject.toManga(): Manga {
+ val id = getInt("id")
val titleObj = optJSONObject("title")
- return listOfNotNull(
- titleObj?.optString("english"),
- titleObj?.optString("pretty"),
- optString("english_title"),
- optString("japanese_title")
- ).firstOrNull { it.isNotBlank() } ?: "Gallery ${optInt("id")}"
+ val rawTitle = titleObj?.optString("english")
+ ?: titleObj?.optString("pretty")
+ ?: optString("english_title")
+ ?: optString("japanese_title")
+ ?: "Gallery $id"
+
+ val title = if (displayFullTitle) rawTitle else rawTitle.shortenTitle()
+ val thumbPath = optJSONObject("thumbnail")?.optString("path")
+ ?: optString("thumbnail").takeIf { !it.isNullOrBlank() }
+ ?: "galleries/$id/thumb.webp"
+
+ return Manga(
+ id = generateUid("/g/$id/"),
+ title = title,
+ altTitles = emptySet(),
+ url = "/g/$id/",
+ publicUrl = "https://$domain/g/$id/",
+ rating = RATING_UNKNOWN,
+ contentRating = ContentRating.ADULT,
+ coverUrl = "$thumbServer/$thumbPath",
+ tags = extractTags(),
+ state = null,
+ authors = emptySet(),
+ source = source,
+ )
}
- private fun JSONObject.getThumbnailPath(): String = optJSONObject("thumbnail")?.optString("path")
- ?: optString("thumbnail").takeIf { it.isNotBlank() }
- ?: "galleries/${optString("media_id")}/thumb.webp"
+ private fun JSONObject.toMangaDetails(): Manga {
+ val id = getInt("id")
+ val titleObj = optJSONObject("title")
+ val rawTitle = titleObj?.optString("english")
+ ?: titleObj?.optString("japanese")
+ ?: titleObj?.optString("pretty")
+ ?: "Gallery $id"
+ val title = if (displayFullTitle) rawTitle else rawTitle.shortenTitle()
+
+ val thumbPath = getJSONObject("thumbnail").getString("path")
+ val coverPath = getJSONObject("cover").optString("path", thumbPath)
+ val numPages = optInt("num_pages")
+ val uploadDate = optLong("upload_date") * 1000
+
+ val tags = extractTags()
+ val artists = getTagsOfType("artist")
+ val groups = getTagsOfType("group")
+ val author = artists.joinToString(", ").ifBlank { groups.joinToString(", ") }
+
+ var langName: String?
+ var translated = false
+ val languageCandidates = mutableListOf()
+
+ for (tag in tags) {
+ val parts = tag.key.split(":", limit = 2)
+ if (parts.size == 2 && parts[0] == "language") {
+ val name = parts[1]
+ when {
+ name.equals("translated", ignoreCase = true) -> translated = true
+ else -> languageCandidates.add(name)
+ }
+ }
+ }
+
+ langName = languageCandidates.firstOrNull { it.equals("chinese", ignoreCase = true) }
+ ?: languageCandidates.firstOrNull { it.equals("japanese", ignoreCase = true) }
+ ?: languageCandidates.firstOrNull { it.equals("english", ignoreCase = true) }
+ ?: languageCandidates.firstOrNull()
- private fun JSONObject.getCoverPath(): String = optJSONObject("cover")?.optString("path") ?: getThumbnailPath()
+ val description = buildString {
+ langName?.let {
+ val displayLang = it.replaceFirstChar { c -> c.uppercase() }
+ append(if (translated) "$displayLang (translated)" else displayLang)
+ append("\n")
+ }
+ append("Pages: $numPages")
+ }
+ return Manga(
+ id = generateUid("/g/$id/"),
+ title = title,
+ altTitles = listOfNotNull(titleObj?.optString("japanese")).toSet(),
+ url = "/g/$id/",
+ publicUrl = "https://$domain/g/$id/",
+ rating = RATING_UNKNOWN,
+ contentRating = ContentRating.ADULT,
+ coverUrl = "$thumbServer/$thumbPath",
+ largeCoverUrl = "$imageServer/$coverPath",
+ tags = tags,
+ state = null,
+ authors = setOfNotNull(author.ifBlank { null }),
+ description = description,
+ chapters = listOf(
+ MangaChapter(
+ id = generateUid("/g/$id/"),
+ title = title,
+ number = 1f,
+ volume = 0,
+ url = "/g/$id/",
+ scanlator = groups.joinToString(", ").ifBlank { null },
+ uploadDate = uploadDate,
+ branch = null,
+ source = source,
+ )
+ ),
+ source = source,
+ )
+ }
- override fun Locale.toLanguagePath(): String = when (this) {
- Locale.ENGLISH -> "english"
- Locale.JAPANESE -> "japanese"
- Locale.CHINESE -> "chinese"
- else -> language
+ private fun JSONObject.extractTags(): Set {
+ val tagsArray = optJSONArray("tags") ?: return emptySet()
+ return (0 until tagsArray.length()).map { i ->
+ val tag = tagsArray.getJSONObject(i)
+ val name = tag.getString("name")
+ MangaTag(
+ title = name.replace("-", " ").replaceFirstChar { it.uppercase() },
+ key = "${tag.getString("type")}:$name",
+ source = source,
+ )
+ }.toSet()
}
+
+ private fun JSONObject.getTagsOfType(type: String): List {
+ val tagsArray = optJSONArray("tags") ?: return emptyList()
+ return (0 until tagsArray.length()).mapNotNull { i ->
+ val tag = tagsArray.getJSONObject(i)
+ if (tag.getString("type") == type) tag.getString("name") else null
+ }
+ }
+
+ private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""")
+ private fun String.shortenTitle() = this.replace(shortenTitleRegex, "").trim()
+
+ override val authUrl: String get() = "https://$domain"
+ override suspend fun isAuthorized(): Boolean = true
+ override suspend fun getUsername(): String = ""
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt
index 852e0959..079c25df 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt
@@ -1,12 +1,13 @@
package org.koitharu.kotatsu.parsers.site.kotatsu.en
-import okhttp3.Headers
+import org.jsoup.nodes.Document
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
-import org.koitharu.kotatsu.parsers.model.MangaParserSource
+import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.parsers.MadaraParser
-import java.util.Base64
-import kotlin.random.Random
+import org.koitharu.kotatsu.parsers.util.*
+import java.text.SimpleDateFormat
+import java.util.*
@MangaSourceParser("AQUAMANGA", "AquaManga", "en")
internal class AquaManga(context: MangaLoaderContext) :
@@ -15,38 +16,180 @@ internal class AquaManga(context: MangaLoaderContext) :
override val withoutAjax = true
override val stylePage = ""
- override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder()
- .set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
- .set("Accept-Language", "en-US,en;q=0.5")
- .set("Referer", "https://$domain/")
- .set("Sec-Fetch-Dest", "document")
- .set("Sec-Fetch-Mode", "navigate")
- .set("Sec-Fetch-Site", "same-origin")
- .set("Upgrade-Insecure-Requests", "1")
- .set("X-Requested-With", randomValue)
- .build()
-
- private fun getRandomSubstring(input: String, length: Int): String {
- val startIndex = Random.nextInt(0, input.length - length + 1)
- return input.substring(startIndex, startIndex + length)
- }
+ override val selectChapter = ".aqua-ch-item"
+
+ override val availableSortOrders: EnumSet = EnumSet.of(
+ SortOrder.UPDATED,
+ SortOrder.POPULARITY,
+ SortOrder.NEWEST,
+ SortOrder.ALPHABETICAL,
+ )
+
+ override suspend fun getFilterOptions() = MangaListFilterOptions(
+ availableTags = setOf(
+ MangaTag("Academy", "academy", source),
+ MangaTag("Action", "action", source),
+ MangaTag("Adaptation", "adaptation", source),
+ MangaTag("Adventure", "adventure", source),
+ MangaTag("Comedy", "comedy", source),
+ MangaTag("Cooking", "cooking", source),
+ MangaTag("Crime", "crime", source),
+ MangaTag("Cultivation", "cultivation", source),
+ MangaTag("Delinquents", "delinquents", source),
+ MangaTag("Demons", "demons", source),
+ MangaTag("Drama", "drama", source),
+ MangaTag("Dungeons", "dungeons", source),
+ MangaTag("Ecchi", "ecchi", source),
+ MangaTag("Fantasy", "fantasy", source),
+ MangaTag("Game", "game", source),
+ MangaTag("Gore", "gore", source),
+ MangaTag("Harem", "harem", source),
+ MangaTag("Historical", "historical", source),
+ MangaTag("Horror", "horror", source),
+ MangaTag("Isekai", "isekai", source),
+ MangaTag("Josei", "josei", source),
+ MangaTag("Magic", "magic", source),
+ MangaTag("Manga", "manga", source),
+ MangaTag("Manhua", "manhua", source),
+ MangaTag("Manhwa", "manhwa", source),
+ MangaTag("Martial Arts", "martial-arts", source),
+ MangaTag("Mecha", "mecha", source),
+ MangaTag("Medical", "medical", source),
+ MangaTag("Military", "military", source),
+ MangaTag("Monsters", "monsters", source),
+ MangaTag("Murim", "murim", source),
+ MangaTag("Music", "music", source),
+ MangaTag("Mystery", "mystery", source),
+ MangaTag("Necromancer", "necromancer", source),
+ MangaTag("Ninja", "ninja", source),
+ MangaTag("Office Workers", "office-workers", source),
+ MangaTag("OP-MC", "op-mc", source),
+ MangaTag("Overpowered", "overpowered", source),
+ MangaTag("Philosophical", "philosophical", source),
+ MangaTag("Post-Apocalyptic", "post-apocalyptic", source),
+ MangaTag("Psychological", "psychological", source),
+ MangaTag("Rebirth", "rebirth", source),
+ MangaTag("Regression", "regression", source),
+ MangaTag("Reincarnation", "reincarnation", source),
+ MangaTag("Returner", "returner", source),
+ MangaTag("Revenge", "revenge", source),
+ MangaTag("Romance", "romance", source),
+ MangaTag("School Life", "school-life", source),
+ MangaTag("Sci-fi", "sci-fi", source),
+ MangaTag("Seinen", "seinen", source),
+ MangaTag("Shounen", "shounen", source),
+ MangaTag("Slice-of-Life", "slice-of-life", source),
+ MangaTag("Sports", "sports", source),
+ MangaTag("Super Power", "super-power", source),
+ MangaTag("Superhero", "superhero", source),
+ MangaTag("Supernatural", "supernatural", source),
+ MangaTag("Survival", "survival", source),
+ MangaTag("System", "system", source),
+ MangaTag("Thriller", "thriller", source),
+ MangaTag("Time Travel", "time-travel", source),
+ MangaTag("Tower", "tower", source),
+ MangaTag("Tragedy", "tragedy", source),
+ MangaTag("Vampire", "vampire", source),
+ MangaTag("Video Games", "video-games", source),
+ MangaTag("Villainess", "villainess", source),
+ MangaTag("Virtual Reality", "virtual-reality", source),
+ MangaTag("Voilence", "voilence", source),
+ MangaTag("Webcomic", "webcomic", source),
+ MangaTag("Wuxia", "wuxia", source),
+ MangaTag("Zombies", "zombies", source),
+ ),
+ availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED),
+ )
+
+ override suspend fun getDetails(manga: Manga): Manga {
+ val fullUrl = manga.url.toAbsoluteUrl(domain)
+ val doc = webClient.httpGet(fullUrl).parseHtml()
- private val randomLength = Random.Default.nextInt(13, 21)
+ val title = doc.selectFirstOrThrow(".aqua-series-info__title").text()
+ val thumbnail = doc.selectFirstOrThrow(".aqua-series-cover__img").requireSrc()
+ val description = doc.selectFirst(".aqua-series-synopsis")?.html().orEmpty()
+ val status = doc.selectFirst(".aqua-series-meta__status")?.text()
+ val genres = doc.select(".aqua-series-genre-pill").map { it.text() }.toSet()
+ val author = doc.selectFirst(".aqua-series-info__creator-value a")?.ownText()
+ val artist = doc.selectFirst(".aqua-series-info__creator-value a")?.ownText()
- private val decodedString = Base64.getDecoder()
- .decode(LITTLE_BIT_CURSED_VALUE)
- .toString(Charsets.UTF_8)
- .trim()
+ val tags = genres.mapTo(mutableSetOf()) { MangaTag(title = it, key = it, source = source) }
+ val statusText = status?.lowercase().orEmpty()
+ val state = when (statusText) {
+ in ongoing -> MangaState.ONGOING
+ in finished -> MangaState.FINISHED
+ in abandoned -> MangaState.ABANDONED
+ in paused -> MangaState.PAUSED
+ else -> null
+ }
- private val randomStringValue = getRandomSubstring(decodedString, randomLength)
+ val chapters = getChapters(manga, doc)
+ return manga.copy(
+ title = title,
+ coverUrl = thumbnail,
+ description = description,
+ tags = tags,
+ state = state,
+ authors = setOfNotNull(author, artist).filter { it.isNotBlank() }.toSet(),
+ chapters = chapters,
+ )
+ }
- private val randomValue = when {
- Random.nextInt(1, 11) == 1 -> "org.chromium.chrome"
- else -> randomStringValue
+ override suspend fun getChapters(manga: Manga, doc: Document): List {
+ return doc.select(selectChapter).mapChapters(reversed = true) { i, el ->
+ val a = el.selectFirstOrThrow("a")
+ val href = a.attrAsRelativeUrl("href")
+ val name = el.selectFirstOrThrow(".aqua-ch-item__name").text()
+ val dateText = el.selectFirst(".aqua-ch-item__time")?.text()?.trim()
+ MangaChapter(
+ id = generateUid(href),
+ url = href + stylePage,
+ title = name,
+ number = i + 1f,
+ volume = 0,
+ uploadDate = parseChapterDate(dateText),
+ source = source,
+ scanlator = null,
+ branch = null,
+ )
+ }
}
- private companion object {
- private const val LITTLE_BIT_CURSED_VALUE =
- "ICAgSUFBZ0FFa0FRd0JCQUdjQVNRQkRBRUVBWndCUkFGVUFUZ0JDQUZFQVZRQnNBRUlBVVFCWEFHUUFRZ0JSQURBQVJnQkNBRk1BVlFCR0FFSUFXZ0F3QUVZQVJBQlJBRlVBUmdCVUFGVUFWUUJLQUVVQVVRQlZBRllBUmdCUkFGWUFjQUF6QUZFQWF3QndBRUlBVWdCVkFERUFRZ0JWQUZZQVJnQkRBR0lBYXdCR0FFWUFZUUF3QUVZQVVnQmtBREFBU2dCREFGRUFWUUJrQUdvQVVRQldBRTRBVWdCUkFHc0FVZ0JDQUZJQVZRQnNBRUlBVlFBeUFHUUFRd0JWQUdzQVJnQkhBRllBVlFCR0FGTUFXZ0F3QUVvQU1RQlJBRlVBV2dCR0FGRUFWZ0JhQUZJQVVRQnJBRGtBUWdCU0FGVUFiQUJDQUZZQVZnQkdBRU1BVmdBd0FFWUFSZ0JPQUVVQVJnQldBRm9BTUFCS0FGTUFVUUJWQUdRQWVnQlJBRllBVmdCdUFGRUFhd0JPQUVJQVVnQnJBR3dBUWdCV0FHd0FSZ0JEQUZZQU1BQkdBRVlBVXdCVkFFWUFWd0JrQURBQVNnQXhBRkVBVlFCa0FGSUFVUUJXQUVZQU13QlJBR3dBVWdCQ0FGSUFNd0JPQUVJQVZRQnRBR1FBUXdCU0FEQUFSZ0JIQUZVQVZRQkdBRmNBVlFCVkFFb0FTQUJSQUZVQVdnQktBRkVBVmdCYUFGSUFVUUJzQUZvQVFnQlNBRmNBT1FCQ0FGb0FSZ0JHQUVNQVZRQnJBRVlBUndCV0FGVUFSZ0JYQUZvQU1BQktBRFVBVVFCVkFGb0FSZ0JSQUZZQVdnQnVBRkVBYXdCa0FFSUFVZ0JGQURFQVFnQldBRllBUmdCREFHTUFhd0JHQUVZQVlnQXdBRVlBVWdCYUFEQUFTZ0JVQUZFQVZRQlNBRW9BVVFCV0FGSUFiZ0JSQUdzQVRnQkNBRklBYkFCc0FFSUFWQUJXQUVZQVF3QlNBREFBUmdCR0FGTUFWUUJHQUdFQVZRQlZBRW9BVndCUkFGVUFWZ0JhQUZFQVZnQktBRklBVVFCdEFHZ0FRZ0JTQUVVQVJnQkNBRlVBYlFCa0FFTUFZd0JyQUVZQVJ3QlNBRlVBUmdCWEFGVUFWUUJLQUV3QVVRQlZBRlVBTUFCUkFGWUFWZ0JTQUZFQWJBQmFBRUlBVWdBd0FERUFRZ0JhQUVnQVpBQkRBRlVBYXdCR0FFY0FWd0JWQUVZQVZBQmFBREFBU2dBeEFGRUFWUUJhQUVZQVVRQlhBRVlBYmdCUkFHc0FaQUJDQUZJQVZRQnNBRUlBVmdCWEFHUUFRd0JrQUVVQVJnQkZBR0VBTUFCR0FGSUFXZ0F3QUVvQVZ3QlJBRlVBVWdCQ0FGRUFWZ0JLQUc0QVVRQnJBRklBUWdCU0FHc0FNUUJDQUZRQVZRQkdBRU1BVWdBd0FFWUFSZ0JoQURBQVJnQlhBR1FBTUFCS0FGY0FVUUJWQUZZQVdnQlJBRllBWkFCdUFGRUFiQUJhQUVJQVVnQnNBRllBUWdCVkFESUFaQUJEQUZjQWF3QkdBRWNBVWdCVkFFWUFWd0JWQUZVQVNnQm9BRkVBVlFCV0FGb0FVUUJXQUZZQVVnQlJBR3dBYUFCQ0FGSUFhd0JzQUVJQVZnQlhBR1FBUXdCVkFHc0FSZ0JJQUdRQU1BQkdBR29BVVFCVkFFb0FSQUJSQUZVQVdnQktBRkVBVmdCS0FGSUFVUUJ1QUU0QVFnQlNBRlVBYkFCQ0FGWUFNUUJHQUVNQVZnQnJBRVlBUmdCWEFGVUFSZ0JTQUdRQU1BQktBRkFBVVFCVkFGb0FWZ0JSQUZZQVNnQnVBRkVBYXdCc0FFSUFVZ0JyQURFQVFnQldBR3dBUmdCREFGSUFNQUJHQUVZQVRnQkZBRVlBV0FCYUFEQUFSZ0F6QUZFQVZRQldBRm9BVVFCVkFEVUFRZ0JSQUd3QVNnQkNBRklBYkFCV0FFSUFWd0JyQUVZQVFnQmxBR3NBUmdCSEFGSUFWUUJHQUZjQVdnQXdBRW9BVUFCUkFGVUFXZ0JLQUZFQVZnQldBRklBVVFCdUFFb0FRZ0JTQUdzQWJBQkNBRlVBVndCa0FFTUFWUUF3QUVZQVJ3QldBRlVBUmdCWEFGb0FNQUJLQUVRQVVRQlZBRm9BYWdCUkFGY0FTZ0J1QUZFQWJRQjBBRUlBVWdCVkFERUFRZ0JXQUZnQVpBQkRBR01BYXdCR0FFWUFWd0JWQUVZQVV3QmFBREFBU2dCV0FGRUFWUUJhQUZZQVVRQldBRW9BYmdCUkFHd0FUZ0JDQUZJQWJBQldBRUlBVmdCc0FFWUFRd0JUQURBQVJnQkpBRllBVlFCR0FGWUFWUUJWQUVvQVZ3QlJBRlVBV2dCYUFGRUFWd0JPQUc0QVVRQnNBRW9BUWdCU0FHd0FiQUJDQUZVQWJRQmtBRUlBWlFCckFFWUFSd0JTQUZVQVJnQm9BR1FBTUFCS0FFd0FVUUJWQUZZQVNnQlJBRllBVmdCdUFGRUFXQUJzQUVJQVVnQlVBRklBUWdCVkFGY0FaQUJEQUZZQVJRQkdBRWNBVmdCVkFFWUFVd0JhQURBQVNnQkVBRkVBVlFCYUFIWUFVUUJWQURFQVFnQlJBR3NBWkFCQ0FGSUFWZ0JHQUVJQVZnQldBRVlBUXdCV0FHc0FSZ0JHQUZjQVZRQkdBRlFBV2dBd0FFb0FVd0JSQUZVQVdnQldBRkVBVmdCS0FHNEFVUUJ1QUZZQVFnQlNBR3NBVmdCQ0FGWUFiQUJHQUVNQVVnQnJBRVlBUlFCaEFEQUFSZ0JXQUZFQVZRQktBRlVBVVFCVkFGWUFSZ0JSQUZZQWNBQXpBRkVBYXdCd0FFSUFVZ0JWQURFQVFnQlZBRllBUmdCREFHSUFhd0JHQUVZQVlRQXdBRVlBVWdCa0FEQUFTZ0JEQUZFQVZRQmtBR29BVVFCV0FFNEFVZ0JSQUdzQVNnQkNBRklBUkFCQ0FFSUFWUUJHQUVZQVFnQmFBREFBUmdCRUFGRUFWUUJHQUVvQVVRQlZBRVlBYmdCUkFGVUFUZ0JDQUZFQVZRQnNBRUlBVVFCWEFHTUFad0JKQUVNQVFRQm5BRWtBUXdCQkFEMEFJQUFnQUNBQUlBQT0gICA="
+ private fun parseChapterDate(text: String?): Long {
+ if (text.isNullOrBlank()) return 0L
+
+ val relativeRegex = Regex(
+ """(\d+)\s+(years?|months?|weeks?|days?|hours?|mins?|minutes?|sec(?:onds?)?)\s+ago""",
+ RegexOption.IGNORE_CASE
+ )
+ val match = relativeRegex.matchEntire(text)
+ if (match != null) {
+ val number = match.groupValues[1].toInt()
+ val unit = match.groupValues[2].lowercase()
+ val cal = Calendar.getInstance()
+ when {
+ unit.startsWith("year") -> cal.add(Calendar.YEAR, -number)
+ unit.startsWith("month") -> cal.add(Calendar.MONTH, -number)
+ unit.startsWith("week") -> cal.add(Calendar.DAY_OF_MONTH, -number * 7)
+ unit.startsWith("day") -> cal.add(Calendar.DAY_OF_MONTH, -number)
+ unit.startsWith("hour") -> cal.add(Calendar.HOUR, -number)
+ unit.startsWith("min") -> cal.add(Calendar.MINUTE, -number)
+ unit.startsWith("sec") -> cal.add(Calendar.SECOND, -number)
+ }
+ return cal.timeInMillis
+ }
+
+ val formats = listOf(
+ SimpleDateFormat("MMM d, yyyy", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") },
+ SimpleDateFormat("MMM dd, yyyy", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") },
+ SimpleDateFormat("MMMM d, yyyy", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") },
+ )
+ for (fmt in formats) {
+ try {
+ return fmt.parse(text)?.time ?: 0L
+ } catch (_: Exception) {}
+ }
+
+ return 0L
}
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt
index 1c526f8d..a5aa24db 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt
@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.parsers.site.kotatsu.en
+import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
+import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
@@ -8,166 +10,428 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
-import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.sync.Semaphore
-import kotlinx.coroutines.sync.withPermit
@MangaSourceParser("ATSUMARU", "Atsumaru", "en")
internal class Atsumaru(context: MangaLoaderContext) :
- PagedMangaParser(context, MangaParserSource.ATSUMARU, pageSize = 24) {
+ PagedMangaParser(context, MangaParserSource.ATSUMARU, pageSize = 40) {
override val configKeyDomain = ConfigKey.Domain("atsu.moe")
- private val apiUrl = "https://$domain/api/"
+ private val baseUrl = "https://atsu.moe"
- private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH).apply {
- timeZone = TimeZone.getTimeZone("UTC")
+ private val apiHeaders: Headers by lazy {
+ Headers.Builder()
+ .add("Accept", "*/*")
+ .add("Referer", baseUrl)
+ .add("Content-Type", "application/json")
+ .build()
}
override val availableSortOrders: Set = EnumSet.of(
- SortOrder.POPULARITY,
- SortOrder.RATING,
+ SortOrder.POPULARITY, // trending (views)
+ SortOrder.UPDATED, // recently added (dateAdded)
+ SortOrder.ADDED, // recently new added mangas
+ SortOrder.RELEVANCE // chapters count
)
- override val filterCapabilities: MangaListFilterCapabilities
- get() = MangaListFilterCapabilities(
- isSearchSupported = true
- )
+ override val filterCapabilities = MangaListFilterCapabilities(
+ isSearchSupported = true,
+ isMultipleTagsSupported = true,
+ isTagsExclusionSupported = true,
+ )
+
+ private val allGenres = listOf(
+ Genre("Action", "39"), Genre("Adult", "46"), Genre("Adventure", "37"),
+ Genre("Boys Love", "180"), Genre("Comedy", "6"), Genre("Drama", "31"),
+ Genre("Fantasy", "36"), Genre("Girls Love", "4"), Genre("Hentai", "10"),
+ Genre("Historical", "45"), Genre("Horror", "44"), Genre("Martial Arts", "29"),
+ Genre("Mystery", "32"), Genre("Psychological", "18"), Genre("Romance", "9"),
+ Genre("Sci-Fi", "1"), Genre("Slice of Life", "7"), Genre("Smut", "41"),
+ Genre("Supernatural", "22"), Genre("Thriller", "19"), Genre("Tragedy", "5")
+ )
+
+ private val allTags = listOf(
+ Tag("Blackmail", "285"), Tag("Cooking", "669"), Tag("Crimes", "288"),
+ Tag("Crossdressing", "167"), Tag("Murder", "250"), Tag("Prostitution", "366"),
+ Tag("Swordplay", "337"), Tag("Working", "248"),
+ Tag("Josei", "43"), Tag("Seinen", "8"), Tag("Shoujo", "40"), Tag("Shounen", "38"),
+ Tag("Otaku", "264"), Tag("Tsundere", "313"), Tag("Yandere", "315"),
+ Tag("Animal Characteristics", "274"), Tag("Beautiful Female Lead", "72"),
+ Tag("Big Breasts", "123"), Tag("Flat Chest", "320"), Tag("Glasses-Wearing Male Lead", "71"),
+ Tag("Handsome Male Lead", "68"), Tag("Kemonomimi", "279"), Tag("MILF", "339"),
+ Tag("Small Breasts", "124"), Tag("Young Male Lead", "787"),
+ Tag("Adult Cast", "159"), Tag("Bisexual", "382"), Tag("Ensemble Cast", "362"),
+ Tag("Female Lead", "59"), Tag("Male Lead", "58"), Tag("Non-Human Protagonist", "247"),
+ Tag("Primarily Adult Cast", "158"), Tag("Primarily Female Cast", "333"),
+ Tag("Primarily Male Cast", "335"), Tag("Primarily Teen Cast", "334"),
+ Tag("Strong Female Lead", "69"), Tag("Strong Male Lead", "67"),
+ Tag("Adapted to Anime", "166"), Tag("Based on a Light Novel", "76"),
+ Tag("Based on a Novel", "75"), Tag("Based on a Video Game", "77"),
+ Tag("Based on a Web Novel", "74"), Tag("College", "257"), Tag("Company", "1205"),
+ Tag("Countryside", "415"), Tag("Europe", "405"), Tag("Foreign", "336"),
+ Tag("High School", "162"), Tag("Hospital", "760"), Tag("Japan", "225"),
+ Tag("School", "107"), Tag("School Clubs", "356"),
+ Tag("Amnesia", "283"), Tag("Appearance Different from Personality", "651"),
+ Tag("Caught in the Act", "874"), Tag("Dead Family Member", "831"),
+ Tag("Family Drama", "848"), Tag("Flashbacks", "449"), Tag("Gender Bender", "12"),
+ Tag("Love Triangle", "125"), Tag("Male Lead Falls in Love First", "653"),
+ Tag("Misunderstandings", "647"), Tag("Past Plays a Big Role", "648"),
+ Tag("Reincarnation", "126"), Tag("Secret Identity", "260"),
+ Tag("Time Manipulation", "311"), Tag("Time Skip", "172"), Tag("Time Travel", "249"),
+ Tag("Tragic Past", "898"), Tag("Weak to Strong", "1064"),
+ Tag("Delinquents", "239"), Tag("Detectives", "240"), Tag("Idols", "281"),
+ Tag("Maids", "116"), Tag("Office Lady", "312"), Tag("Office Worker", "429"),
+ Tag("School Girl", "788"), Tag("Teachers", "175"),
+ Tag("Age Gap", "106"), Tag("Childhood Friends", "97"), Tag("Coworkers", "286"),
+ Tag("Female Harem", "163"), Tag("Friends to Lovers", "243"), Tag("Friendship", "242"),
+ Tag("Harem", "20"), Tag("Heterosexual", "108"), Tag("Incest", "174"),
+ Tag("Infidelity", "231"), Tag("Interspecies Relationship", "308"),
+ Tag("Love-Hate Relationship", "889"), Tag("Master-Servant Relationship", "406"),
+ Tag("Older Female Younger Male", "114"), Tag("Older Male Younger Female", "649"),
+ Tag("Older Uke Younger Seme", "880"), Tag("Siblings", "254"),
+ Tag("Student-Student Relationship", "573"), Tag("Student-Teacher Relationship", "177"),
+ Tag("Twins", "253"),
+ Tag("Chinese Ambience", "588"), Tag("European Ambience", "450"),
+ Tag("Fantasy World", "642"), Tag("Feudal Japan", "606"), Tag("Game Elements", "399"),
+ Tag("Game World", "641"), Tag("Isekai", "94"), Tag("Isekaied Into a Novel", "258"),
+ Tag("Mecha", "11"), Tag("Mythology", "259"), Tag("Urban", "338"),
+ Tag("Urban Fantasy", "261"),
+ Tag("Anal Intercourse", "100"), Tag("Bondage", "280"), Tag("Boobjob", "381"),
+ Tag("Borderline H", "448"), Tag("Cunnilingus", "171"), Tag("Defloration", "306"),
+ Tag("Dubious Consent", "985"), Tag("Ecchi", "21"), Tag("Erotica", "14"),
+ Tag("Exhibitionism", "287"), Tag("Group Intercourse", "373"),
+ Tag("Handjob", "303"), Tag("Lolicon", "28"), Tag("Masturbation", "161"),
+ Tag("Mature", "15"), Tag("Nakadashi", "169"), Tag("Netorare", "232"),
+ Tag("Nudity", "109"), Tag("Oral Intercourse", "99"), Tag("Outdoor Intercourse", "307"),
+ Tag("Public Intercourse", "103"), Tag("Rape", "95"), Tag("Sex Addict", "650"),
+ Tag("Sex Toys", "289"), Tag("Shotacon", "35"), Tag("Teens Love", "374"),
+ Tag("Threesome", "173"), Tag("Virginity", "369"),
+ Tag("Animals", "278"), Tag("Cats", "284"), Tag("Demons", "160"),
+ Tag("Ghosts", "229"), Tag("Gods", "176"), Tag("Monsters", "395"),
+ Tag("Non-human", "547"), Tag("Vampires", "252"),
+ Tag("21st century", "132"), Tag("Betrayal", "403"), Tag("Bullying", "235"),
+ Tag("Cohabitation", "228"), Tag("Coming of Age", "117"), Tag("Danmei", "305"),
+ Tag("Depression", "1090"), Tag("Family Life", "282"), Tag("Female Empowerment", "1816"),
+ Tag("Forbidden Love", "699"), Tag("Gore", "262"), Tag("Gourmet", "2"),
+ Tag("Harlequin", "304"), Tag("Jealousy", "881"), Tag("LGBTQ+", "326"),
+ Tag("Love Confession", "882"), Tag("Marriage", "360"), Tag("Mature Romance", "241"),
+ Tag("Medical", "350"), Tag("Military", "230"), Tag("Music", "27"),
+ Tag("Nobility", "127"), Tag("Obsessive Love", "893"), Tag("Orphans", "237"),
+ Tag("Religion", "498"), Tag("Reunion", "984"), Tag("Revenge", "227"),
+ Tag("Royalty", "128"), Tag("School Life", "42"), Tag("Shoujo Ai", "47"),
+ Tag("Shounen Ai", "23"), Tag("Special Ability", "883"), Tag("Sports", "30"),
+ Tag("Suicide", "309"), Tag("Super Powers", "236"), Tag("Unrequited Love", "226"),
+ Tag("Violence", "830"), Tag("War", "238"), Tag("Yaoi", "16"), Tag("Yuri", "33"),
+ Tag("4-Koma", "105"), Tag("Anthology", "113"), Tag("Chinese Novels", "1112"),
+ Tag("Collection of Stories", "111"), Tag("Doujinshi", "24"), Tag("Episodic", "115"),
+ Tag("Full color", "57"), Tag("Korean Novels", "1111"), Tag("Light Novel", "466"),
+ Tag("Longstrip", "93"), Tag("One Shot", "110"), Tag("Web Comic", "428"),
+ Tag("Web Novel", "427"), Tag("Magic", "121")
+ )
override suspend fun getFilterOptions() = MangaListFilterOptions(
- availableTags = setOf(
- MangaTag(
- key = "Manga",
- title = "Manga",
- source = source
- ),
- MangaTag(
- key = "Manwha",
- title = "Manhwa",
- source = source
- ),
- MangaTag(
- key = "Manhua",
- title = "Manhua",
- source = source
- ),
- MangaTag(
- key = "OEL",
- title = "OEL",
- source = source
- )
- ),
- availableStates = EnumSet.of(
- MangaState.ONGOING,
- MangaState.FINISHED,
- MangaState.PAUSED,
- MangaState.ABANDONED
- )
+ availableTags = (allGenres.map { (name, id) -> MangaTag(name, "genre:$id", source) } +
+ allTags.map { (name, id) -> MangaTag(name, "tag:$id", source) }).toSet(),
+ availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED, MangaState.ABANDONED),
+ availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER),
+ availableContentRating = EnumSet.of(ContentRating.SAFE, ContentRating.ADULT),
)
override suspend fun getListPage(
page: Int,
order: SortOrder,
- filter: MangaListFilter
- ): List {
+ filter: MangaListFilter,
+ ): List = getSearchPage(page, order, filter)
+
+ private suspend fun getSearchPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ val query = filter.query ?: "*"
+ val url = "$baseUrl/collections/manga/documents/search".toHttpUrl().newBuilder().apply {
+ addQueryParameter("q", query)
+ if (query != "*") {
+ addQueryParameter("query_by", "title,englishTitle,otherNames,authors")
+ addQueryParameter("query_by_weights", "4,3,2,1")
+ addQueryParameter("num_typos", "4,3,2,1")
+ }
+ addQueryParameter("page", page.toString())
+ addQueryParameter("per_page", pageSize.toString())
- val sort = when (order) {
- SortOrder.POPULARITY -> "views:desc"
- SortOrder.RATING -> "mbRating:desc"
- else -> "views:desc"
- }
+ val filterParts = mutableListOf("hidden:!=true", "views:>0")
- val url = "https://$domain/collections/manga/documents/search"
- .toHttpUrl()
- .newBuilder()
- .addQueryParameter("q", filter.query ?: "*")
- .addQueryParameter(
- "query_by",
- "title,englishTitle,otherNames,authors"
- )
- .addQueryParameter(
- "include_fields",
- "id,title,poster,type,status"
- )
- .addQueryParameter("page", page.toString())
- .addQueryParameter("per_page", pageSize.toString())
- .addQueryParameter("sort_by", sort)
- .addQueryParameter(
- "filter_by",
- buildFilter(filter)
- )
- .build()
+ val ratings = filter.contentRating
+ when {
+ ratings.contains(ContentRating.ADULT) && !ratings.contains(ContentRating.SAFE) -> {
+ filterParts.add("isAdult:=true")
+ }
+ ratings.contains(ContentRating.SAFE) && !ratings.contains(ContentRating.ADULT) -> {
+ filterParts.add("isAdult:=false")
+ }
+ // both or none: no filter shows all
+ else -> { /* no isAdult filter */ }
+ }
+ filterParts.add("(mbContentRating:=[`Safe`,`Suggestive`,`Erotica`] || mbContentRating:!=*)")
+
+ val types = filter.types.mapNotNull { ct ->
+ when (ct) {
+ ContentType.MANGA -> "Manga"
+ ContentType.MANHWA -> "Manwha"
+ ContentType.MANHUA -> "Manhua"
+ ContentType.OTHER -> "OEL"
+ else -> null
+ }
+ }
+ if (types.isNotEmpty()) {
+ filterParts.add("type:=[${types.joinToString(",") { "`$it`" }}]")
+ }
+
+ val statusVals = filter.states.mapNotNull { st ->
+ when (st) {
+ MangaState.ONGOING -> "Ongoing"
+ MangaState.FINISHED -> "Completed"
+ MangaState.PAUSED -> "Hiatus"
+ MangaState.ABANDONED -> "Canceled"
+ else -> null
+ }
+ }
+ if (statusVals.isNotEmpty()) {
+ filterParts.add("status:=[${statusVals.joinToString(",") { "`$it`" }}]")
+ }
+
+ val includedGenres = mutableListOf()
+ val excludedGenres = mutableListOf()
+ val includedTags = mutableListOf()
+ val excludedTags = mutableListOf()
+ for (tag in filter.tags) {
+ val key = tag.key
+ when {
+ key.startsWith("genre:") -> includedGenres.add(key.removePrefix("genre:"))
+ key.startsWith("tag:") -> includedTags.add(key.removePrefix("tag:"))
+ }
+ }
+ for (tag in filter.tagsExclude) {
+ val key = tag.key
+ when {
+ key.startsWith("genre:") -> excludedGenres.add(key.removePrefix("genre:"))
+ key.startsWith("tag:") -> excludedTags.add(key.removePrefix("tag:"))
+ }
+ }
- val hits = webClient
- .httpGet(url.toString())
- .parseJson()
- .optJSONArray("hits")
- ?: return emptyList()
+ if (includedGenres.isNotEmpty()) {
+ filterParts.add(includedGenres.joinToString(" && ") { "genreIds:=`$it`" })
+ }
+ if (excludedGenres.isNotEmpty()) {
+ filterParts.add("genreIds:!=[${excludedGenres.joinToString(",") { "`$it`" }}]")
+ }
+ if (includedTags.isNotEmpty()) {
+ filterParts.add(includedTags.joinToString(" && ") { "tagIds:=`$it`" })
+ }
+ if (excludedTags.isNotEmpty()) {
+ filterParts.add("tagIds:!=[${excludedTags.joinToString(",") { "`$it`" }}]")
+ }
+ addQueryParameter("filter_by", filterParts.joinToString(" && "))
- return List(hits.length()) { index ->
- hits.getJSONObject(index)
- .getJSONObject("document")
- .let(::parseManga)
+ val sort = when (order) {
+ SortOrder.POPULARITY -> "trending:desc"
+ SortOrder.UPDATED -> "released:desc"
+ SortOrder.ADDED -> "dateAdded:desc"
+ SortOrder.RELEVANCE -> "chapterCount:desc"
+ else -> "released:desc"
+ }
+ addQueryParameter("sort_by", sort)
+ addQueryParameter("sort_by", sort)
+ }.build()
+
+ val json = webClient.httpGet(url, apiHeaders).parseJson()
+ val hits = json.optJSONArray("hits") ?: return emptyList()
+ return (0 until hits.length()).map { i ->
+ hits.getJSONObject(i).getJSONObject("document").toManga()
}
}
- private fun buildFilter(filter: MangaListFilter): String {
+ // limit tags to 20
+ override suspend fun getDetails(manga: Manga): Manga {
+ detailsCache[manga.url]?.let { return it }
- val filters = mutableListOf()
+ val result = coroutineScope {
+ val mangaId = manga.url.substringAfterLast("/")
- filters += "views:>0"
+ val pageDeferred = async {
+ webClient.httpGet("$baseUrl/api/manga/page?id=$mangaId", apiHeaders).parseJson()
+ }
+ val chaptersDeferred = async {
+ webClient.httpGet("$baseUrl/api/manga/allChapters?mangaId=$mangaId", apiHeaders).parseJson()
+ }
- // Manga type filter
- val types = filter.tags.map {
- "`${it.key}`"
- }
+ val pageJson = pageDeferred.await()
+ val mangaPage = pageJson.optJSONObject("mangaPage") ?: return@coroutineScope manga
- if (types.isNotEmpty()) {
- filters += "type:=[${types.joinToString(",")}]"
- }
+ val title = mangaPage.optString("title").ifEmpty { mangaPage.optString("englishTitle", manga.title) }
+ val description = mangaPage.optString("synopsis") ?: manga.description
+
+ val rawRating = mangaPage.optDouble("avgRating", -1.0)
+ val rating = if (rawRating >= 0.0) (rawRating / 10.0).toFloat() else RATING_UNKNOWN
+
+ val posterObj = mangaPage.optJSONObject("poster")
+ val posterImage = posterObj?.optString("mediumImage")
+ val coverUrl = if (!posterImage.isNullOrEmpty()) {
+ "https://$domain/static/$posterImage"
+ } else manga.coverUrl
+
+ val authors = mangaPage.optJSONArray("authors")?.let { arr ->
+ (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("name") }.toSet()
+ } ?: emptySet()
+
+ val statusText = mangaPage.optString("status").orEmpty()
+ val state = when (statusText.lowercase()) {
+ "ongoing" -> MangaState.ONGOING
+ "completed" -> MangaState.FINISHED
+ "hiatus" -> MangaState.PAUSED
+ "canceled" -> MangaState.ABANDONED
+ else -> manga.state
+ }
+
+ val tagSet = mutableSetOf()
+ mangaPage.optJSONArray("genres")?.let { arr ->
+ for (i in 0 until minOf(arr.length(), 10)) {
+ val obj = arr.optJSONObject(i) ?: continue
+ val name = obj.optString("name").takeIf { it.isNotEmpty() } ?: continue
+ tagSet.add(MangaTag(name, "genre:${obj.optString("id")}", source))
+ }
+ }
+ mangaPage.optJSONArray("tags")?.let { arr ->
+ for (i in 0 until minOf(arr.length(), 10)) {
+ val obj = arr.optJSONObject(i) ?: continue
+ val name = obj.optString("name").takeIf { it.isNotEmpty() } ?: continue
+ tagSet.add(MangaTag(name, "tag:${obj.optString("id")}", source))
+ }
+ }
+
+ val chaptersJson = chaptersDeferred.await()
+ val chaptersArray = chaptersJson.optJSONArray("chapters") ?: JSONArray()
+ val scanlators = mutableMapOf()
+ mangaPage.optJSONArray("scanlators")?.let { scanArr ->
+ for (i in 0 until scanArr.length()) {
+ val sc = scanArr.optJSONObject(i) ?: continue
+ val id = sc.optString("id").takeIf { it.isNotEmpty() } ?: continue
+ val name = sc.optString("name").takeIf { it.isNotEmpty() } ?: continue
+ scanlators[id] = name
+ }
+ }
+
+ val chapters = (0 until chaptersArray.length()).map { i ->
+ val ch = chaptersArray.getJSONObject(i)
+ val chId = ch.getString("id")
+ val number = ch.optDouble("number", 0.0).toFloat()
+ val chTitle = ch.optString("title")
+ val scanId = ch.optString("scanlationMangaId").nullIfEmpty()
+ val scanName = scanId?.let { scanlators[it] }
+ val createdAt = ch.optLong("createdAt", 0L)
+
+ MangaChapter(
+ id = generateUid("$mangaId/$chId"),
+ title = chTitle.ifBlank { "Chapter $number" },
+ number = number,
+ volume = 0,
+ url = "$mangaId/$chId",
+ uploadDate = createdAt,
+ source = source,
+ scanlator = scanName,
+ branch = scanName,
+ )
+ }.reversed()
- // Status filter
- val statuses = filter.states.mapNotNull {
- when (it) {
- MangaState.ONGOING -> "`Ongoing`"
- MangaState.FINISHED -> "`Completed`"
- MangaState.PAUSED -> "`Hiatus`"
- MangaState.ABANDONED -> "`Canceled`"
- else -> null
+ val groupedChapters = if (chapters.map { it.branch }.distinct().size > 1) {
+ chapters.map { it.copy(branch = it.branch ?: "Unknown") }
+ } else {
+ chapters.map { it.copy(branch = null, scanlator = null) }
}
+
+ val altTitles = mangaPage.optJSONArray("otherNames")?.let { arr ->
+ (0 until arr.length()).mapNotNull { arr.optString(it) }.toSet()
+ } ?: emptySet()
+
+ manga.copy(
+ title = title,
+ description = description,
+ coverUrl = coverUrl,
+ authors = authors,
+ state = state,
+ tags = tagSet,
+ rating = rating,
+ chapters = groupedChapters,
+ altTitles = altTitles,
+ )
}
- if (statuses.isNotEmpty()) {
- filters += "status:=[${statuses.joinToString(",")}]"
+ detailsCache.put(manga.url, result)
+ return result
+ }
+
+ @get:Synchronized
+ private val detailsCache = object : LinkedHashMap(16, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean {
+ return size > 10
}
+ }
+ override suspend fun getRelatedManga(seed: Manga): List {
+ val mangaId = seed.url.substringAfterLast("/")
+ val pageJson = webClient.httpGet("$baseUrl/api/manga/page?id=$mangaId", apiHeaders).parseJson()
+ val mangaPage = pageJson.optJSONObject("mangaPage") ?: return emptyList()
+ val similar = mangaPage.optJSONArray("similarManga") ?: return emptyList()
+ return (0 until similar.length()).map { i ->
+ similar.getJSONObject(i).toManga()
+ }
+ }
- return filters.joinToString(" && ")
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val (mangaId, chapterId) = chapter.url.split("/")
+ val url = "$baseUrl/api/read/chapter".toHttpUrl().newBuilder()
+ .addQueryParameter("mangaId", mangaId)
+ .addQueryParameter("chapterId", chapterId)
+ .build()
+ val json = webClient.httpGet(url, apiHeaders).parseJson()
+ val pagesArray = json.getJSONObject("readChapter").getJSONArray("pages")
+ return (0 until pagesArray.length()).map { i ->
+ val page = pagesArray.getJSONObject(i)
+ val imagePath = page.getString("image")
+ val fullUrl = when {
+ imagePath.startsWith("http") -> imagePath
+ imagePath.startsWith("//") -> "https:$imagePath"
+ else -> "https://$domain/static/${imagePath.removePrefix("/").removePrefix("static/")}"
+ }
+ MangaPage(id = generateUid(fullUrl), url = fullUrl, preview = null, source = source)
+ }
}
+ private fun JSONObject.toManga(): Manga {
+ val id = getString("id")
+ val title = optString("title").ifEmpty { optString("englishTitle", "Unknown") }
- private fun parseManga(json: JSONObject): Manga {
- val id = json.getString("id")
- val title = json.optString("title").ifEmpty {
- json.optString("englishTitle", "Unknown")
+ val imageRaw: Any? = when {
+ has("poster") -> get("poster")
+ has("image") -> getString("image")
+ else -> null
}
- // List results have "image", search results have "poster"
- val image = json.optString("image")
- val poster = json.optString("poster")
- val imagePath = image.ifEmpty { poster }
+ val imagePath: String? = when (imageRaw) {
+ is String -> imageRaw.takeIf { it.isNotBlank() }
+ is JSONObject -> {
+ imageRaw.optString("image").nullIfEmpty()
+ ?: imageRaw.optString("mediumImage").nullIfEmpty()
+ ?: imageRaw.optString("smallImage").nullIfEmpty()
+ }
+ else -> null
+ }?.removePrefix("/")?.removePrefix("static/")
- val coverUrl = if (imagePath.isNotEmpty()) {
+ val coverUrl = if (!imagePath.isNullOrBlank()) {
when {
imagePath.startsWith("http") -> imagePath
- imagePath.startsWith("/static/") -> "https://$domain$imagePath"
- imagePath.startsWith("/") -> "https://$domain$imagePath"
+ imagePath.startsWith("//") -> "https:$imagePath"
else -> "https://$domain/static/$imagePath"
}
} else null
@@ -177,245 +441,17 @@ internal class Atsumaru(context: MangaLoaderContext) :
title = title,
altTitles = emptySet(),
url = "/manga/$id",
- publicUrl = "https://$domain/manga/$id",
+ publicUrl = "$baseUrl/manga/$id",
rating = RATING_UNKNOWN,
contentRating = null,
coverUrl = coverUrl,
tags = emptySet(),
state = null,
authors = emptySet(),
- source = source
- )
- }
-
- override suspend fun getDetails(manga: Manga): Manga = coroutineScope {
- val mangaId = manga.url.substringAfterLast("/")
- val pageDeferred = async {
- webClient
- .httpGet("${apiUrl}manga/page?id=$mangaId")
- .parseJson()
- .optJSONObject("mangaPage")
- }
- val chaptersDeferred = async {
- fetchAllChapters(
- mangaId,
- pageDeferred.await()
- )
- }
- val mangaPage = pageDeferred.await()
- val title = mangaPage?.optString("title")?.ifEmpty {
- mangaPage.optString("englishTitle", manga.title)
- } ?: manga.title
- val description =
- mangaPage?.optString("synopsis")
- ?: manga.description
- val posterImage =
- mangaPage
- ?.optJSONObject("poster")
- ?.optString("image")
- val coverUrl =
- if (!posterImage.isNullOrEmpty()) {
- "https://$domain/static/$posterImage"
- } else {
- manga.coverUrl
- }
- val authors =
- mangaPage
- ?.optJSONArray("authors")
- ?.let { array ->
- (0 until array.length())
- .mapNotNull {
- array.optJSONObject(it)
- ?.optString("name")
- }
- .toSet()
- }
- ?: emptySet()
- val status = mangaPage?.optString("status").orEmpty()
- val state = when(status.lowercase()) {
- "ongoing" -> MangaState.ONGOING
- "completed" -> MangaState.FINISHED
- "hiatus" -> MangaState.PAUSED
- "cancelled" -> MangaState.ABANDONED
- else -> manga.state
- }
- manga.copy(
- title = title,
- description = description,
- coverUrl = coverUrl,
- authors = authors,
- state = state,
- chapters = chaptersDeferred.await()
- )
- }
-
- private suspend fun fetchAllChapters(
- mangaId: String,
- mangaPage: JSONObject?
- ): List = coroutineScope {
-
- val scanlators = mangaPage.parseScanlators()
-
- // First request is needed to know total pages
- val firstUrl =
- "${apiUrl}manga/chapters?id=$mangaId&filter=all&sort=desc&page=0"
-
- val firstJson = webClient
- .httpGet(firstUrl)
- .parseJson()
-
- val totalPages = firstJson.optInt("pages", 1)
-
- val semaphore = Semaphore(8)
-
- val pages = buildList {
-
- // Add already fetched page 0
- add(firstJson)
-
- // Fetch remaining pages in parallel
- addAll(
- (1 until totalPages).map { page ->
-
- async {
- semaphore.withPermit {
-
- val url =
- "${apiUrl}manga/chapters?id=$mangaId&filter=all&sort=desc&page=$page"
-
- webClient
- .httpGet(url)
- .parseJson()
- }
- }
-
- }.awaitAll()
- )
- }
-
-
- pages.flatMap { json ->
-
- val array = json.optJSONArray("chapters")
- ?: return@flatMap emptyList()
-
- (0 until array.length())
- .mapNotNull { i ->
- array.optJSONObject(i)
- }
- .map {
- parseChapter(
- it,
- mangaId,
- scanlators
- )
- }
- }
- .reversed()
- .mapChapterBranches()
- }
-
- private fun JSONObject?.parseScanlators(): Map {
- val scanlatorsArray = this?.optJSONArray("scanlators") ?: return emptyMap()
- return buildMap {
- for (i in 0 until scanlatorsArray.length()) {
- val scanlator = scanlatorsArray.optJSONObject(i) ?: continue
- val id = scanlator.optString("id").takeIf { it.isNotEmpty() } ?: continue
- val name = scanlator.optString("name").takeIf { it.isNotEmpty() } ?: continue
- put(id, name)
- }
- }
- }
-
-
- private fun parseChapter(
- json: JSONObject,
- mangaId: String,
- scanlators: Map,
- ): MangaChapter {
- val chapterId = json.getString("id")
- val title = json.optString("title").takeIf { it.isNotEmpty() }
- val number = json.optString("number").toFloatOrNull()
- ?: json.optDouble("number", 0.0).toFloat()
- val scanlator = scanlators[json.optString("scanlationMangaId")]
-
- val uploadDate = when (val createdAt = json.opt("createdAt")) {
- is Number -> createdAt.toLong()
- is String -> {
- try {
- dateFormat.parse(createdAt)?.time ?: 0L
- } catch (_: Exception) {
- 0L
- }
- }
- else -> 0L
- }
-
- return MangaChapter(
- id = generateUid("$mangaId/$chapterId"),
- title = title,
- number = number,
- volume = 0,
- url = "$mangaId/$chapterId",
- uploadDate = uploadDate,
source = source,
- scanlator = scanlator,
- branch = scanlator
)
}
- private fun List.mapChapterBranches(): List {
- val totalPerChapter = groupingBy { it.number }
- .eachCount()
- val counters = HashMap()
- return map { chapter ->
- val total = totalPerChapter[chapter.number] ?: 1
-
- if (total == 1) {
- return@map chapter.copy(
- scanlator = null,
- branch = null
- )
- }
- val count = counters.getOrDefault(chapter.number, 0) + 1
- counters[chapter.number] = count
- val branch = if (count == 1) {
- "Group 1"
- } else {
- "Group $count"
- }
- chapter.copy(
- scanlator = branch,
- branch = branch
- )
- }
- }
-
- override suspend fun getPages(chapter: MangaChapter): List {
- val (mangaId, chapterId) = chapter.url.split("/")
- val url = "${apiUrl}read/chapter".toHttpUrl().newBuilder()
- .addQueryParameter("mangaId", mangaId)
- .addQueryParameter("chapterId", chapterId)
- .build()
-
- val json = webClient.httpGet(url.toString()).parseJson()
- val readChapter = json.getJSONObject("readChapter")
- val pages = readChapter.getJSONArray("pages")
-
- return (0 until pages.length()).map { i ->
- val page = pages.getJSONObject(i)
- val imagePath = page.getString("image")
- val fullUrl = "https://$domain$imagePath"
-
- MangaPage(
- id = generateUid(fullUrl),
- url = fullUrl,
- preview = null,
- source = source
- )
- }
- }
- override suspend fun getRelatedManga(seed: Manga): List {
- return emptyList()
- }
+ private data class Genre(val name: String, val id: String)
+ private data class Tag(val name: String, val id: String)
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Batcave.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Batcave.kt
new file mode 100644
index 00000000..7699d15d
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Batcave.kt
@@ -0,0 +1,251 @@
+package org.koitharu.kotatsu.parsers.site.kotatsu.en
+
+import okhttp3.FormBody
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import org.json.JSONObject
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.parsers.MangaSourceParser
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.core.PagedMangaParser
+import org.koitharu.kotatsu.parsers.exception.ParseException
+import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.network.OkHttpWebClient
+import org.koitharu.kotatsu.parsers.util.*
+import java.text.SimpleDateFormat
+import java.util.*
+
+@MangaSourceParser("BATCAVE", "Batcave", "en", ContentType.COMICS)
+internal class Batcave(context: MangaLoaderContext) :
+ PagedMangaParser(context, MangaParserSource.BATCAVE, 20) {
+
+ override val configKeyDomain = ConfigKey.Domain("batcave.biz")
+
+ private var publishers: List> = emptyList()
+ private var genres: List> = emptyList()
+ private var filterParseFailed = false
+
+ private val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.US)
+
+ private val apiClient: OkHttpWebClient by lazy {
+ val httpClient = context.httpClient.newBuilder()
+ .addInterceptor(::refererInterceptor)
+ .addInterceptor(::dleGuardInterceptor)
+ .build()
+ OkHttpWebClient(httpClient, source)
+ }
+
+ private val rawHttpClient: OkHttpClient by lazy { context.httpClient.newBuilder().build() }
+
+ private fun refererInterceptor(chain: okhttp3.Interceptor.Chain): Response {
+ val request = chain.request()
+ val url = request.url.toString()
+ val referer = when {
+ url.contains("readcomicsonline.ru") -> "https://readcomicsonline.ru/"
+ else -> "https://$domain/"
+ }
+ return chain.proceed(request.newBuilder().header("Referer", referer).build())
+ }
+
+ private fun dleGuardInterceptor(chain: okhttp3.Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+ val response = chain.proceed(originalRequest)
+ if (response.request.url.pathSegments.firstOrNull() != "_c") {
+ return response
+ }
+ response.close()
+ val url = if (originalRequest.method == "GET") {
+ originalRequest.url.toString()
+ } else {
+ "https://$domain/"
+ }
+ context.requestBrowserAction(this, url)
+ }
+
+ override fun onCreateConfig(keys: MutableCollection>) {
+ super.onCreateConfig(keys)
+ keys.add(userAgentKey)
+ }
+
+ override val availableSortOrders: Set = EnumSet.of(SortOrder.POPULARITY, SortOrder.UPDATED)
+
+ override val filterCapabilities = MangaListFilterCapabilities(
+ isSearchSupported = true,
+ isMultipleTagsSupported = false,
+ )
+
+ override suspend fun getFilterOptions(): MangaListFilterOptions {
+ return MangaListFilterOptions(
+ availableTags = emptySet(),
+ )
+ }
+
+ private fun parseFilterValues(filterItems: JSONObject, key: String): List> {
+ val obj = filterItems.optJSONObject(key) ?: return emptyList()
+ val values = obj.optJSONArray("values") ?: return emptyList()
+ val result = mutableListOf>()
+ for (i in 0 until values.length()) {
+ val item = values.getJSONObject(i)
+ result.add(item.getString("value") to item.getInt("id"))
+ }
+ return result
+ }
+
+ override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ val query = filter.query?.trim()?.takeIf { it.isNotEmpty() }
+ if (query != null) return searchManga(query, page)
+
+ val urlBuilder = StringBuilder().apply {
+ append("https://$domain/ComicList/")
+ if (filter.yearFrom != YEAR_UNKNOWN) append("y[from]=${filter.yearFrom}/")
+ if (filter.yearTo != YEAR_UNKNOWN) append("y[to]=${filter.yearTo}/")
+ val pIds = filter.tags.filter { it.key.startsWith("p_") }.map { it.key.removePrefix("p_") }
+ val gIds = filter.tags.filter { it.key.startsWith("g_") }.map { it.key.removePrefix("g_") }
+ if (pIds.isNotEmpty()) append("p=${pIds.joinToString(",")}/")
+ if (gIds.isNotEmpty()) append("g=${gIds.joinToString(",")}/")
+ append("sort")
+ if (page > 1) append("/page/$page/")
+ }
+ val url = urlBuilder.toString()
+
+ val sortPair = when (order) {
+ SortOrder.POPULARITY -> "rating" to "desc"
+ SortOrder.UPDATED -> "date" to "desc"
+ else -> "" to ""
+ }
+
+ return if (sortPair.first.isEmpty()) {
+ parseMangaList(apiClient.httpGet(url).parseHtml())
+ } else {
+ val formBody = FormBody.Builder()
+ .add("dlenewssortby", sortPair.first)
+ .add("dledirection", sortPair.second)
+ .add("set_new_sort", "dle_sort_xfilter")
+ .add("set_direction_sort", "dle_direction_xfilter")
+ .build()
+ val request = Request.Builder().url(url).post(formBody).build()
+ val response = rawHttpClient.newCall(request).execute()
+ parseMangaList(response.parseHtml())
+ }
+ }
+
+ private suspend fun searchManga(query: String, page: Int): List {
+ val encoded = query.urlEncoded()
+ val url = if (page == 1) "https://$domain/search/$encoded/"
+ else "https://$domain/search/$encoded/page/$page/"
+ return parseMangaList(apiClient.httpGet(url).parseHtml())
+ }
+
+ private fun parseMangaList(doc: org.jsoup.nodes.Document): List {
+ return doc.select("#dle-content > .readed").map { element ->
+ val a = element.selectFirst(".readed__title > a") ?: return@map null
+ val cover = element.selectFirst("img")?.attrAsAbsoluteUrl("data-src")
+ Manga(
+ id = generateUid(a.attrAsRelativeUrl("href")),
+ url = a.attrAsRelativeUrl("href"),
+ publicUrl = a.absUrl("href"),
+ title = a.ownText(),
+ altTitles = emptySet(),
+ rating = RATING_UNKNOWN,
+ contentRating = null,
+ coverUrl = cover,
+ tags = emptySet(),
+ state = null,
+ authors = emptySet(),
+ source = source,
+ )
+ }.filterNotNull()
+ }
+
+ @get:Synchronized
+ private val detailsCache = object : LinkedHashMap(16, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 10
+ }
+
+ override suspend fun getDetails(manga: Manga): Manga {
+ detailsCache[manga.url]?.let { return it }
+
+ val doc = apiClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
+
+ val title = doc.selectFirst("header.page__header h1")?.text() ?: manga.title
+ val cover = doc.selectFirst("div.page__poster img")?.absUrl("src")
+ val description = doc.selectFirst("div.page__text")?.text()
+ val author = doc.selectFirst(".page__list > li:has(> div:contains(Writer))")?.ownText()
+ val state = when (doc.selectFirst(".page__list > li:has(> div:contains(Release type))")?.ownText()?.trim()) {
+ "Ongoing" -> MangaState.ONGOING
+ else -> MangaState.FINISHED
+ }
+
+ val script = doc.selectFirst("script:containsData(window.__DATA__)")?.data()
+ ?: throw ParseException("Chapter data script not found", manga.url)
+ val json = JSONObject(
+ script.substringAfter("window.__DATA__ = ").substringBeforeLast(";").trim()
+ )
+ val newsId = json.getLong("news_id")
+ val chaptersArray = json.getJSONArray("chapters")
+ val chapters = (0 until chaptersArray.length()).map { i ->
+ val ch = chaptersArray.getJSONObject(i)
+ MangaChapter(
+ id = generateUid("/reader/$newsId/${ch.getInt("id")}"),
+ url = "/reader/$newsId/${ch.getInt("id")}",
+ title = ch.optString("title"),
+ number = ch.optDouble("posi", 0.0).toFloat(),
+ volume = 0,
+ scanlator = null,
+ uploadDate = dateFormat.parseSafe(ch.optString("date")),
+ branch = null,
+ source = source,
+ )
+ }.reversed()
+
+ val result = manga.copy(
+ title = title,
+ coverUrl = cover ?: manga.coverUrl,
+ description = description,
+ authors = setOfNotNull(author),
+ state = state,
+ tags = emptySet(),
+ chapters = chapters,
+ )
+ detailsCache[manga.url] = result
+ return result
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val (newsId, rawId) = chapter.url.substringAfter("reader/").split("/", limit = 2)
+ val id = Regex("^\\d+").find(rawId)?.value ?: rawId
+ val jsonBody = JSONObject().apply {
+ put("news_id", newsId)
+ put("chapter_id", id)
+ }.toString()
+
+ val request = Request.Builder()
+ .url("https://$domain/engine/ajax/controller.php?mod=api&action=reader/getChapterData")
+ .header("Referer", "https://$domain/")
+ .header("Content-Type", "application/json")
+ .post(
+ jsonBody
+ .toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
+ )
+ .build()
+
+ val response = rawHttpClient.newCall(request).execute()
+ val json = JSONObject(response.body?.string() ?: throw ParseException("Empty response", chapter.url))
+ val data = json.getJSONObject("data")
+ val images = data.getJSONArray("images")
+
+ return (0 until images.length()).map { i ->
+ var img = images.getString(i).trim()
+ if (!img.startsWith("http")) img = "https://$domain$img"
+ MangaPage(id = generateUid(img), url = img, preview = null, source = source)
+ }
+ }
+
+ override suspend fun getPageUrl(page: MangaPage): String = page.url
+
+ private fun SimpleDateFormat.parseSafe(date: String?): Long =
+ if (date.isNullOrBlank()) 0L else runCatching { parse(date)?.time ?: 0L }.getOrDefault(0L)
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/EnThunderScans.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/EnThunderScans.kt
new file mode 100644
index 00000000..03153da3
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/EnThunderScans.kt
@@ -0,0 +1,24 @@
+package org.koitharu.kotatsu.parsers.site.kotatsu.en
+
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.parsers.MangaSourceParser
+import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
+import org.koitharu.kotatsu.parsers.model.MangaParserSource
+import org.koitharu.kotatsu.parsers.parsers.MangaReaderParser
+
+@MangaSourceParser("ENTHUNDERSCANS", "EnThunderScans", "en")
+internal class EnThunderScans(context: MangaLoaderContext) :
+ MangaReaderParser(
+ context,
+ MangaParserSource.ENTHUNDERSCANS,
+ "en-thunderscans.com",
+ pageSize = 30,
+ searchPageSize = 10,
+ ) {
+ override val listUrl = "/comics"
+
+ override val filterCapabilities: MangaListFilterCapabilities
+ get() = super.filterCapabilities.copy(
+ isTagsExclusionSupported = false,
+ )
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt
similarity index 62%
rename from src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo
rename to src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt
index a8cf1485..7e31556c 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt
@@ -5,17 +5,6 @@ import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.parsers.MangaThemesia
-
-@MangaSourceParser("KINGOFSHOJO", "King of Shojo", "en",)
+@MangaSourceParser("KINGOFSHOJO", "King of Shojo", "en")
internal class KingofShojo(context: MangaLoaderContext) :
- MangaThemesia(context, MangaParserSource.KINGOFSHOJO, "kingofshojo.com",) {
-
- override val mangaDirectory =
- "manga"
-
- override val searchSelector =
- ".listupd .bsx"
-
- override val pageSelector =
- "#readerarea img"
-}
+ MangaThemesia(context, MangaParserSource.KINGOFSHOJO, "kingofshojo.com")
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaFox.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaFox.kt
index f86ca381..8af146a5 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaFox.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaFox.kt
@@ -1,60 +1,89 @@
package org.koitharu.kotatsu.parsers.site.kotatsu.en
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import org.jsoup.nodes.Document
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaSourceParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.network.OkHttpWebClient
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
import java.util.*
@MangaSourceParser("MANGAFOX", "MangaFox", "en")
-internal class MangaFox(context: MangaLoaderContext):
+internal class MangaFox(context: MangaLoaderContext) :
PagedMangaParser(context, MangaParserSource.MANGAFOX, pageSize = 24) {
private val baseUrl = "fanfox.net"
private val mobileUrl = "https://m.fanfox.net"
private val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.ENGLISH)
- override val configKeyDomain: ConfigKey.Domain =
- ConfigKey.Domain(baseUrl)
+ override val configKeyDomain = ConfigKey.Domain(baseUrl)
+
+ private val apiClient: OkHttpWebClient by lazy {
+ val httpClient = context.httpClient.newBuilder()
+ .addInterceptor { chain ->
+ val request = chain.request().newBuilder()
+ .header("Referer", "https://$baseUrl/")
+ .build()
+ chain.proceed(request)
+ }
+ .build()
+ OkHttpWebClient(httpClient, source)
+ }
- override val availableSortOrders: Set =
- EnumSet.of(SortOrder.POPULARITY, SortOrder.UPDATED)
+ override val availableSortOrders: Set = EnumSet.of(SortOrder.POPULARITY, SortOrder.UPDATED)
- override val filterCapabilities =
- MangaListFilterCapabilities(isSearchSupported = true)
+ override val filterCapabilities = MangaListFilterCapabilities(
+ isSearchSupported = true
+ )
- override suspend fun getListPage(
- page: Int,
- order: SortOrder,
- filter: MangaListFilter,
- ): List {
+ @Volatile
+ private var tagsCache: Set? = null
+ private val tagsMutex = Mutex()
- val url = buildUrl(page, filter, order)
- val doc = webClient.httpGet(url).parseHtml()
+ override suspend fun getFilterOptions(): MangaListFilterOptions {
+ return MangaListFilterOptions(
+ availableTags = getOrFetchTags()
+ )
+ }
+ private suspend fun getOrFetchTags(): Set {
+ tagsCache?.let { return it }
+ return tagsMutex.withLock {
+ tagsCache ?: fetchTags().also { tagsCache = it }
+ }
+ }
+
+ private fun fetchTags(): Set {
+ return listOf(
+ "action", "adventure", "comedy", "drama", "fantasy",
+ "harem", "romance", "school-life", "shounen", "shoujo",
+ "seinen", "sci-fi", "slice-of-life", "supernatural",
+ "ecchi", "mystery", "psychological", "historical",
+ "martial-arts", "mecha", "sports"
+ ).map {
+ MangaTag(key = it, title = it.replace("-", " "), source = source)
+ }.toSet()
+ }
+
+ override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ val url = buildUrl(page, filter, order)
+ val doc = apiClient.httpGet(url).parseHtml()
return parseMangaList(doc)
}
private fun buildUrl(page: Int, filter: MangaListFilter, order: SortOrder): String {
-
val query = filter.query?.trim().orEmpty()
val tag = filter.tags.firstOrNull()?.key
-
return when {
-
- // TAG SEARCH (Fanfox mobile endpoint)
!tag.isNullOrBlank() && query.isEmpty() ->
"https://m.fanfox.net/search/cate/$tag"
-
- // TEXT SEARCH
query.isNotEmpty() ->
"https://m.fanfox.net/search?k=${query.urlEncoded()}"
-
- // DEFAULT DIRECTORY
else ->
buildString {
append("https://$baseUrl/directory/")
@@ -65,71 +94,77 @@ internal class MangaFox(context: MangaLoaderContext):
}
private fun parseMangaList(doc: Document): List {
-
- val nodes = when {
- doc.select("ul.manga-list-1-list li").isNotEmpty() ->
- doc.select("ul.manga-list-1-list li")
-
- doc.select("ul.manga-list-4-list li").isNotEmpty() ->
- doc.select("ul.manga-list-4-list li")
-
- doc.select("ul.manga-list-2-list li").isNotEmpty() ->
- doc.select("ul.manga-list-2-list li")
-
- else ->
- doc.select("li")
+ val searchItems = doc.select(".post-list li")
+ if (searchItems.isNotEmpty()) {
+ return searchItems.mapNotNull { el ->
+ val a = el.selectFirst("a") ?: return@mapNotNull null
+ val href = a.attrAsRelativeUrl("href")
+ Manga(
+ id = generateUid(href),
+ title = a.attr("title").ifBlank { a.selectFirst(".cover-info .title")?.text() ?: a.text() },
+ url = href,
+ publicUrl = a.absUrl("href"),
+ coverUrl = el.selectFirst("img")?.attr("abs:src").orEmpty(),
+ rating = RATING_UNKNOWN,
+ contentRating = null,
+ tags = emptySet(),
+ authors = emptySet(),
+ state = null,
+ altTitles = emptySet(),
+ largeCoverUrl = null,
+ description = null,
+ source = source
+ )
+ }
}
- return nodes.mapNotNull { el ->
- val a = el.selectFirst("a") ?: return@mapNotNull null
- val href = a.attrAsRelativeUrl("href")
-
- Manga(
- id = generateUid(href),
- title = a.attr("title").ifBlank { a.text() },
- url = href,
- publicUrl = a.absUrl("href"),
- coverUrl = el.selectFirst("img")?.attr("abs:src"),
-
- rating = RATING_UNKNOWN,
- contentRating = null,
-
- tags = emptySet(),
- authors = emptySet(),
-
- state = null,
-
- altTitles = emptySet(),
- largeCoverUrl = null,
- description = null,
-
- source = source
- )
+ val dirSelectors = listOf(
+ "ul.manga-list-1-list li",
+ "ul.manga-list-4-list li",
+ "ul.manga-list-2-list li"
+ )
+ for (sel in dirSelectors) {
+ val items = doc.select(sel)
+ if (items.isNotEmpty()) {
+ return items.mapNotNull { el ->
+ val a = el.selectFirst("a") ?: return@mapNotNull null
+ val href = a.attrAsRelativeUrl("href")
+ Manga(
+ id = generateUid(href),
+ title = a.attr("title").ifBlank { a.text() },
+ url = href,
+ publicUrl = a.absUrl("href"),
+ coverUrl = el.selectFirst("img")?.attr("abs:src").orEmpty(),
+ rating = RATING_UNKNOWN,
+ contentRating = null,
+ tags = emptySet(),
+ authors = emptySet(),
+ state = null,
+ altTitles = emptySet(),
+ largeCoverUrl = null,
+ description = null,
+ source = source
+ )
+ }
+ }
}
+ return emptyList()
}
-
override suspend fun getDetails(manga: Manga): Manga {
- val doc = webClient.httpGet(manga.url.toAbsoluteUrl(baseUrl)).parseHtml()
+ val doc = apiClient.httpGet(manga.url.toAbsoluteUrl(baseUrl)).parseHtml()
val info = doc.selectFirst(".detail-info-right")!!
val chapters = doc.select("ul.detail-main-list li").mapIndexedNotNull { i, el ->
-
val a = el.selectFirst("a") ?: return@mapIndexedNotNull null
val href = a.attrAsRelativeUrl("href")
-
- val name = a.selectFirst("p")?.text()
- ?: a.ownText()
-
+ val name = a.selectFirst("p")?.text() ?: a.ownText()
val rawDate = el.text()
.let { text ->
Regex("""(Today|Yesterday|\w{3}\s\d{1,2},\s\d{4}|\d{4}-\d{2}-\d{2})""")
- .find(text)
- ?.value
+ .find(text)?.value
}
-
val parsedDate = parseChapterDate(rawDate)
-
MangaChapter(
id = generateUid(href),
title = name,
@@ -146,35 +181,21 @@ internal class MangaFox(context: MangaLoaderContext):
return manga.copy(
title = doc.selectFirst("h1")?.text() ?: manga.title,
-
- authors = info.select(".detail-info-right-say a")
- .mapToSet { it.text() },
-
- tags = info.select(".detail-info-right-tag-list a")
- .mapToSet {
- MangaTag(
- key = it.text().lowercase(),
- title = it.text(),
- source = source
- )
- },
-
+ authors = info.select(".detail-info-right-say a").mapToSet { it.text() },
+ tags = info.select(".detail-info-right-tag-list a").mapToSet {
+ MangaTag(key = it.text().lowercase(), title = it.text(), source = source)
+ },
description = info.selectFirst("p.fullcontent")?.text(),
-
state = parseStatus(info.selectFirst(".detail-info-right-title-tip")?.text().orEmpty()),
-
- coverUrl = doc.selectFirst(".detail-info-cover-img")?.attr("abs:src"),
-
+ coverUrl = doc.selectFirst(".detail-info-cover-img")?.attr("abs:src").orEmpty(),
chapters = chapters
)
}
- private fun parseStatus(status: String): MangaState? {
- return when {
- status.contains("Ongoing", true) -> MangaState.ONGOING
- status.contains("Completed", true) -> MangaState.FINISHED
- else -> null
- }
+ private fun parseStatus(status: String): MangaState? = when {
+ status.contains("Ongoing", true) -> MangaState.ONGOING
+ status.contains("Completed", true) -> MangaState.FINISHED
+ else -> null
}
private fun extractChapterNumber(name: String): Float {
@@ -188,70 +209,28 @@ internal class MangaFox(context: MangaLoaderContext):
private fun parseChapterDate(date: String?): Long {
if (date.isNullOrBlank()) return 0L
-
val clean = date.trim()
-
return when {
"Today" in clean -> Calendar.getInstance().timeInMillis
-
- "Yesterday" in clean -> Calendar.getInstance().apply {
- add(Calendar.DATE, -1)
- }.timeInMillis
-
+ "Yesterday" in clean -> Calendar.getInstance().apply { add(Calendar.DATE, -1) }.timeInMillis
clean.matches(Regex("""\d{4}-\d{2}-\d{2}""")) -> runCatching {
val parts = clean.split("-")
- Calendar.getInstance().apply {
- set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt())
- }.timeInMillis
- }.getOrDefault(0L)
-
- else -> runCatching {
- dateFormat.parse(clean)?.time ?: 0L
+ Calendar.getInstance().apply { set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt()) }.timeInMillis
}.getOrDefault(0L)
+ else -> runCatching { dateFormat.parse(clean)?.time ?: 0L }.getOrDefault(0L)
}
}
- override suspend fun getFilterOptions(): MangaListFilterOptions {
- return MangaListFilterOptions(
- availableTags = getTags()
- )
- }
-
- private fun getTags(): Set {
- return listOf(
- "action", "adventure", "comedy", "drama", "fantasy",
- "harem", "romance", "school-life", "shounen", "shoujo",
- "seinen", "sci-fi", "slice-of-life", "supernatural",
- "ecchi", "mystery", "psychological", "historical",
- "martial-arts", "mecha", "sports"
- ).map {
- MangaTag(
- key = it,
- title = it.replace("-", " "),
- source = source
- )
- }.toSet()
- }
-
- override suspend fun getRelatedManga(seed: Manga): List = emptyList()
-
override suspend fun getPages(chapter: MangaChapter): List {
val mobilePath = chapter.url.replace("/manga/", "/roll_manga/")
val url = "$mobileUrl$mobilePath"
-
- val doc = webClient.httpGet(url).parseHtml()
-
+ val doc = apiClient.httpGet(url).parseHtml()
return doc.select("#viewer img").map {
val imgUrl = it.attr("abs:data-original")
-
- MangaPage(
- id = generateUid(imgUrl),
- url = imgUrl,
- preview = null,
- source = source
- )
+ MangaPage(id = generateUid(imgUrl), url = imgUrl, preview = null, source = source)
}
}
override suspend fun getPageUrl(page: MangaPage): String = page.url
+ override suspend fun getRelatedManga(seed: Manga): List = emptyList()
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaGeko.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaGeko.kt
new file mode 100644
index 00000000..c99da478
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaGeko.kt
@@ -0,0 +1,242 @@
+package org.koitharu.kotatsu.parsers.site.kotatsu.en
+
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import org.json.JSONObject
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Element
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.parsers.MangaSourceParser
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.core.PagedMangaParser
+import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.util.*
+import java.text.SimpleDateFormat
+import java.util.*
+
+@MangaSourceParser("MANGAKEKO", "MangaGeko", "en")
+internal class MangaGeko(context: MangaLoaderContext) :
+ PagedMangaParser(context, MangaParserSource.MANGAKEKO, 30) {
+
+ override val configKeyDomain = ConfigKey.Domain("www.mgeko.cc")
+ private val baseUrl = "https://$domain"
+
+ override val availableSortOrders: Set = EnumSet.of(
+ SortOrder.NEWEST, // recently_added
+ SortOrder.UPDATED, // latest
+ SortOrder.POPULARITY, // popular_all_time
+ SortOrder.RATING, // rating
+ SortOrder.ALPHABETICAL, // az
+ SortOrder.ALPHABETICAL_DESC, // za
+ )
+
+ override val filterCapabilities = MangaListFilterCapabilities(
+ isSearchSupported = true,
+ isMultipleTagsSupported = true,
+ isTagsExclusionSupported = true,
+ )
+
+ private val dateFormats = listOf(
+ SimpleDateFormat("MMMM dd, yyyy, h:mm a", Locale.ENGLISH),
+ SimpleDateFormat("MMMM dd, yyyy, h a", Locale.ENGLISH),
+ )
+
+ private val allGenres = setOf(
+ "Action", "Adventure", "Comedy", "Cooking", "Manga", "Drama", "Fantasy",
+ "Gender bender", "Harem", "Historical", "Horror", "Isekai", "Josei",
+ "Manhua", "Manhwa", "Martial arts", "Mature", "Mecha", "Medical",
+ "Mystery", "One shot", "Psychological", "Romance", "School life",
+ "Sci fi", "Seinen", "Shoujo", "Shounen", "Slice of life", "Sports",
+ "Supernatural", "Tragedy", "Webtoons", "Ladies",
+ )
+
+ override fun onCreateConfig(keys: MutableCollection>) {
+ super.onCreateConfig(keys)
+ keys.add(userAgentKey)
+ }
+
+ override suspend fun getFilterOptions(): MangaListFilterOptions {
+ val tags = allGenres.map { MangaTag(it, it.lowercase().replace(" ", "_"), source) }.toSet()
+ return MangaListFilterOptions(
+ availableTags = tags,
+ availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED),
+ availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.COMICS),
+ availableContentRating = EnumSet.of(ContentRating.SAFE, ContentRating.ADULT),
+ )
+ }
+
+ override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ if (!filter.query.isNullOrEmpty()) {
+ return searchManga(page, filter.query!!)
+ }
+ return browseManga(page, order, filter)
+ }
+
+ private suspend fun searchManga(page: Int, query: String): List {
+ val url = "$baseUrl/search/".toHttpUrl().newBuilder()
+ .addQueryParameter("search", query)
+ .addQueryParameter("results", page.toString())
+ .build()
+ val doc = webClient.httpGet(url).parseHtml()
+ return doc.select(".novel-item").mapNotNull { it.toManga() }
+ }
+
+ private suspend fun browseManga(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ val sort = when (order) {
+ SortOrder.NEWEST -> "recently_added"
+ SortOrder.UPDATED -> "latest"
+ SortOrder.POPULARITY -> "popular_all_time"
+ SortOrder.RATING -> "rating"
+ SortOrder.ALPHABETICAL -> "az"
+ SortOrder.ALPHABETICAL_DESC -> "za"
+ else -> "latest"
+ }
+
+ val url = "$baseUrl/browse-comics/data/".toHttpUrl().newBuilder().apply {
+ addQueryParameter("page", page.toString())
+ addQueryParameter("sort", sort)
+
+ val safeMode = if (ContentRating.ADULT !in filter.contentRating) "1" else "0"
+ addQueryParameter("safe_mode", safeMode)
+
+ if (filter.tags.isNotEmpty()) {
+ addQueryParameter("include_genres", filter.tags.joinToString(",") { it.key.replace("_", " ") })
+ }
+ if (filter.tagsExclude.isNotEmpty()) {
+ addQueryParameter("exclude_genres", filter.tagsExclude.joinToString(",") { it.key.replace("_", " ") })
+ }
+
+ if (filter.states.isNotEmpty()) {
+ val status = when (filter.states.first()) {
+ MangaState.ONGOING -> "Ongoing"
+ MangaState.FINISHED -> "Completed"
+ MangaState.PAUSED -> "Hiatus"
+ else -> null
+ }
+ status?.let { addQueryParameter("status", it) }
+ }
+
+ if (filter.types.isNotEmpty()) {
+ val type = when (filter.types.first()) {
+ ContentType.MANGA -> "Manga"
+ ContentType.MANHWA -> "Manhwa"
+ ContentType.MANHUA -> "Manhua"
+ ContentType.COMICS -> "Webtoon"
+ else -> null
+ }
+ type?.let { addQueryParameter("type", it) }
+ }
+ }.build()
+
+ val response = webClient.httpGet(url)
+ val json = JSONObject(response.body.string())
+ val htmlFragment = json.getString("results_html")
+ val doc = Jsoup.parseBodyFragment(htmlFragment, baseUrl)
+ return doc.select(".comic-card").map { it.toManga() }
+ }
+
+ override suspend fun getDetails(manga: Manga): Manga {
+ val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml()
+ val header = doc.selectFirstOrThrow(".novel-header")
+
+ val title = header.selectFirst(".novel-title")?.text()?.trim() ?: manga.title
+ val cover = header.selectFirst(".cover img")?.let {
+ it.attr("data-src").ifBlank { it.attr("src") }
+ } ?: manga.coverUrl
+
+ val author = header.selectFirst(".author a")?.attr("title")?.trim()?.takeIf {
+ it.lowercase() != "updating"
+ }
+
+ val state = when {
+ header.selectFirst("div.header-stats strong.completed") != null -> MangaState.FINISHED
+ header.selectFirst("div.header-stats strong.ongoing") != null -> MangaState.ONGOING
+ else -> null
+ }
+
+ val tags = doc.select(".categories a[href*=genre]").mapToSet { a ->
+ val name = a.ownText().trim().split(" ").joinToString(" ") { word ->
+ word.lowercase().replaceFirstChar { it.uppercase() }
+ }
+ MangaTag(name, name.lowercase().replace(" ", "_"), source)
+ }
+
+ val chapters = fetchChapters(manga.url)
+
+ return manga.copy(
+ title = title,
+ altTitles = emptySet(),
+ coverUrl = cover,
+ largeCoverUrl = cover,
+ state = state,
+ authors = setOfNotNull(author),
+ description = null,
+ tags = tags,
+ chapters = chapters,
+ )
+ }
+
+ private suspend fun fetchChapters(mangaUrl: String): List {
+ val doc = webClient.httpGet(mangaUrl.toAbsoluteUrl(domain) + "all-chapters/").parseHtml()
+ return doc.select("ul.chapter-list > li").mapNotNull { li ->
+ val a = li.selectFirst("a") ?: return@mapNotNull null
+ val url = a.attrAsRelativeUrl("href")
+ val titleEl = li.selectFirst(".chapter-title, .chapter-number") ?: return@mapNotNull null
+ val chapterName = titleEl.ownText()
+ val dateStr = li.selectFirst(".chapter-update")?.attr("datetime")
+ ?.replace(".", "")?.replace("Sept", "Sep") ?: ""
+ val date = dateFormats.firstNotNullOfOrNull { fmt -> fmt.parseSafe(dateStr) } ?: 0L
+ MangaChapter(
+ id = generateUid(url),
+ title = "Chapter $chapterName",
+ number = 0f,
+ volume = 0,
+ url = url,
+ uploadDate = date,
+ source = source,
+ scanlator = null,
+ branch = null,
+ )
+ }.reversed()
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml()
+ return doc.select("#chapter-reader img").mapNotNull { img ->
+ val src = img.absUrl("src")
+ if (src.isEmpty()) return@mapNotNull null
+ MangaPage(
+ id = generateUid(src),
+ url = src,
+ preview = null,
+ source = source,
+ )
+ }
+ }
+
+ private fun Element.toManga(): Manga {
+ val titleEl = selectFirst(".comic-card__title a") ?: selectFirst(".novel-title")
+ val title = titleEl?.text()?.trim() ?: ""
+ val href = selectFirst("a")?.attrAsRelativeUrl("href") ?: ""
+ val cover = selectFirst(".comic-card__cover img, .novel-cover img")?.let {
+ it.attr("data-src").ifBlank { it.attr("src") }
+ } ?: ""
+ return Manga(
+ id = generateUid(href),
+ url = href,
+ publicUrl = href.toAbsoluteUrl(domain),
+ title = title,
+ altTitles = emptySet(),
+ rating = RATING_UNKNOWN,
+ contentRating = null,
+ coverUrl = cover,
+ tags = emptySet(),
+ state = null,
+ authors = emptySet(),
+ source = source,
+ )
+ }
+
+ override suspend fun getRelatedManga(seed: Manga): List = emptyList()
+
+ private fun SimpleDateFormat.parseSafe(date: String): Long? = runCatching { parse(date)?.time }.getOrNull()
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaK.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaK.kt
index e5d71fa7..508a8099 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaK.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaK.kt
@@ -6,7 +6,9 @@ import org.json.JSONObject
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.exception.ParseException
+import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
+import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
@@ -32,6 +34,8 @@ import org.koitharu.kotatsu.parsers.util.urlEncoded
import java.text.SimpleDateFormat
import java.util.EnumSet
import java.util.Locale
+import okhttp3.Interceptor
+import okhttp3.Response
@MangaSourceParser("MANGAK", "MangaK", "en", ContentType.MANGA)
internal class MangaK(context: MangaLoaderContext) :
@@ -48,6 +52,18 @@ internal class MangaK(context: MangaLoaderContext) :
keys.add(userAgentKey)
}
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ val response = chain.proceed(request)
+ if (!response.isSuccessful && request.url.host.matches(IMAGE_FALLBACK_REGEX)) {
+ response.close()
+ val newUrl = request.url.newBuilder().host("rx.rzyn.net").build()
+ return chain.proceed(request.newBuilder().url(newUrl).build())
+ }
+ return response
+ }
+
+
override val availableSortOrders: Set = EnumSet.of(
SortOrder.UPDATED,
SortOrder.NEWEST,
@@ -65,7 +81,28 @@ internal class MangaK(context: MangaLoaderContext) :
override suspend fun getFilterOptions() = MangaListFilterOptions(
availableTags = fetchTags(),
- availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED),
+ availableStates = EnumSet.of(
+ MangaState.ONGOING,
+ MangaState.FINISHED,
+// MangaState.PAUSED,
+// MangaState.ABANDONED,
+ ),
+ availableContentTypes = EnumSet.of(
+ ContentType.MANGA,
+ ContentType.MANHWA,
+ ContentType.MANHUA,
+ ),
+ availableDemographics = EnumSet.of(
+ Demographic.SHOUJO,
+ Demographic.SEINEN,
+ Demographic.SHOUNEN,
+ Demographic.JOSEI,
+ ),
+ availableContentRating = EnumSet.of(
+ ContentRating.SAFE,
+ ContentRating.SUGGESTIVE,
+ ContentRating.ADULT,
+ ),
)
private suspend fun fetchTags(): Set {
@@ -79,7 +116,12 @@ internal class MangaK(context: MangaLoaderContext) :
}
}
- override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ override suspend fun getListPage(
+ page: Int,
+ order: SortOrder,
+ filter: MangaListFilter,
+ ): List {
+ val allExcludedTags = filter.tagsExclude.map { it.key }.toMutableSet()
val query = filter.query
val url = buildString {
append(apiUrl)
@@ -100,27 +142,70 @@ internal class MangaK(context: MangaLoaderContext) :
if (!query.isNullOrEmpty()) {
append("&q=")
- append(query.urlEncoded())
+ // api does not like commans and character limits to 60-70
+ append(query.replace(",", "").urlEncoded())
}
+
if (filter.tags.isNotEmpty()) {
append("&genres=")
append(filter.tags.joinToString(",") { it.key })
}
- if (filter.tagsExclude.isNotEmpty()) {
+
+ if (allExcludedTags.isNotEmpty()) {
append("&exclude=")
- append(filter.tagsExclude.joinToString(",") { it.key })
+ append(allExcludedTags.joinToString(","))
}
+
+ filter.types.oneOrThrowIfMany()?.let {
+ append("&type=")
+ append(
+ when (it) {
+ ContentType.MANGA -> "manga"
+ ContentType.MANHWA -> "manhwa"
+ ContentType.MANHUA -> "manhua"
+ else -> null
+ } ?: return@let
+ )
+ }
+
+ filter.contentRating.oneOrThrowIfMany()?.let {
+ append("&content_rating=")
+ append(
+ when (it) {
+ ContentRating.SAFE -> "safe"
+ ContentRating.SUGGESTIVE -> "suggestive"
+ ContentRating.ADULT -> "pornographic"
+ }
+ )
+ }
+
+ filter.demographics.oneOrThrowIfMany()?.let {
+ append("&demographic=")
+ append(
+ when (it) {
+ Demographic.SHOUJO -> "shoujo"
+ Demographic.SEINEN -> "seinen"
+ Demographic.SHOUNEN -> "shounen"
+ Demographic.JOSEI -> "josei"
+ else -> null
+ } ?: return@let
+ )
+ }
+
filter.states.oneOrThrowIfMany()?.let {
append("&status=")
append(
when (it) {
MangaState.ONGOING -> "ongoing"
MangaState.FINISHED -> "completed"
- else -> "all"
- },
+// MangaState.PAUSED -> "hiatus"
+// MangaState.ABANDONED -> "cancelled"
+ else -> null
+ } ?: return@let
)
}
}
+
val json = webClient.httpGet(url).parseJson()
return json.getJSONObject("data").getJSONArray("items").mapJSON { item ->
item.toManga()
@@ -132,16 +217,13 @@ internal class MangaK(context: MangaLoaderContext) :
return Manga(
id = generateUid(getString("id")),
title = getString("name"),
- altTitles = emptySet(),
+ altTitles = parseAltTitles(),
url = getString("id"),
publicUrl = "https://$domain$relativeUrl",
rating = optDouble("rating", 0.0).toRating(),
- contentRating = null,
+ contentRating = optString("content_rating").nullIfEmpty().toContentRating(),
coverUrl = optString("cover").nullIfEmpty(),
- tags = optJSONArray("genres")?.mapJSONNotNullToSet { genre ->
- val slug = genre.optString("slug").nullIfEmpty() ?: return@mapJSONNotNullToSet null
- MangaTag(genre.getString("name").toTitleCase(sourceLocale), slug, source)
- }.orEmpty(),
+ tags = parseTags(),
state = optString("status").toMangaState(),
authors = emptySet(),
description = optString("summary").nullIfEmpty(),
@@ -149,6 +231,34 @@ internal class MangaK(context: MangaLoaderContext) :
)
}
+
+ private fun String?.toContentRating() = when (this) {
+ "safe" -> ContentRating.SAFE
+ "suggestive" -> ContentRating.SUGGESTIVE
+ "erotica", "pornographic" -> ContentRating.ADULT
+ else -> null
+ }
+
+
+ private fun JSONObject.parseAltTitles(): Set {
+ val result = mutableSetOf()
+ optJSONArray("alt_names")?.let { arr ->
+ for (i in 0 until arr.length()) {
+ arr.optJSONObject(i)?.optString("name")?.nullIfEmpty()?.let { result.add(it) }
+ }
+ }
+ optString("alt_name").nullIfEmpty()?.let { result.add(it) }
+ return result
+ }
+
+
+ private fun JSONObject.parseTags(): Set =
+ optJSONArray("genres")?.mapJSONNotNullToSet { genre ->
+ val slug = genre.optString("slug").nullIfEmpty() ?: return@mapJSONNotNullToSet null
+ MangaTag(genre.getString("name").toTitleCase(sourceLocale), slug, source)
+ }.orEmpty()
+
+
override suspend fun getDetails(manga: Manga): Manga {
val json = webClient.httpGet("$apiUrl/titles/${manga.url}").parseJson()
.getJSONObject("data").getJSONObject("title")
@@ -159,18 +269,11 @@ internal class MangaK(context: MangaLoaderContext) :
val artists = json.optJSONArray("artists")?.mapJSONNotNullToSet {
it.optString("name").nullIfEmpty()
}.orEmpty()
- val tags = json.optJSONArray("genres")?.mapJSONNotNullToSet { genre ->
- val slug = genre.optString("slug").nullIfEmpty() ?: return@mapJSONNotNullToSet null
- MangaTag(genre.getString("name").toTitleCase(sourceLocale), slug, source)
- }.orEmpty()
- val altTitles = json.optString("alt_name").split(";")
- .mapNotNull { it.trim().nullIfEmpty() }
- .toSet()
return manga.copy(
title = json.optString("name").nullIfEmpty() ?: manga.title,
- altTitles = altTitles,
+ altTitles = json.parseAltTitles().ifEmpty { manga.altTitles },
description = json.optString("summary").nullIfEmpty() ?: manga.description,
- tags = tags.ifEmpty { manga.tags },
+ tags = json.parseTags().ifEmpty { manga.tags },
authors = authors + artists,
state = json.optString("status").toMangaState() ?: manga.state,
rating = json.optDouble("rating", 0.0).toRating(),
@@ -178,6 +281,7 @@ internal class MangaK(context: MangaLoaderContext) :
)
}
+
private suspend fun fetchChapters(id: String, cv: Long): List {
val json = webClient.httpGet("$apiUrl/titles/$id/chapters?cv=$cv").parseJson()
val chapters = json.getJSONObject("data").getJSONArray("chapters").mapJSON { it }
@@ -198,6 +302,7 @@ internal class MangaK(context: MangaLoaderContext) :
}.reversed()
}
+
override suspend fun getPages(chapter: MangaChapter): List {
val doc = webClient.httpGet("https://$domain${chapter.url}").parseHtml()
val raw = doc.selectFirst("script#__NEXT_DATA__")?.data()
@@ -218,11 +323,18 @@ internal class MangaK(context: MangaLoaderContext) :
}
}
- private fun Double.toRating(): Float = if (this > 0.0) (this / 5.0).toFloat() else RATING_UNKNOWN
- private fun String?.toMangaState(): MangaState? = when (this?.lowercase(Locale.US)) {
+ private fun Double.toRating() = if (this > 0.0) (this / 5.0).toFloat() else RATING_UNKNOWN
+
+ private fun String?.toMangaState() = when (this?.lowercase(Locale.US)) {
"ongoing" -> MangaState.ONGOING
"completed" -> MangaState.FINISHED
+ "hiatus" -> MangaState.PAUSED
+ "cancelled" -> MangaState.ABANDONED
else -> null
}
-}
\ No newline at end of file
+
+ companion object {
+ private val IMAGE_FALLBACK_REGEX = Regex("rx\\.qvzr[a-z]\\.org")
+ }
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaKatana.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaKatana.kt
index 9293c4c5..a4b9072b 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaKatana.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaKatana.kt
@@ -8,7 +8,8 @@ import org.koitharu.kotatsu.parsers.core.PagedMangaParser
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.*
import java.text.SimpleDateFormat
-import java.util.*
+import java.util.EnumSet
+import java.util.Locale
private const val two = "?sv=mk"
private const val three = "?sv=3"
@@ -107,15 +108,18 @@ internal class MangaKatana(context: MangaLoaderContext):
order: SortOrder,
filter: MangaListFilter,
): String {
-
val query = filter.query?.trim().orEmpty()
val tags = filter.tags.map { it.key }.filter { it.isNotBlank() }
-
val status = filter.states.firstOrNull()
return when {
-
- query.isNotEmpty() -> "$baseUrl/page/$page?search=${query.urlEncoded()}&search_by=m_name"
+ query.isNotEmpty() -> {
+ if (page == 1) {
+ "$baseUrl/?search=${query.urlEncoded()}&search_by=m_name"
+ } else {
+ "$baseUrl/page/$page?search=${query.urlEncoded()}&search_by=m_name"
+ }
+ }
status != null -> {
"$baseUrl/genres/?" +
@@ -387,6 +391,6 @@ internal class MangaKatana(context: MangaLoaderContext):
}
.toList()
- return if (pages.isEmpty()) null else pages
+ return pages.ifEmpty { null }
}
}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaTown.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaTown.kt
new file mode 100644
index 00000000..2ecac3ad
--- /dev/null
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaTown.kt
@@ -0,0 +1,375 @@
+package org.koitharu.kotatsu.parsers.site.kotatsu.en
+
+import okhttp3.Headers
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import org.koitharu.kotatsu.parsers.MangaLoaderContext
+import org.koitharu.kotatsu.parsers.MangaSourceParser
+import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.parsers.core.PagedMangaParser
+import org.koitharu.kotatsu.parsers.model.*
+import org.koitharu.kotatsu.parsers.util.*
+import java.text.SimpleDateFormat
+import java.util.*
+
+@MangaSourceParser("MANGATOWN", "MangaTown", "en")
+internal class MangaTown(context: MangaLoaderContext) :
+ PagedMangaParser(context, MangaParserSource.MANGATOWN, 30) {
+
+ override val configKeyDomain = ConfigKey.Domain("www.mangatown.com")
+
+ override fun onCreateConfig(keys: MutableCollection>) {
+ super.onCreateConfig(keys)
+ keys.add(userAgentKey)
+ }
+
+ override fun getRequestHeaders(): Headers = Headers.Builder()
+ .add("Referer", "https://$domain/")
+ .build()
+
+ override val availableSortOrders: Set = EnumSet.of(
+ SortOrder.ALPHABETICAL,
+ SortOrder.RATING,
+ SortOrder.POPULARITY,
+ SortOrder.UPDATED,
+ )
+
+ override val filterCapabilities: MangaListFilterCapabilities
+ get() = MangaListFilterCapabilities(
+ isSearchSupported = true,
+ isSearchWithFiltersSupported = true,
+ isMultipleTagsSupported = true,
+ isTagsExclusionSupported = true,
+ isYearSupported = false,
+ )
+
+
+ @Volatile
+ private var allTagsCache: List? = null
+
+ private suspend fun getAllTags(): List {
+ if (allTagsCache == null) {
+ allTagsCache = fetchAvailableTags().toList()
+ }
+ return allTagsCache!!
+ }
+
+ override suspend fun getFilterOptions() = MangaListFilterOptions(
+ availableTags = getAllTags().toSet(),
+ availableStates = EnumSet.of(
+ MangaState.ONGOING,
+ MangaState.FINISHED,
+ ),
+ availableDemographics = EnumSet.of(
+ Demographic.SHOUJO,
+ Demographic.SEINEN,
+ Demographic.SHOUNEN,
+ Demographic.JOSEI,
+ ),
+ availableContentTypes = EnumSet.of(
+ ContentType.MANGA,
+ ContentType.MANHUA,
+ ContentType.MANHWA,
+ ),
+ )
+
+
+ override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ val query = filter.query?.trim()
+ val hasFilters = filter.types.isNotEmpty() || filter.demographics.isNotEmpty() ||
+ filter.tags.isNotEmpty() || filter.tagsExclude.isNotEmpty() ||
+ filter.states.isNotEmpty()
+
+ val url = when {
+ hasFilters -> {
+ val builder = "https://$domain/search".toHttpUrl().newBuilder()
+ .addQueryParameter("name_method", "cw")
+ .addQueryParameter("name", query ?: "")
+ .addQueryParameter("author_method", "cw")
+ .addQueryParameter("author", "")
+ .addQueryParameter("artist_method", "cw")
+ .addQueryParameter("artist", "")
+
+ builder.addQueryParameter("type",
+ if (filter.types.size == 1) when (filter.types.first()) {
+ ContentType.MANGA -> "manga"
+ ContentType.MANHWA -> "manhwa"
+ ContentType.MANHUA -> "manhua"
+ else -> ""
+ } else ""
+ )
+
+ builder.addQueryParameter("demographic",
+ if (filter.demographics.size == 1) when (filter.demographics.first()) {
+ Demographic.SEINEN -> "SEINEN"
+ Demographic.SHOUJO -> "SHOUJO"
+ Demographic.SHOUNEN -> "SHOUNEN"
+ Demographic.JOSEI -> "JOSEI"
+ else -> ""
+ } else ""
+ )
+
+ if (filter.states.size == 1) {
+ when (filter.states.first()) {
+ MangaState.FINISHED -> builder.addQueryParameter("is_completed", "1")
+ MangaState.ONGOING -> builder.addQueryParameter("is_completed", "0")
+ else -> builder.addQueryParameter("is_completed", "")
+ }
+ } else {
+ builder.addQueryParameter("is_completed", "")
+ }
+
+ val allTags = getAllTags()
+ val includedKeys = filter.tags.map { it.key }.toSet()
+ val excludedKeys = filter.tagsExclude.map { it.key }.toSet()
+ for (tag in allTags) {
+ val value = when (tag.key) {
+ in includedKeys -> "1"
+ in excludedKeys -> "2"
+ else -> "0"
+ }
+ builder.addQueryParameter("genres[${tag.title}]", value)
+ }
+
+ builder.addQueryParameter("released_method", "eq")
+ builder.addQueryParameter("released", "")
+ builder.addQueryParameter("rating_method", "eq")
+ builder.addQueryParameter("rating", "")
+ builder.addQueryParameter("advopts", "1")
+ builder.addQueryParameter("page", page.toString())
+
+ builder.build().toString()
+ }
+ !query.isNullOrEmpty() -> {
+ // Simple search
+ "https://$domain/search?page=$page&name=${query.urlEncoded()}"
+ }
+ order == SortOrder.UPDATED -> {
+ "https://$domain/latest/$page.htm"
+ }
+ else -> {
+ // Directory browse
+ buildString {
+ append("https://$domain/directory/")
+ append("0-")
+ append(
+ if (filter.tags.isNotEmpty()) filter.tags.oneOrThrowIfMany()?.key ?: "0"
+ else "0"
+ )
+ append("-0-")
+ append(
+ if (filter.states.isNotEmpty()) {
+ filter.states.oneOrThrowIfMany()?.let {
+ when (it) {
+ MangaState.ONGOING -> "ongoing"
+ MangaState.FINISHED -> "completed"
+ else -> "0"
+ }
+ } ?: "0"
+ } else "0"
+ )
+ append("-0-0/")
+ append(page).append(".htm")
+ append(
+ when (order) {
+ SortOrder.POPULARITY -> ""
+ SortOrder.ALPHABETICAL -> "?name.az"
+ SortOrder.RATING -> "?rating.za"
+ else -> "?last_chapter_time.za"
+ }
+ )
+ }
+ }
+ }
+
+ val doc = webClient.httpGet(url, getRequestHeaders()).parseHtml()
+ val root = doc.body().selectFirst("ul.manga_pic_list") ?: return emptyList()
+ val manga = root.select("li")
+ if (manga.isEmpty()) return emptyList()
+
+ return manga.mapNotNull { li ->
+ val a = li.selectFirst("a.manga_cover") ?: return@mapNotNull null
+ val href = a.attrAsRelativeUrl("href")
+ val views = li.select("p.view")
+ val status = views.firstNotNullOfOrNull { it.ownText().takeIf { x -> x.startsWith("Status:") } }
+ ?.substringAfter(':')?.trim()?.lowercase(Locale.ROOT)
+ val author = views.firstNotNullOfOrNull { it.text().takeIf { x -> x.startsWith("Author:") } }
+ ?.substringAfter(':')
+ ?.trim()
+ Manga(
+ id = generateUid(href),
+ title = a.attr("title"),
+ coverUrl = a.selectFirst("img")?.attrAsAbsoluteUrlOrNull("src"),
+ source = source,
+ altTitles = emptySet(),
+ rating = li.selectFirst("p.score")?.selectFirst("b")
+ ?.ownText()?.toFloatOrNull()?.div(5f) ?: RATING_UNKNOWN,
+ authors = setOfNotNull(author),
+ state = when (status) {
+ "ongoing" -> MangaState.ONGOING
+ "completed" -> MangaState.FINISHED
+ else -> null
+ },
+ tags = li.selectFirst("p.keyWord")?.select("a")?.mapToSet tags@{ x ->
+ MangaTag(
+ title = x.attr("title").toTitleCase(),
+ key = x.attr("href").substringAfter("/directory/0-").substringBefore("-0-"),
+ source = source,
+ )
+ }.orEmpty(),
+ url = href,
+ contentRating = null,
+ publicUrl = href.toAbsoluteUrl(a.host ?: domain),
+ )
+ }
+ }
+
+
+ override suspend fun getDetails(manga: Manga): Manga {
+ val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain), getRequestHeaders()).parseHtml()
+ val root = doc.body().selectFirst("section.main")
+ ?.selectFirst("div.article_content") ?: return manga
+ val info = root.selectFirst("div.detail_info")?.selectFirst("ul")
+ val chaptersList = root.selectFirst("div.chapter_content")
+ ?.selectFirst("ul.chapter_list")?.select("li")?.asReversed()
+ val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
+
+ if (root.select("div.chapter_content:contains(has been licensed)").isNotEmpty()) {
+ return manga.copy(state = MangaState.ABANDONED, chapters = bypassLicensedChapters(manga))
+ }
+
+ return manga.copy(
+ tags = manga.tags + (info?.select("li")?.find { x ->
+ x.selectFirst("b")?.ownText() == "Genre(s):"
+ }?.select("a")?.mapNotNull { a ->
+ MangaTag(
+ title = a.attr("title").toTitleCase(),
+ key = a.attr("href").substringAfter("/directory/0-").substringBefore("-0-"),
+ source = source,
+ )
+ }.orEmpty()),
+ description = info?.getElementById("show")?.ownText(),
+ chapters = chaptersList?.mapChapters { i, li ->
+ val linkEl = li.selectFirst("a") ?: return@mapChapters null
+ val href = linkEl.attrAsRelativeUrl("href")
+ val name = buildString {
+ append(linkEl.text())
+ li.select("span").filter { span ->
+ !span.hasClass("time") && !span.hasClass("new")
+ }.forEach { span ->
+ if (isNotEmpty()) append(" ")
+ append(span.text())
+ }
+ }
+ MangaChapter(
+ id = generateUid(href),
+ url = href,
+ source = source,
+ number = i + 1f,
+ volume = 0,
+ uploadDate = parseChapterDate(
+ dateFormat,
+ li.selectFirst("span.time")?.text(),
+ ),
+ title = name.nullIfEmpty(),
+ scanlator = null,
+ branch = null,
+ )
+ } ?: emptyList(),
+ )
+ }
+
+
+ private suspend fun bypassLicensedChapters(manga: Manga): List {
+ val subdomain = "m." + domain.removePrefix("www.")
+ val doc = webClient.httpGet(manga.url.toAbsoluteUrl(subdomain), getRequestHeaders()).parseHtml()
+ val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList()
+ val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
+ return list.select("li").asReversed().mapIndexedNotNull { i, li ->
+ val a = li.selectFirst("a") ?: return@mapIndexedNotNull null
+ val href = a.attrAsRelativeUrl("href")
+ val name = a.selectFirst("span.vol")?.text().orEmpty().ifEmpty { a.ownText() }
+ MangaChapter(
+ id = generateUid(href),
+ url = href,
+ source = source,
+ number = i + 1f,
+ volume = 0,
+ uploadDate = parseChapterDate(
+ dateFormat,
+ li.selectFirst("span.time")?.text(),
+ ),
+ title = name.nullIfEmpty(),
+ scanlator = null,
+ branch = null,
+ )
+ }
+ }
+
+
+ private fun parseChapterDate(dateFormat: SimpleDateFormat, date: String?): Long {
+ return when {
+ date.isNullOrEmpty() -> 0L
+ date.contains("Today") -> Calendar.getInstance().timeInMillis
+ date.contains("Yesterday") -> Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) }.timeInMillis
+ else -> dateFormat.parseSafe(date)
+ }
+ }
+
+
+ private suspend fun fetchAvailableTags(): Set {
+ val doc = webClient.httpGet("/directory/".toAbsoluteUrl(domain), getRequestHeaders()).parseHtml()
+ val root = doc.body().selectFirst("aside.right")
+ ?.getElementsContainingOwnText("Genres")
+ ?.first()
+ ?.nextElementSibling() ?: doc.parseFailed("Root not found")
+ return root.select("li").mapNotNullToSet { li ->
+ val a = li.selectFirst("a") ?: return@mapNotNullToSet null
+ val key = a.attr("href").substringAfter("/directory/0-").substringBefore("-0-")
+ MangaTag(
+ source = source,
+ key = key,
+ title = a.text().toTitleCase(),
+ )
+ }
+ }
+
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ val fullUrl = chapter.url.toAbsoluteUrl(domain)
+ val doc = webClient.httpGet(fullUrl, getRequestHeaders()).parseHtml()
+ val pageSelect = doc.body().selectFirst("div.page_select select") ?: return emptyList()
+ val options = pageSelect.select("option").filterNot { it.attr("value").endsWith("featured.html") }
+ if (options.isNotEmpty()) {
+ return options.mapIndexed { _, option ->
+ val href = option.attrAsRelativeUrl("value")
+ MangaPage(
+ id = generateUid(href),
+ url = href,
+ preview = null,
+ source = source,
+ )
+ }
+ }
+ val imgElements = doc.select("div#viewer img")
+ return imgElements.map { img ->
+ val src = img.attrAsAbsoluteUrl("src")
+ MangaPage(
+ id = generateUid(src),
+ url = src,
+ preview = null,
+ source = source,
+ )
+ }
+ }
+
+
+ override suspend fun getPageUrl(page: MangaPage): String {
+ if (page.url.startsWith("http")) return page.url
+ val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain), getRequestHeaders()).parseHtml()
+ return doc.selectFirst("div#viewer img")?.attrAsAbsoluteUrl("src")
+ ?: throw Exception("Could not find image")
+ }
+
+
+ private fun String.nullIfEmpty(): String? = ifEmpty { null }
+}
diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt
index 592dd4ce..1e74f249 100644
--- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt
+++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt
@@ -17,77 +17,72 @@ internal class Mangadotnet(context: MangaLoaderContext) :
PagedMangaParser(context, MangaParserSource.MANGADOTNET, 20) {
override val configKeyDomain = ConfigKey.Domain("mangadot.net")
-
private val baseUrl = "https://mangadot.net"
-
- override val filterCapabilities: MangaListFilterCapabilities
- get() = MangaListFilterCapabilities(
- isSearchSupported = true,
- )
-
- override val availableSortOrders: Set = EnumSet.of(
- SortOrder.UPDATED,
- SortOrder.POPULARITY,
- SortOrder.NEWEST,
- SortOrder.ALPHABETICAL,
- )
-
- override suspend fun getFilterOptions() = MangaListFilterOptions(
- availableTags = cachedGenres.mapTo(LinkedHashSet(cachedGenres.size)) { MangaTag(key = it, title = it, source = source) },
- )
-
- private var cachedGenres: List = emptyList()
-
+ private val apiBase = "$baseUrl/api"
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
- // region RSC decoder
+ private val showAdultKey = ConfigKey.ShowSuspiciousContent(false)
+
+ private val genreNames = setOf(
+ "Josei", "Seinen", "Shoujo", "Shounen",
+ "Action", "Adventure", "Comedy", "Drama", "Fantasy",
+ "Historical", "Horror", "Mecha", "Mystery", "Psychological",
+ "Romance", "Sci-Fi", "Slice of Life", "Sports", "Supernatural", "Thriller", "Tragedy",
+ "Cooking", "Demons", "Ecchi", "Harem", "Isekai", "Magic", "Martial Arts",
+ "Medical", "Military", "Music", "School Life", "Webtoon",
+ "Academy", "Acting", "Adult", "Aliens", "Animals", "Anthology", "Apocalypse",
+ "Avant Garde", "Award Winning", "BDSM", "Boys Love", "Bully", "Business",
+ "Child Abuse", "Child Neglect", "Comic", "Crime", "crossdressing", "Crossdressing",
+ "Cultivation", "Delinquents", "Difficult Childhood", "dojinshi", "Doujinshi",
+ "Erotica", "Female Protagonist", "Femdom", "Fight", "futanari on male", "futunari",
+ "Gender Bender", "Genderswap", "Ghosts", "Girls Love", "Gore", "Gourmet",
+ "Gyaru", "Hentai", "Hunters", "Idol", "Incest", "Loli", "Lolicon", "Mafia",
+ "Magical Girls", "Mahou Shoujo", "Manga", "Manhua", "Manhwa", "Mature",
+ "Medieval Area", "Monster Girls", "Monsters", "Ninja", "Nobility",
+ "Office Romance", "Office Worker", "Office Workers", "One Shot", "Otome",
+ "Overpowered", "Philosophical", "playboy", "Police", "Post-Apocalyptic",
+ "Reincarnation", "Revenge", "Reverse Harem", "Royalty", "Samurai", "School",
+ "Seinin", "Shota", "Shotacon", "Shoujo Ai", "Shounen Ai", "Smut", "Superhero",
+ "Survival", "Suspense", "System", "Time Travel", "Traditional Games", "uncensored",
+ "Vampires", "Video Games", "Villainess", "Virtual Reality", "War", "Workplace",
+ "Wuxia", "Yaoi", "Yuri", "Zombies"
+ )
+
+ override fun onCreateConfig(keys: MutableCollection>) {
+ super.onCreateConfig(keys)
+ keys.add(userAgentKey)
+ keys.add(showAdultKey)
+ }
private fun decodeRsc(flat: JSONArray): Any? {
val cache = arrayOfNulls(flat.length())
- val nil = Object()
-
+ val nil = Any()
fun resolve(i: Int): Any? {
if (i < 0 || i >= flat.length()) return null
val cached = cache[i]
if (cached != null) return if (cached === nil) null else cached
-
val result: Any? = when (val el = flat.opt(i)) {
JSONObject.NULL -> null
is String, is Number, is Boolean -> el
- is JSONArray -> {
- (0 until el.length()).mapTo(mutableListOf()) { j ->
- resolve(el.optInt(j, -1))
- }
- }
+ is JSONArray -> (0 until el.length()).mapTo(mutableListOf()) { j -> resolve(el.optInt(j, -1)) }
is JSONObject -> {
val map = mutableMapOf()
for (key in el.keys()) {
- val actualKey = if (key.startsWith("_")) {
- flat.optString(key.substring(1).toInt(), key)
- } else {
- key
- }
+ val actualKey = if (key.startsWith("_")) flat.optString(key.removePrefix("_").toInt(), key) else key
map[actualKey] = resolve(el.optInt(key, -1))
}
map
}
else -> null
}
-
cache[i] = result ?: nil
return result
}
-
return resolve(0)
}
- /**
- * Fetch an RSC-encoded URL and extract the given route.
- * Route values are wrapped in `{"data": ...}` (Data).
- * Returns the inner `data` value of the route.
- */
@Suppress("UNCHECKED_CAST")
private suspend fun fetchRscData(url: String, route: String): Map? {
val flat = webClient.httpGet(url).parseJsonArray()
@@ -96,101 +91,118 @@ internal class Mangadotnet(context: MangaLoaderContext) :
return routeValue.asMap("data")
}
- // endregion
+ override val filterCapabilities = MangaListFilterCapabilities(
+ isSearchSupported = true,
+ isMultipleTagsSupported = true,
+ isTagsExclusionSupported = true,
+ isSearchWithFiltersSupported = true,
+ )
- // region Listing
+ override val availableSortOrders: Set = EnumSet.of(
+ SortOrder.UPDATED, // latest
+ SortOrder.POPULARITY, // most viewed
+ SortOrder.RATING, // top rated
+ SortOrder.ALPHABETICAL, // a-z
+ SortOrder.RELEVANCE, // most tracked
+ )
- override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
- val query = filter.query
- if (!query.isNullOrEmpty()) {
- return getSearchPage(page, query, order)
- }
- return when (order) {
- SortOrder.POPULARITY -> getPopularPage(page)
- else -> getLatestPage(page)
- }
+ override suspend fun getFilterOptions(): MangaListFilterOptions {
+ val tags = genreNames.map { name -> MangaTag(name, name, source) }.toSet()
+ return MangaListFilterOptions(
+ availableTags = tags,
+ availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED),
+ availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER),
+ availableDemographics = EnumSet.of(Demographic.JOSEI, Demographic.SEINEN, Demographic.SHOUJO, Demographic.SHOUNEN),
+ )
}
- private suspend fun getPopularPage(page: Int): List {
- val url = "$baseUrl/view-all/trending.data".toHttpUrl().newBuilder().apply {
- if (page > 1) addQueryParameter("page", page.toString())
- addQueryParameter("_routes", "pages/ViewAllPage")
- }.build().toString()
- return parseViewAllPage(url)
+ override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ if (!filter.query.isNullOrBlank() || filter.tags.isNotEmpty() || filter.tagsExclude.isNotEmpty() ||
+ filter.states.isNotEmpty() || filter.types.isNotEmpty() || filter.demographics.isNotEmpty()) {
+ return getSearchPage(page, order, filter)
+ }
+ return getBrowsePage(page, order)
}
- private suspend fun getLatestPage(page: Int): List {
+ private suspend fun getBrowsePage(page: Int, order: SortOrder): List {
+ val sortParam = when (order) {
+ SortOrder.POPULARITY -> "views"
+ SortOrder.RATING -> "rating"
+ SortOrder.ALPHABETICAL -> "alphabetical"
+ SortOrder.RELEVANCE -> "tracked"
+ else -> null
+ }
+ val adultParam = if (config[showAdultKey]) "both" else "0"
val url = "$baseUrl/view-all/latest-updates.data".toHttpUrl().newBuilder().apply {
+ addQueryParameter("adult", adultParam)
if (page > 1) addQueryParameter("page", page.toString())
+ sortParam?.let { addQueryParameter("sortBy", it) }
addQueryParameter("_routes", "pages/ViewAllPage")
}.build().toString()
return parseViewAllPage(url)
}
- /**
- * ViewAllPage route structure:
- * route_value = { "data": ViewAllData }
- * ViewAllData = { "data": MangaList, "allGenres": [...] }
- * MangaList = { "manga_list"|"results": [...], "pagination": {...} }
- *
- * After fetchRscData: we get ViewAllData.
- */
- @Suppress("UNCHECKED_CAST")
- private suspend fun parseViewAllPage(url: String): List {
- val viewAllData = fetchRscData(url, "pages/ViewAllPage") ?: return emptyList()
+ private suspend fun getSearchPage(page: Int, order: SortOrder, filter: MangaListFilter): List {
+ val adultParam = if (config[showAdultKey]) "both" else "0"
+ val url = "$baseUrl/search.data".toHttpUrl().newBuilder().apply {
+ addQueryParameter("adult", adultParam)
+ if (!filter.query.isNullOrBlank()) addQueryParameter("search", filter.query!!)
+ addQueryParameter("page", page.toString())
+ addQueryParameter("perPage", "56")
+
+ val (sortBy, sortOrder) = when (order) {
+ SortOrder.UPDATED -> "latest" to "desc"
+ SortOrder.ALPHABETICAL -> "alphabetical" to "asc"
+ SortOrder.POPULARITY -> "views" to "desc"
+ SortOrder.RATING -> "rating" to "desc"
+ else -> "relevance" to "desc"
+ }
+ addQueryParameter("sortBy", sortBy)
+ addQueryParameter("sortOrder", sortOrder)
- // Update genres
- (viewAllData["allGenres"] as? List<*>)?.filterIsInstance()?.let {
- if (it.isNotEmpty()) cachedGenres = it
- }
+ if (filter.states.size == 1) {
+ addQueryParameter("status", filter.states.first().toApiStatus())
+ }
- // Navigate to MangaList: viewAllData.data
- val mangaListMap = viewAllData.asMap("data") ?: return emptyList()
- return parseMangaList(mangaListMap)
- }
+ filter.types.forEach { ct ->
+ when (ct) {
+ ContentType.MANGA -> addQueryParameter("origin", "JP")
+ ContentType.MANHWA -> addQueryParameter("origin", "KR")
+ ContentType.MANHUA -> addQueryParameter("origin", "CN")
+ ContentType.OTHER -> addQueryParameter("origin", "ONESHOT")
+ else -> {}
+ }
+ }
+
+ filter.demographics.forEach { demographic ->
+ addQueryParameter("genre", demographic.name.lowercase().replaceFirstChar { it.uppercase() })
+ }
+
+ filter.tags.forEach { tag -> addQueryParameter("genre", tag.key) }
+ filter.tagsExclude.forEach { tag -> addQueryParameter("genre", "-${tag.key}") }
- private suspend fun getSearchPage(page: Int, query: String, order: SortOrder): List {
- val url = "$baseUrl/search.data".toHttpUrl().newBuilder().apply {
- addQueryParameter("search", query)
- addQueryParameter("page", page.toString())
- addQueryParameter("sortBy", when (order) {
- SortOrder.NEWEST -> "latest"
- SortOrder.ALPHABETICAL -> "Alphabetical"
- else -> "relevance"
- })
addQueryParameter("_routes", "pages/SearchPage")
}.build().toString()
- /**
- * SearchPage route structure:
- * route_value = { "data": MangaList }
- * MangaList = { "manga_list"|"results": [...], "pagination": {...}, "allGenres": [...] }
- *
- * After fetchRscData: we get MangaList directly.
- */
- @Suppress("UNCHECKED_CAST")
- val mangaListMap = fetchRscData(url, "pages/SearchPage") ?: return emptyList()
-
- (mangaListMap["allGenres"] as? List<*>)?.filterIsInstance()?.let {
- if (it.isNotEmpty()) cachedGenres = it
- }
+ val dataMap = fetchRscData(url, "pages/SearchPage") ?: return emptyList()
+ return parseMangaList(dataMap)
+ }
+ private suspend fun parseViewAllPage(url: String): List {
+ val viewAllData = fetchRscData(url, "pages/ViewAllPage") ?: return emptyList()
+ val mangaListMap = viewAllData.asMap("data") ?: return emptyList()
return parseMangaList(mangaListMap)
}
- @Suppress("UNCHECKED_CAST")
- private fun parseMangaList(mangaListMap: Map): List {
- val mangaList = mangaListMap["manga_list"] as? List<*>
- ?: mangaListMap["results"] as? List<*>
- ?: return emptyList()
- return mangaList.filterIsInstance