From 5f941138cd80251cfb522e136468fbb57f585041 Mon Sep 17 00:00:00 2001 From: biglag Date: Fri, 25 Sep 2026 13:34:00 +0800 Subject: [PATCH 01/33] Fix AComics, MangaLib, and InkStory reading --- .gitignore | 5 + .../kotlin/tsuki/parsers/LibSocialParser.kt | 4 +- src/main/kotlin/tsuki/site/ru/AComics.kt | 5 +- src/main/kotlin/tsuki/site/ru/MangaLib.kt | 2 + src/main/kotlin/tsuki/site/ru/MangaWtf.kt | 7 +- .../tsuki/site/ru/SourceRegressionTest.kt | 153 ++++++++++++++++++ 6 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 src/test/kotlin/tsuki/site/ru/SourceRegressionTest.kt diff --git a/.gitignore b/.gitignore index a75af06e..7a59e445 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,11 @@ fabric.properties build/ bin/ +# Local agent and specification workspaces +/.codex/ +/openspec/ +/.worktrees/ + .idea/**/misc.xml .idea/**/vcs.xml .idea/**/ktlint.xml diff --git a/src/main/kotlin/tsuki/parsers/LibSocialParser.kt b/src/main/kotlin/tsuki/parsers/LibSocialParser.kt index d197bf81..5a4adbbf 100644 --- a/src/main/kotlin/tsuki/parsers/LibSocialParser.kt +++ b/src/main/kotlin/tsuki/parsers/LibSocialParser.kt @@ -69,7 +69,7 @@ internal abstract class LibSocialParser( siteDomains: String, ) : PagedMangaParser(context, source, pageSize = 60), MangaParserAuthProvider { - protected val apiHost = "api.cdnlibs.org" + protected open val apiHost = "api.cdnlibs.org" override val userAgentKey = ConfigKey.UserAgent(UserAgents.CHROME_MOBILE) @@ -257,7 +257,7 @@ internal abstract class LibSocialParser( val json = pages.await() val primaryServer = getPrimaryImageServer(servers) json.getJSONArray("pages").mapJSON { jo -> - val url = jo.getString("url") + val url = "/" + jo.getString("url").trimStart('/') MangaPage( id = generateUid(jo.getLong("id")), url = concatUrl(primaryServer, url), diff --git a/src/main/kotlin/tsuki/site/ru/AComics.kt b/src/main/kotlin/tsuki/site/ru/AComics.kt index 666651d5..27d4cc03 100644 --- a/src/main/kotlin/tsuki/site/ru/AComics.kt +++ b/src/main/kotlin/tsuki/site/ru/AComics.kt @@ -190,9 +190,8 @@ internal class AComics(context: MangaLoaderContext) : override suspend fun getPages(chapter: MangaChapter): List { val doc = webClient.httpGet(chapter.url + "1").parseHtml() - val totalPages = doc.selectFirstOrThrow("h1.reader-issue-title span.number") - .text() - .substringAfterLast('/') + val totalPages = doc.selectFirstOrThrow("nav.reader-navigator[data-issue-count]") + .attr("data-issue-count") .toInt() return (1..totalPages).map { val url = chapter.url + it diff --git a/src/main/kotlin/tsuki/site/ru/MangaLib.kt b/src/main/kotlin/tsuki/site/ru/MangaLib.kt index b7526e02..31842d2d 100644 --- a/src/main/kotlin/tsuki/site/ru/MangaLib.kt +++ b/src/main/kotlin/tsuki/site/ru/MangaLib.kt @@ -15,6 +15,8 @@ import tsuki.model.MangaParserSource internal class MangaLib(context: MangaLoaderContext, ) : LibSocialParser(context, MangaParserSource.MANGALIB, 1, "mangalib.org") { + override val apiHost = "api2.mangalib.me" + override val configKeyDomain = ConfigKey.Domain("mangalib.org", "mangalib.me") override suspend fun getPages(chapter: MangaChapter): List = try { diff --git a/src/main/kotlin/tsuki/site/ru/MangaWtf.kt b/src/main/kotlin/tsuki/site/ru/MangaWtf.kt index 6c035da8..6d9c7828 100644 --- a/src/main/kotlin/tsuki/site/ru/MangaWtf.kt +++ b/src/main/kotlin/tsuki/site/ru/MangaWtf.kt @@ -35,6 +35,7 @@ import tsuki.util.urlBuilder import androidx.collection.ArrayMap import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import okhttp3.HttpUrl.Companion.toHttpUrl import org.json.JSONObject import java.text.SimpleDateFormat import java.util.EnumSet @@ -185,7 +186,11 @@ internal class MangaWtf(context: MangaLoaderContext) : return json.getJSONArray("pages").mapJSON { jo -> MangaPage( id = generateUid(jo.getString("id")), - url = jo.getString("image"), + url = jo.getString("image").toHttpUrl().newBuilder() + .setQueryParameter("format", "webp") + .setQueryParameter("width", "1600") + .build() + .toString(), preview = null, source = source, ) diff --git a/src/test/kotlin/tsuki/site/ru/SourceRegressionTest.kt b/src/test/kotlin/tsuki/site/ru/SourceRegressionTest.kt new file mode 100644 index 00000000..e8354bf8 --- /dev/null +++ b/src/test/kotlin/tsuki/site/ru/SourceRegressionTest.kt @@ -0,0 +1,153 @@ +package tsuki.site.ru + +import kotlinx.coroutines.runBlocking +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tsuki.MangaLoaderContext +import tsuki.MangaParser +import tsuki.newParser +import tsuki.bitmap.Bitmap +import tsuki.bitmap.Rect +import tsuki.config.ConfigKey +import tsuki.config.MangaSourceConfig +import tsuki.model.MangaChapter +import tsuki.model.MangaListFilter +import tsuki.model.MangaParserSource +import tsuki.model.MangaSource +import tsuki.model.SortOrder + +internal class SourceRegressionTest { + + @Test + fun `AComics reads the current issue count and image`() = runBlocking { + val context = FixtureContext { request -> + assertEquals("https://acomics.ru/~my-mom-is-strange/1", request.url.toString()) + """ +

1/97

+
""" + } + val parser = AComics(context) + val pages = parser.getPages(chapter("https://acomics.ru/~my-mom-is-strange/", MangaParserSource.ACOMICS)) + + assertEquals(97, pages.size) + assertEquals("https://acomics.ru/~my-mom-is-strange/97", pages.last().url) + assertEquals("https://acomics.ru/upload/test.png", parser.getPageUrl(pages.first())) + } + + @Test + fun `MangaLib catalog uses its working API host`() = runBlocking { + val context = FixtureContext { request -> + assertEquals("api2.mangalib.me", request.url.host) + assertEquals("/api/manga", request.url.encodedPath) + """{"data":[{"id":1,"rus_name":"Test manga","name":"Original title", + "slug_url":"test-manga","cover":{"thumbnail":"https://example.org/thumb.jpg", + "default":"https://example.org/cover.jpg"},"status":{"id":1}}]}""" + } + + val manga = MangaLib(context).getList(0, SortOrder.UPDATED, MangaListFilter.EMPTY) + + assertEquals(1, manga.size) + assertEquals("Test manga", manga.single().title) + } + + @Test + fun `MangaLib reader resolves pages through the same API host`() = runBlocking { + val context = FixtureContext { request -> + assertEquals("api2.mangalib.me", request.url.host) + when (request.url.encodedPath) { + "/api/manga/test-manga/chapter" -> + """{"data":{"pages":[{"id":42,"url":"//manga/test-manga/chapter/page.png"}]}}""" + + "/api/constants" -> + """{"data":{"imageServers":[{"id":"main","url":"https://img2.imglib.info", + "site_ids":[1]},{"id":"compress","url":"https://img3.cdnlibs.org", + "site_ids":[1]}]}}""" + + else -> error("Unexpected MangaLib request: ${request.url}") + } + } + + val pages = MangaLib(context).getPages( + chapter("test-manga/chapter?number=1&volume=1", MangaParserSource.MANGALIB), + ) + + assertEquals(1, pages.size) + assertEquals("https://img2.imglib.info/manga/test-manga/chapter/page.png", pages.single().url) + } + + @Test + fun `InkStory requests a concrete image format for reader pages`() = runBlocking { + val context = FixtureContext { request -> + assertEquals("https://api.inkstory.net/v2/chapters/chapter-1", request.url.toString()) + """{"pages":[{"id":"page-1","image":"https://gstatic.inuko.me/book/example/page.jpeg?format=webp"}, + {"id":"page-2","image":"https://gstatic.inuko.me/book/example/next.jpeg?width=320"}]}""" + } + + val pages = MangaWtf(context).getPages(chapter("chapter-1", MangaParserSource.MANGAWTF)) + + assertEquals("https://gstatic.inuko.me/book/example/page.jpeg?format=webp&width=1600", pages[0].url) + assertEquals("https://gstatic.inuko.me/book/example/next.jpeg?format=webp&width=1600", pages[1].url) + } + + private fun chapter(url: String, source: MangaParserSource) = MangaChapter( + id = 1L, + title = null, + number = 1f, + volume = 0, + url = url, + scanlator = null, + uploadDate = 0L, + branch = null, + source = source, + ) +} + +@Suppress("OVERRIDE_DEPRECATION") +private class FixtureContext(private val bodyFor: (Request) -> String) : MangaLoaderContext() { + override val httpClient = OkHttpClient.Builder().addInterceptor(Interceptor { chain -> + val request = chain.request() + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(bodyFor(request).toResponseBody("text/html; charset=utf-8".toMediaType())) + .build() + }).build() + + override val cookieJar: CookieJar = object : CookieJar { + override fun saveFromResponse(url: HttpUrl, cookies: List) = Unit + override fun loadForRequest(url: HttpUrl): List = emptyList() + } + + override fun newParserInstance(source: MangaSource): MangaParser = + (source as MangaParserSource).newParser(this) + + override fun getParserSources(): List = emptyList() + override suspend fun evaluateJs(script: String): String? = error("JavaScript is not used by these fixtures") + override suspend fun evaluateJs(baseUrl: String, script: String): String? = error("JavaScript is not used by these fixtures") + override fun getConfig(source: MangaSource): MangaSourceConfig = object : MangaSourceConfig { + override fun get(key: ConfigKey): T = key.defaultValue + } + + override fun getDefaultUserAgent(): String = "UMA source regression test" + override fun redrawImageResponse(response: Response, redraw: (Bitmap) -> Bitmap): Response = + error("Image redraw is not used by these fixtures") + + override fun createBitmap(width: Int, height: Int): Bitmap = object : Bitmap { + override val width = width + override val height = height + override fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect) = + error("Bitmap drawing is not used by these fixtures") + } +} From 040970fa6b21b2ff095cf53564f557ddfcdfb003 Mon Sep 17 00:00:00 2001 From: biglag Date: Fri, 25 Sep 2026 13:36:40 +0800 Subject: [PATCH 02/33] cleanup --- .gitignore | 4 - .worktrees/add-russian-sources | 1 + .../tsuki/site/ru/SourceRegressionTest.kt | 153 ------------------ 3 files changed, 1 insertion(+), 157 deletions(-) create mode 160000 .worktrees/add-russian-sources delete mode 100644 src/test/kotlin/tsuki/site/ru/SourceRegressionTest.kt diff --git a/.gitignore b/.gitignore index 7a59e445..80235f8c 100644 --- a/.gitignore +++ b/.gitignore @@ -76,10 +76,6 @@ fabric.properties build/ bin/ -# Local agent and specification workspaces -/.codex/ -/openspec/ -/.worktrees/ .idea/**/misc.xml .idea/**/vcs.xml diff --git a/.worktrees/add-russian-sources b/.worktrees/add-russian-sources new file mode 160000 index 00000000..46275afb --- /dev/null +++ b/.worktrees/add-russian-sources @@ -0,0 +1 @@ +Subproject commit 46275afb0aede5ae0e27c683748ef85c96e68636 diff --git a/src/test/kotlin/tsuki/site/ru/SourceRegressionTest.kt b/src/test/kotlin/tsuki/site/ru/SourceRegressionTest.kt deleted file mode 100644 index e8354bf8..00000000 --- a/src/test/kotlin/tsuki/site/ru/SourceRegressionTest.kt +++ /dev/null @@ -1,153 +0,0 @@ -package tsuki.site.ru - -import kotlinx.coroutines.runBlocking -import okhttp3.Cookie -import okhttp3.CookieJar -import okhttp3.HttpUrl -import okhttp3.Interceptor -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody.Companion.toResponseBody -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import tsuki.MangaLoaderContext -import tsuki.MangaParser -import tsuki.newParser -import tsuki.bitmap.Bitmap -import tsuki.bitmap.Rect -import tsuki.config.ConfigKey -import tsuki.config.MangaSourceConfig -import tsuki.model.MangaChapter -import tsuki.model.MangaListFilter -import tsuki.model.MangaParserSource -import tsuki.model.MangaSource -import tsuki.model.SortOrder - -internal class SourceRegressionTest { - - @Test - fun `AComics reads the current issue count and image`() = runBlocking { - val context = FixtureContext { request -> - assertEquals("https://acomics.ru/~my-mom-is-strange/1", request.url.toString()) - """ -

1/97

-
""" - } - val parser = AComics(context) - val pages = parser.getPages(chapter("https://acomics.ru/~my-mom-is-strange/", MangaParserSource.ACOMICS)) - - assertEquals(97, pages.size) - assertEquals("https://acomics.ru/~my-mom-is-strange/97", pages.last().url) - assertEquals("https://acomics.ru/upload/test.png", parser.getPageUrl(pages.first())) - } - - @Test - fun `MangaLib catalog uses its working API host`() = runBlocking { - val context = FixtureContext { request -> - assertEquals("api2.mangalib.me", request.url.host) - assertEquals("/api/manga", request.url.encodedPath) - """{"data":[{"id":1,"rus_name":"Test manga","name":"Original title", - "slug_url":"test-manga","cover":{"thumbnail":"https://example.org/thumb.jpg", - "default":"https://example.org/cover.jpg"},"status":{"id":1}}]}""" - } - - val manga = MangaLib(context).getList(0, SortOrder.UPDATED, MangaListFilter.EMPTY) - - assertEquals(1, manga.size) - assertEquals("Test manga", manga.single().title) - } - - @Test - fun `MangaLib reader resolves pages through the same API host`() = runBlocking { - val context = FixtureContext { request -> - assertEquals("api2.mangalib.me", request.url.host) - when (request.url.encodedPath) { - "/api/manga/test-manga/chapter" -> - """{"data":{"pages":[{"id":42,"url":"//manga/test-manga/chapter/page.png"}]}}""" - - "/api/constants" -> - """{"data":{"imageServers":[{"id":"main","url":"https://img2.imglib.info", - "site_ids":[1]},{"id":"compress","url":"https://img3.cdnlibs.org", - "site_ids":[1]}]}}""" - - else -> error("Unexpected MangaLib request: ${request.url}") - } - } - - val pages = MangaLib(context).getPages( - chapter("test-manga/chapter?number=1&volume=1", MangaParserSource.MANGALIB), - ) - - assertEquals(1, pages.size) - assertEquals("https://img2.imglib.info/manga/test-manga/chapter/page.png", pages.single().url) - } - - @Test - fun `InkStory requests a concrete image format for reader pages`() = runBlocking { - val context = FixtureContext { request -> - assertEquals("https://api.inkstory.net/v2/chapters/chapter-1", request.url.toString()) - """{"pages":[{"id":"page-1","image":"https://gstatic.inuko.me/book/example/page.jpeg?format=webp"}, - {"id":"page-2","image":"https://gstatic.inuko.me/book/example/next.jpeg?width=320"}]}""" - } - - val pages = MangaWtf(context).getPages(chapter("chapter-1", MangaParserSource.MANGAWTF)) - - assertEquals("https://gstatic.inuko.me/book/example/page.jpeg?format=webp&width=1600", pages[0].url) - assertEquals("https://gstatic.inuko.me/book/example/next.jpeg?format=webp&width=1600", pages[1].url) - } - - private fun chapter(url: String, source: MangaParserSource) = MangaChapter( - id = 1L, - title = null, - number = 1f, - volume = 0, - url = url, - scanlator = null, - uploadDate = 0L, - branch = null, - source = source, - ) -} - -@Suppress("OVERRIDE_DEPRECATION") -private class FixtureContext(private val bodyFor: (Request) -> String) : MangaLoaderContext() { - override val httpClient = OkHttpClient.Builder().addInterceptor(Interceptor { chain -> - val request = chain.request() - Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(200) - .message("OK") - .body(bodyFor(request).toResponseBody("text/html; charset=utf-8".toMediaType())) - .build() - }).build() - - override val cookieJar: CookieJar = object : CookieJar { - override fun saveFromResponse(url: HttpUrl, cookies: List) = Unit - override fun loadForRequest(url: HttpUrl): List = emptyList() - } - - override fun newParserInstance(source: MangaSource): MangaParser = - (source as MangaParserSource).newParser(this) - - override fun getParserSources(): List = emptyList() - override suspend fun evaluateJs(script: String): String? = error("JavaScript is not used by these fixtures") - override suspend fun evaluateJs(baseUrl: String, script: String): String? = error("JavaScript is not used by these fixtures") - override fun getConfig(source: MangaSource): MangaSourceConfig = object : MangaSourceConfig { - override fun get(key: ConfigKey): T = key.defaultValue - } - - override fun getDefaultUserAgent(): String = "UMA source regression test" - override fun redrawImageResponse(response: Response, redraw: (Bitmap) -> Bitmap): Response = - error("Image redraw is not used by these fixtures") - - override fun createBitmap(width: Int, height: Int): Bitmap = object : Bitmap { - override val width = width - override val height = height - override fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect) = - error("Bitmap drawing is not used by these fixtures") - } -} From 7bfb0c066cab7f88152f2e8b684755cf23e34b5d Mon Sep 17 00:00:00 2001 From: biglag Date: Fri, 25 Sep 2026 13:37:41 +0800 Subject: [PATCH 03/33] remove local worktree --- .worktrees/add-russian-sources | 1 - 1 file changed, 1 deletion(-) delete mode 160000 .worktrees/add-russian-sources diff --git a/.worktrees/add-russian-sources b/.worktrees/add-russian-sources deleted file mode 160000 index 46275afb..00000000 --- a/.worktrees/add-russian-sources +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 46275afb0aede5ae0e27c683748ef85c96e68636 From dd0da338d0f2d2599cbfb2ea458ccad3803a7322 Mon Sep 17 00:00:00 2001 From: Abdelrahman Date: Sat, 26 Sep 2026 16:13:00 +0300 Subject: [PATCH 04/33] fix stuck homepage for Manga18fx it doesn't load new entries when scrolling down, this fixes it --- src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt b/src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt index d31cd509..7238d9e9 100644 --- a/src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt +++ b/src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt @@ -88,7 +88,7 @@ internal class Manga18fx(context: MangaLoaderContext) : parsePopular(doc) } else -> { - val url = "https://$domain/page/$page" + val url = if (page > 1) "https://$domain/page/$page" else "https://$domain/" val doc = webClient.httpGet(url).parseHtml() parseLatestOrSearch(doc) } From b027e4ec375fc68efd171156081de054f639390d Mon Sep 17 00:00:00 2001 From: Abdelrahman Date: Sat, 26 Sep 2026 16:32:28 +0300 Subject: [PATCH 05/33] Update Manga18fx.kt --- src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt b/src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt index 7238d9e9..b76aed50 100644 --- a/src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt +++ b/src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt @@ -48,6 +48,11 @@ internal class Manga18fx(context: MangaLoaderContext) : isMultipleTagsSupported = false, ) + init { + paginator.firstPage = 1 + searchPaginator.firstPage = 1 + } + override suspend fun fetchAvailableTags(): Set { val doc = webClient.httpGet("https://$domain/").parseHtml() val scraped = doc.select(".header-bottom li a").mapNotNull { a -> From 1d13779dcc374b75852bb9085ef0e90ada699e43 Mon Sep 17 00:00:00 2001 From: Abdelrahman Date: Sat, 26 Sep 2026 16:41:37 +0300 Subject: [PATCH 06/33] Update Manga18fx.kt --- src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt b/src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt index b76aed50..c74b424e 100644 --- a/src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt +++ b/src/main/kotlin/tsuki/site/en/nsfw/Manga18fx.kt @@ -93,7 +93,7 @@ internal class Manga18fx(context: MangaLoaderContext) : parsePopular(doc) } else -> { - val url = if (page > 1) "https://$domain/page/$page" else "https://$domain/" + val url = "https://$domain/page/$page" val doc = webClient.httpGet(url).parseHtml() parseLatestOrSearch(doc) } From 4fe6a184fc1423cd643b3f2b1d70f27e941c403d Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:44:41 +0200 Subject: [PATCH 07/33] fix mangaball --- src/main/kotlin/tsuki/site/all/Mangaball.kt | 898 +++++++++----------- 1 file changed, 416 insertions(+), 482 deletions(-) diff --git a/src/main/kotlin/tsuki/site/all/Mangaball.kt b/src/main/kotlin/tsuki/site/all/Mangaball.kt index a6ffb97a..3fe4a9f4 100644 --- a/src/main/kotlin/tsuki/site/all/Mangaball.kt +++ b/src/main/kotlin/tsuki/site/all/Mangaball.kt @@ -4,8 +4,7 @@ import tsuki.MangaLoaderContext import tsuki.MangaSourceParser import tsuki.config.ConfigKey import tsuki.core.PagedMangaParser -import tsuki.network.OkHttpWebClient -import tsuki.network.WebClient +import tsuki.network.CommonHeaders import tsuki.model.ContentRating import tsuki.model.ContentType @@ -24,64 +23,55 @@ import tsuki.model.SortOrder import tsuki.util.LinkResolver import tsuki.util.generateUid -import tsuki.util.insertCookies import tsuki.util.nullIfEmpty -import tsuki.util.oneOrThrowIfMany -import tsuki.util.ownTextOrNull -import tsuki.util.parseHtml import tsuki.util.parseJson -import tsuki.util.parseSafe -import tsuki.util.toAbsoluteUrl -import tsuki.util.urlEncoded +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response import org.json.JSONArray import org.json.JSONObject -import org.jsoup.HttpStatusException -import org.jsoup.nodes.Document -import java.text.SimpleDateFormat +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter import java.util.EnumSet -import java.util.Locale -private const val DOMAIN = "mangaball.net" +private const val DOMAIN = "mangaball.com" +private const val LEGACY_HOST = "mangaball.net" +private const val BASE_URL = "https://$DOMAIN" +private const val COVER_BASE_URL = "https://bulbasaur.poke-black-and-white.net/covers/" +private const val PAGE_SIZE = 24 + +private val ADULT_TAG_NAMES = setOf( + "adult", "ecchi", "mature", "smut", "hentai", "manhwa 18+", "sexual violence", +) internal abstract class MangaBallParser( context: MangaLoaderContext, source: MangaParserSource, private val siteLanguages: Set, -) : PagedMangaParser(context, source, pageSize = 20), Interceptor { +): PagedMangaParser(context, source, pageSize = PAGE_SIZE) { override val configKeyDomain = ConfigKey.Domain(DOMAIN) private val showSuspiciousContentKey = ConfigKey.ShowSuspiciousContent(false) - private val rawHttpClient: OkHttpClient by lazy { context.httpClient.newBuilder().build() } - private val client: WebClient by lazy { - OkHttpWebClient( - context.httpClient.newBuilder() - .addInterceptor(this) - .build(), - source, - ) - } - - @Volatile - private var csrfToken: String? = null + private val dateFormat = DateTimeFormatter.ISO_LOCAL_DATE_TIME - private val chapterDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT) + override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder() + .add(CommonHeaders.REFERER, "$BASE_URL/") + .add(CommonHeaders.ORIGIN, BASE_URL) + .add(CommonHeaders.ACCEPT, "application/json, text/plain, */*") + .build() override val availableSortOrders: Set = EnumSet.of( SortOrder.UPDATED, SortOrder.POPULARITY, + SortOrder.RATING, SortOrder.NEWEST, SortOrder.ALPHABETICAL, - SortOrder.ALPHABETICAL_DESC, - SortOrder.RELEVANCE, ) override val filterCapabilities: MangaListFilterCapabilities @@ -121,386 +111,347 @@ internal abstract class MangaBallParser( Demographic.JOSEI, ), ) - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - ensureAdultCookie() - val query = filter.query?.trim().orEmpty() - if (page == 1 && query.startsWith("http")) { - return resolveSearchUrl(query) - } - val isQueryOnly = query.isNotEmpty() && !filter.hasNonSearchOptions() - return when { - isQueryOnly && page == 1 -> smartSearch(query) - isQueryOnly -> advancedSearch(page - 1, order, filter) - else -> advancedSearch(page, order, filter) - } - } + val url = "$BASE_URL/api/v1/title/search-advanced".toHttpUrl().newBuilder().apply { + addQueryParameter("page", page.toString()) + addQueryParameter("limit", PAGE_SIZE.toString()) + addQueryParameter("sort_by", sortByValue(order)) + addQueryParameter("sort_order", "desc") + addQueryParameter("tag_mode", "AND") + addQueryParameter("adult_mode", if (config[showSuspiciousContentKey]) "all" else "no_18") + + filter.query?.trim()?.takeIf { it.isNotEmpty() }?.let { + addQueryParameter("keyword", it) + } + filter.types.firstOrNull()?.let { + addQueryParameter("type", contentTypeValue(it)) + } + filter.demographics.firstOrNull()?.let { + addQueryParameter("publicationDemographic", demographicValue(it)) + } + filter.states.firstOrNull()?.let { + addQueryParameter("status", stateValue(it)) + } + + filter.tags.map { it.key }.takeIf { it.isNotEmpty() }?.let { + addQueryParameter("included_tags", it.joinToString(",")) + } + filter.tagsExclude.map { it.key }.takeIf { it.isNotEmpty() }?.let { + addQueryParameter("excluded_tags", it.joinToString(",")) + } + }.build() + + val data = webClient.httpGet(url).parseJson().optJSONArray("data") + ?: return emptyList() - override suspend fun getDetails(manga: Manga): Manga { - synchronized(detailsCacheLock) { - detailsCache[manga.url]?.let { return it } + return (0 until data.length()).mapNotNull { i -> + data.getJSONObject(i).toListManga() } + } - ensureAdultCookie() - val doc = client.httpGet(getMangaUrl(manga.url)).parseHtml() - updateCsrf(doc) + private fun JSONObject.toListManga(): Manga? { + val id = optString("id").nullIfEmpty() + ?: optString("slug").nullIfEmpty() + ?: return null + val slug = optString("slug").nullIfEmpty() ?: id + return Manga( + id = generateUid(id), + title = getString("name"), + altTitles = emptySet(), + url = id, + publicUrl = getMangaUrl(slug), + rating = RATING_UNKNOWN, + contentRating = null, + coverUrl = parseCoverUrl(optJSONObject("image")), + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + ) + } - val tags = doc.select("#comicDetail span[data-tag-id]").mapTo(LinkedHashSet()) { tag -> - MangaTag( - key = tag.attr("data-tag-id"), - title = tag.ownTextOrNull().orEmpty().trim(), - source = source, - ) + override suspend fun getDetails(manga: Manga): Manga = coroutineScope { + val detailDeferred = async { + webClient.httpGet("$BASE_URL/api/v1/title/detail/${manga.url}") + .parseJson() + .getJSONObject("data") } - val altTitles = doc.select("div.alternate-name-container").text() - .split('/') - .mapNotNullTo(LinkedHashSet()) { it.trim().nullIfEmpty() } - val authors = doc.select("#comicDetail span[data-person-id]") - .mapNotNullTo(LinkedHashSet()) { it.text().trim().nullIfEmpty() } - val status = when (doc.selectFirst("span.badge-status")?.text()?.trim()) { - "Ongoing" -> MangaState.ONGOING - "Completed" -> MangaState.FINISHED - "Hiatus" -> MangaState.PAUSED - "Cancelled" -> MangaState.ABANDONED - else -> null + val chaptersDeferred = async { + runCatching { getChapterList(manga.url) }.getOrDefault(emptyList()) } - val title = doc.selectFirst("#comicDetail h6")?.ownTextOrNull()?.trim()?.nullIfEmpty() ?: manga.title - val cover = doc.selectFirst("img.featured-cover")?.absUrl("src")?.nullIfEmpty() ?: manga.coverUrl - val description = doc.selectFirst("#descriptionContent p")?.wholeText()?.trim()?.nullIfEmpty() - val contentRating = when { - tags.any { it.title in ADULT_TAG_TITLES } -> ContentRating.ADULT - else -> manga.contentRating + + val data = detailDeferred.await() + + val titleId = data.optString("id").nullIfEmpty() ?: manga.url + val slug = data.optString("slug").nullIfEmpty() ?: titleId + val name = data.optString("name").nullIfEmpty() ?: manga.title + + val description = data.optJSONArray("description") + ?.let { arr -> (0 until arr.length()).joinToString("\n\n") { arr.optString(it) } } + .orEmpty() + val altNames = data.optJSONArray("alternateName").toStringList() + val tags = data.optJSONArray("tags").toNameList() + val authors = data.optJSONArray("author").toNameList() + val artists = data.optJSONArray("artist").toNameList() + + val rating = data.optJSONObject("ratings") + ?.optDouble("rating_average", -1.0) + ?.takeIf { it >= 0.0 } + ?.let { (it / 2.0).toFloat() } + ?: RATING_UNKNOWN + + val coverUrl = parseCoverUrl(data.optJSONObject("image")) + + val contentRating = if (tags.any { it.lowercase() in ADULT_TAG_NAMES }) { + ContentRating.ADULT + } else { + null } - val result = manga.copy( - title = title, - altTitles = if (altTitles.isEmpty()) manga.altTitles else altTitles, - publicUrl = getMangaUrl(manga.url), - coverUrl = cover, - largeCoverUrl = cover, - tags = tags, - state = status, - authors = authors, + var chapters = chaptersDeferred.await() + if (chapters.isEmpty() && titleId != manga.url) { + chapters = runCatching { getChapterList(titleId) }.getOrDefault(emptyList()) + } + manga.copy( + title = name, + altTitles = altNames.toSet(), + publicUrl = getMangaUrl(slug), + coverUrl = coverUrl ?: manga.coverUrl, + largeCoverUrl = coverUrl ?: manga.largeCoverUrl, description = description, - chapters = getChapterList(manga.url), + tags = tags.mapTo(mutableSetOf()) { MangaTag(key = it, title = it, source = source) }, + authors = (authors + artists).toSet(), + state = parseState(data.optString("status").nullIfEmpty()), + chapters = chapters, + source = source, + rating = rating, contentRating = contentRating, ) - synchronized(detailsCacheLock) { - detailsCache[manga.url] = result - } - return result } - override suspend fun getPages(chapter: MangaChapter): List { - ensureAdultCookie() - val doc = client.httpGet(getChapterUrl(chapter)).parseHtml() - updateCsrf(doc) - val script = doc.select("script") - .firstOrNull { it.data().contains("chapterImages") } - ?.data() - .orEmpty() - val rawImages = CHAPTER_IMAGES_REGEX.find(script)?.groupValues?.getOrNull(1).orEmpty() - if (rawImages.isEmpty()) { - return emptyList() - } - val images = JSONArray(rawImages) - return List(images.length()) { index -> - val imageUrl = images.getString(index) - MangaPage( - id = generateUid(imageUrl), - url = imageUrl, - preview = null, - source = source, - ) - } - } + private suspend fun getChapterList(titleId: String): List { + val releases = fetchAll(titleId) + val branchLabels = preBranchLabels(releases) - override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? { - if (link.host != domain) { - return null + val buckets = releases.groupBy { (lang, item) -> + ChapterBucket( + number = item.optDouble("number", 0.0).toFloat(), + lang = lang, + ) } - val pathType = link.pathSegments.firstOrNull() ?: return null - val slug = when (pathType) { - "title-detail" -> link.pathSegments.getOrNull(1) - "chapter-detail" -> resolveChapterSlug(link.toString()) - else -> null - } ?: return null - return getDetails(seedManga(slug)) - } - override fun intercept(chain: Interceptor.Chain): Response { - ensureAdultCookie() - val request = chain.request() - var builder = request.newBuilder() - if ( - (request.url.host == domain || request.url.host.endsWith(".poke-black-and-white.net")) && - request.header("Referer").isNullOrEmpty() - ) { - builder = builder.header("Referer", "https://$domain/") - } - if (request.url.host == domain && request.url.pathSegments.firstOrNull() == "api") { - builder = builder - .header("X-Requested-With", "XMLHttpRequest") - .header("X-CSRF-TOKEN", getCsrf()) - } - var response = chain.proceed(builder.build()) - if (response.code == 403 && request.url.host == domain && request.url.pathSegments.firstOrNull() == "api") { - response.close() - refreshCsrf() - response = chain.proceed( - request.newBuilder() - .header("Referer", "https://$domain/") - .header("X-Requested-With", "XMLHttpRequest") - .header("X-CSRF-TOKEN", getCsrf()) - .build(), + return buckets.values + .flatMap { group -> + group.map { (_, item) -> + buildChapter(item, branchLabels[item.getString("id")]) + } + } + .sortedWith( + compareBy { it.number } + .thenBy { it.volume } + .thenByDescending { it.uploadDate } + .thenBy { it.branch.orEmpty() }, ) - } - return response } - private suspend fun smartSearch(query: String): List { - val payload = mapOf("search_input" to query) - val json = postApi("$BASE_URL/api/v1/smart-search/search/".toHttpUrl(), payload).parseJson() - val mangaArray = json.getJSONObject("data").getJSONArray("manga") - return List(mangaArray.length()) { index -> - val item = mangaArray.getJSONObject(index) - val slug = extractSlug(item.getString("url")) - Manga( - id = generateUid(slug), - title = item.getString("title"), - altTitles = emptySet(), - url = slug, - publicUrl = getMangaUrl(slug), - rating = RATING_UNKNOWN, - contentRating = null, - coverUrl = item.getString("img").nullIfEmpty(), - tags = emptySet(), - state = null, - authors = emptySet(), - source = source, - ) - } + private fun getBase(item: JSONObject, lang: String): String { + val scanlator = item.optString("group_name").trim().nullIfEmpty() + val site = item.optString("site").trim().nullIfEmpty() + return scanlator ?: site ?: lang } - private suspend fun advancedSearch(page: Int, order: SortOrder, filter: MangaListFilter): List { - if (page < 1) { - return emptyList() - } - val payload = buildAdvancedPayload(page, order, filter) - val json = postApi("$BASE_URL/api/v1/title/search-advanced/".toHttpUrl(), payload).parseJson() - val data = json.optJSONArray("data") ?: return emptyList() - return List(data.length()) { index -> - val item = data.getJSONObject(index) - val slug = extractSlug(item.getString("url")) - Manga( - id = generateUid(slug), - title = item.getString("name"), - altTitles = emptySet(), - url = slug, - publicUrl = getMangaUrl(slug), - rating = RATING_UNKNOWN, - contentRating = null, - coverUrl = item.optString("cover").nullIfEmpty(), - tags = emptySet(), - state = null, - authors = emptySet(), - source = source, - ) + private fun preBranchLabels(releases: List>): Map { + val byBase = releases.groupBy { (lang, item) -> getBase(item, lang) } + + val result = mutableMapOf() + + for ((base, group) in byBase) { + val volumes = group.map { it.second.optDouble("volume", 0.0).toInt() }.distinct() + val hasNormal = volumes.contains(0) + val hasVolumes = volumes.any { it > 0 } + + if (hasNormal && hasVolumes) { + for ((_, item) in group) { + val vol = item.optDouble("volume", 0.0).toInt() + val label = if (vol == 0) "$base (Chapters)" else "$base (Volumes)" + result[item.getString("id")] = label + } + } else { + for ((_, item) in group) { + result[item.getString("id")] = base + } + } } + return result } - private fun buildAdvancedPayload(page: Int, order: SortOrder, filter: MangaListFilter): String { - val includedTags = LinkedHashSet() - includedTags += filter.tags.map { it.key } - filter.types.oneOrThrowIfMany()?.let { type -> - includedTags += TYPE_TAG_IDS.getValue(type) + private suspend fun fetchAll(titleId: String): List> { + val langParam = siteLanguages.joinToString(",") + val pageSize = 200 + + suspend fun fetchPage(page: Int): JSONArray? { + val url = "$BASE_URL/api/v1/title/chapter-listing".toHttpUrl().newBuilder() + .addQueryParameter("title_id", titleId) + .addQueryParameter("page", page.toString()) + .addQueryParameter("limit", pageSize.toString()) + .addQueryParameter("group_by", "chapter_number") + .addQueryParameter("sort_order", "desc") + .addQueryParameter("language", langParam) + .addQueryParameter("search", "") + .addQueryParameter("user_id", "") + .build() + return webClient.httpGet(url).parseJson().optJSONArray("data") } - return buildList { - addEncoded("search_input", filter.query?.trim().orEmpty()) - addEncoded("filters[sort]", sortValue(order)) - addEncoded("filters[page]", page.toString()) - includedTags.forEach { addEncoded("filters[tag_included_ids][]", it) } - addEncoded("filters[tag_included_mode]", "and") - filter.tagsExclude.forEach { addEncoded("filters[tag_excluded_ids][]", it.key) } - addEncoded("filters[tag_excluded_mode]", "or") - addEncoded("filters[contentRating]", "any") - addEncoded("filters[demographic]", demographicValue(filter.demographics.oneOrThrowIfMany())) - addEncoded("filters[person]", "any") - addEncoded("filters[publicationYear]", filter.year.takeIf { it > 0 }?.toString().orEmpty()) - addEncoded("filters[publicationStatus]", stateValue(filter.states.oneOrThrowIfMany())) - siteLanguages.forEach { addEncoded("filters[translatedLanguage][]", it) } - }.joinToString("&") - } - - private suspend fun getChapterList(mangaSlug: String): List { - val titleId = mangaSlug.substringAfterLast('-') - val json = postApi( - "$BASE_URL/api/v1/chapter/chapter-listing-by-title-id/".toHttpUrl(), - mapOf("title_id" to titleId), + val firstJson = webClient.httpGet( + "$BASE_URL/api/v1/title/chapter-listing".toHttpUrl().newBuilder() + .addQueryParameter("title_id", titleId) + .addQueryParameter("page", "1") + .addQueryParameter("limit", pageSize.toString()) + .addQueryParameter("group_by", "chapter_number") + .addQueryParameter("sort_order", "desc") + .addQueryParameter("language", langParam) + .addQueryParameter("search", "") + .addQueryParameter("user_id", "") + .build() ).parseJson() - val containers = json.getJSONArray("ALL_CHAPTERS") - val chapterCandidates = ArrayList(containers.length()) - for (i in 0 until containers.length()) { - val container = containers.getJSONObject(i) - val number = container.optDouble("number_float", 0.0).toFloat() - val translations = container.getJSONArray("translations") - for (j in 0 until translations.length()) { - val translation = translations.getJSONObject(j) - val language = translation.optString("language") - if (language !in siteLanguages) { - continue - } - val group = translation.getJSONObject("group") - val groupId = group.optString("_id") - val groupName = group.optString("name").trim() - val scanlator = buildString { - append(groupName) - if (!GROUP_ID_REGEX.matches(groupId)) { - append(" (") - append(groupId) - append(')') - } - }.nullIfEmpty() - val volume = translation.optInt("volume", 0) - val translationName = translation.optString("name").trim() - val numberText = number.toString().removeSuffix(".0") - val chapter = MangaChapter( - id = generateUid(translation.getString("id")), - title = buildChapterTitle(numberText, volume, translationName), - number = number, - volume = volume, - url = translation.getString("id"), - scanlator = scanlator, - uploadDate = chapterDateFormat.parseSafe(translation.optString("date")), - branch = language.takeIf { siteLanguages.size > 1 }, - source = source, - ) - chapterCandidates += ChapterCandidate(language = language, chapter = chapter) + + val firstPage = firstJson.optJSONArray("data") ?: return emptyList() + if (firstPage.length() == 0) return emptyList() + + val totalPages = firstJson.optJSONObject("pagination") + ?.optInt("total_pages", 1) + ?.coerceAtLeast(1) + ?: 1 + + val seenIds = HashSet() + val releases = ArrayList>() + + fun consume(data: JSONArray?) { + if (data == null) return + for (i in 0 until data.length()) { + val item = data.optJSONObject(i) ?: continue + val lang = item.optString("lang") + if (lang !in siteLanguages) continue + val id = item.optString("id").nullIfEmpty() ?: continue + if (!seenIds.add(id)) continue + releases += lang to item } } - val scanlatorCounts = chapterCandidates.groupingBy { - "${it.language}|${it.chapter.scanlator.orEmpty()}" - }.eachCount() - return chapterCandidates - .groupBy { "${it.language}|${it.chapter.number}" } - .values - .mapNotNull { candidates -> - candidates.maxWithOrNull { left, right -> - compareChapterCandidates( - left = left, - right = right, - scanlatorCounts = scanlatorCounts, - ) - }?.chapter + + consume(firstPage) + + if (totalPages > 1) { + val rest = coroutineScope { + (2..totalPages).map { page -> async { fetchPage(page) } }.awaitAll() } - .sortedWith( - compareBy { it.number } - .thenBy { if (it.volume > 0) 1 else 0 } - .thenBy { it.uploadDate } - .thenBy { it.branch.orEmpty() } - .thenBy { it.title.orEmpty() }, - ) + rest.forEach { consume(it) } + } + return releases + } + private fun buildChapter(item: JSONObject, branch: String?): MangaChapter { + val number = item.optDouble("number", 0.0).toFloat() + val volume = item.optDouble("volume", 0.0).toFloat() + val id = item.getString("id") + val rawName = item.optString("name").trim() + val groupNm = item.optString("group_name").trim().nullIfEmpty() + val created = item.optString("created_at").nullIfEmpty() + + val title = buildChapterTitle(rawName, number, volume) + + return MangaChapter( + generateUid(id), + title, + number, + volume.toInt(), + id, + groupNm, + parseDate(created), + branch, + source, + ) } - private fun buildChapterTitle(numberText: String, volume: Int, rawName: String): String { - val baseTitle = buildString { - if (volume > 0) { + private fun buildChapterTitle(rawName: String, number: Float, volume: Float): String { + val numberStr = number.toString().removeSuffix(".0") + return buildString { + if (volume > 0f) { append("Vol. ") - append(volume) + append(volume.toString().removeSuffix(".0")) append(' ') } - append("Chapter ") - append(numberText) - } - val name = rawName.trim() - if (name.isEmpty()) { - return baseTitle - } - val compactName = name.replace(Regex("\\s+"), " ") - val chapterPrefixRegex = Regex("^(Vol\\.?\\s*\\d+\\s+)?Ch(?:apter)?\\.?\\s*", RegexOption.IGNORE_CASE) - val normalized = chapterPrefixRegex.find(compactName)?.let { match -> - val volumePrefix = match.groups[1]?.value?.trim().orEmpty() - buildString { - if (volumePrefix.isNotEmpty()) { - append(volumePrefix.replaceFirst(Regex("(?i)^vol\\.?"), "Vol.")) + if (rawName.contains(numberStr)) { + append(rawName) + } else { + append("Ch. ") + append(numberStr) + if (rawName.isNotEmpty()) { append(' ') + append(rawName) } - append("Chapter ") - append(compactName.removeRange(match.range)) } - }?.trim() ?: compactName.trim() - return if (normalized.startsWith("Chapter $numberText", ignoreCase = true) || - normalized.startsWith("Vol. $volume Chapter $numberText", ignoreCase = true) - ) { - normalized - } else { - "$baseTitle: $normalized" } } - private fun compareChapterCandidates( - left: ChapterCandidate, - right: ChapterCandidate, - scanlatorCounts: Map, - ): Int { - val leftChapter = left.chapter - val rightChapter = right.chapter - val leftScore = scanlatorCounts["${left.language}|${leftChapter.scanlator.orEmpty()}"] ?: 0 - val rightScore = scanlatorCounts["${right.language}|${rightChapter.scanlator.orEmpty()}"] ?: 0 - if (leftScore != rightScore) { - return leftScore.compareTo(rightScore) - } - if ((leftChapter.volume > 0) != (rightChapter.volume > 0)) { - return if (leftChapter.volume == 0) 1 else -1 - } - val leftTitle = leftChapter.title.orEmpty() - val rightTitle = rightChapter.title.orEmpty() - if (leftTitle.length != rightTitle.length) { - return rightTitle.length.compareTo(leftTitle.length) + override suspend fun getPages(chapter: MangaChapter): List { + val json = webClient.httpGet( + "$BASE_URL/api/v1/chapter-detail?chapter_id=${chapter.url}", + ).parseJson() + + val pages = json.getJSONObject("data") + .getJSONObject("chapter") + .optJSONArray("pages") + ?: return emptyList() + + runCatching { + webClient.httpPost( + "$BASE_URL/api/v1/views/update".toHttpUrl(), + JSONObject().apply { + put("object_id", chapter.url) + put("object_type", "chapter") + }, + ) } - if (leftChapter.uploadDate != rightChapter.uploadDate) { - return leftChapter.uploadDate.compareTo(rightChapter.uploadDate) + + return (0 until pages.length()).map { i -> + val imageUrl = pages.getString(i) + MangaPage( + id = generateUid(imageUrl), + url = imageUrl, + preview = null, + source = source, + ) } - return rightChapter.id.compareTo(leftChapter.id) } - private suspend fun resolveSearchUrl(query: String): List { - val slug = when { - query.startsWith("https://") || query.startsWith("http://") -> { - val link = query.toHttpUrl() - when (link.pathSegments.firstOrNull()) { - "title-detail" -> link.pathSegments.getOrNull(1) - "chapter-detail" -> resolveChapterSlug(query) - else -> null - } - } + override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? { + if (link.host != DOMAIN && link.host != LEGACY_HOST) return null + + val handle = when (link.pathSegments.firstOrNull()) { + "title-detail" -> link.pathSegments.getOrNull(1) + "chapter-detail" -> link.pathSegments.getOrNull(1)?.let { resolveTitleIdFromChapter(it) } else -> null - } ?: return emptyList() - return listOf(getDetails(seedManga(slug))) + } ?: return null + + return getDetails(seedManga(handle)) } - private suspend fun resolveChapterSlug(chapterUrl: String): String? { - val doc = client.httpGet(chapterUrl).parseHtml() - updateCsrf(doc) - val yoastRaw = doc.selectFirst("script.yoast-schema-graph")?.data()?.nullIfEmpty() ?: return null - val graph = JSONObject(yoastRaw).optJSONArray("@graph") ?: return null - for (i in 0 until graph.length()) { - val item = graph.getJSONObject(i) - if (item.optString("@type") == "WebPage") { - val url = item.optString("url").nullIfEmpty() ?: continue - return url.toHttpUrl().pathSegments.getOrNull(1) - } - } - return null + private suspend fun resolveTitleIdFromChapter(chapterId: String): String? { + val json = webClient.httpGet( + "$BASE_URL/api/v1/chapter-detail?chapter_id=$chapterId", + ).parseJson() + return json.getJSONObject("data") + .getJSONObject("chapter") + .optString("title_id") + .nullIfEmpty() } - private fun seedManga(slug: String): Manga = Manga( - id = generateUid(slug), - title = slug.substringBeforeLast('-').replace('-', ' ').trim(), + private fun seedManga(handle: String) = Manga( + id = generateUid(handle), + title = handle.replace('-', ' ').replaceFirstChar { it.uppercase() }, altTitles = emptySet(), - url = slug, - publicUrl = getMangaUrl(slug), + url = handle, + publicUrl = getMangaUrl(handle), rating = RATING_UNKNOWN, contentRating = null, coverUrl = null, @@ -510,150 +461,92 @@ internal abstract class MangaBallParser( source = source, ) - private fun extractSlug(url: String): String = url.toAbsoluteUrl(domain).toHttpUrl().pathSegments[1] - - private fun getMangaUrl(slug: String): String = "$BASE_URL/title-detail/$slug/" - - private fun getChapterUrl(chapter: MangaChapter): String = "$BASE_URL/chapter-detail/${chapter.url}/" - - private fun getHeadersWithReferer(): Headers = getRequestHeaders().newBuilder() - .add("Referer", "$BASE_URL/") - .build() - - private fun getApiHeaders(): Headers = getHeadersWithReferer().newBuilder() - .add("X-Requested-With", "XMLHttpRequest") - .add("X-CSRF-TOKEN", getCsrf()) - .build() - - private suspend fun postApi(url: HttpUrl, form: Map): Response { - return try { - client.httpPost(url, form, getApiHeaders()) - } catch (e: HttpStatusException) { - if (e.statusCode != 403) throw e - refreshCsrf() - client.httpPost(url, form, getApiHeaders()) - } - } + private fun getMangaUrl(slug: String) = "$BASE_URL/title-detail/$slug" - private suspend fun postApi(url: HttpUrl, payload: String): Response { - return try { - client.httpPost(url, payload, getApiHeaders()) - } catch (e: HttpStatusException) { - if (e.statusCode != 403) throw e - refreshCsrf() - client.httpPost(url, payload, getApiHeaders()) + private fun parseCoverUrl(image: JSONObject?): String? { + if (image == null) return null + val coverPath = image.optJSONObject("cover")?.optString("path")?.nullIfEmpty() + if (coverPath != null) { + val normalized = coverPath.replace('\\', '/') + return if (normalized.startsWith("http")) normalized else COVER_BASE_URL + normalized } + return listOf( + image.optString("file"), + image.optString("cdn_mangadex"), + image.optString("cdn_mangaupdate"), + image.optString("cdn_mangaupdates"), + ).firstOrNull { !it.isNullOrBlank() } } - @Synchronized - private fun ensureAdultCookie() { - context.cookieJar.insertCookies(domain, "show18PlusContent=${config[showSuspiciousContentKey]}") + private fun parseDate(raw: String?): Long { + if (raw.isNullOrBlank()) return 0L + return runCatching { + LocalDateTime.parse(raw, dateFormat).toInstant(ZoneOffset.UTC).toEpochMilli() + }.getOrDefault(0L) } - @Synchronized - private fun getCsrf(): String { - if (csrfToken.isNullOrEmpty()) { - refreshCsrf() - if (csrfToken.isNullOrEmpty()) { - error("CSRF token not found") - } - } - return csrfToken!! + private fun parseState(status: String?): MangaState? = when (status) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + "cancelled" -> MangaState.ABANDONED + else -> null } - @Synchronized - private fun refreshCsrf(document: Document? = null) { - ensureAdultCookie() - 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() + /** Extracts a list of plain strings from an array of primitives. */ + private fun JSONArray?.toStringList(): List { + if (this == null) return emptyList() + return (0 until length()).mapNotNull { optString(it).nullIfEmpty() } } - @Synchronized - private fun updateCsrf(document: Document) { - document.selectFirst("meta[name=csrf-token]")?.attr("content")?.trim()?.nullIfEmpty()?.let { - csrfToken = it - } + /** Extracts `.name` from an array of objects (`[{"name": "..."}]`). */ + private fun JSONArray?.toNameList(): List { + if (this == null) return emptyList() + return (0 until length()) + .mapNotNull { optJSONObject(it)?.optString("name")?.nullIfEmpty() } } - private val detailsCacheLock = Any() - - private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 10 + private fun sortByValue(order: SortOrder): String = when (order) { + SortOrder.UPDATED -> "lastupdate" + SortOrder.POPULARITY -> "views" + SortOrder.RATING -> "rating" + SortOrder.NEWEST -> "created_at" + SortOrder.ALPHABETICAL -> "name" + else -> "lastupdate" } - private fun MutableList.addEncoded(key: String, value: String) { - add("${key.urlEncoded()}=${value.urlEncoded()}") + private fun contentTypeValue(type: ContentType): String = when (type) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.MANHUA -> "manhua" + ContentType.COMICS -> "comics" + else -> "" } - private fun sortValue(order: SortOrder): String = when (order) { - SortOrder.UPDATED -> "updated_chapters_desc" - SortOrder.POPULARITY -> "views_desc" - SortOrder.NEWEST -> "created_at_desc" - SortOrder.ALPHABETICAL -> "name_asc" - SortOrder.ALPHABETICAL_DESC -> "name_desc" - SortOrder.RELEVANCE -> "updated_chapters_desc" - else -> "updated_chapters_desc" + private fun demographicValue(demographic: Demographic): String = when (demographic) { + Demographic.SHOUNEN -> "shounen" + Demographic.SHOUJO -> "shoujo" + Demographic.SEINEN -> "seinen" + Demographic.JOSEI -> "josei" + else -> "" } - private fun stateValue(state: MangaState?): String = when (state) { + private fun stateValue(state: MangaState): String = when (state) { MangaState.ONGOING -> "ongoing" MangaState.FINISHED -> "completed" MangaState.PAUSED -> "hiatus" MangaState.ABANDONED -> "cancelled" - else -> "any" - } - - private fun demographicValue(demographic: Demographic?): String = when (demographic) { - Demographic.SHOUNEN -> "shounen" - Demographic.SHOUJO -> "shoujo" - Demographic.SEINEN -> "seinen" - Demographic.JOSEI -> "josei" - else -> "any" + else -> "" } - private data class ChapterCandidate( - val language: String, - val chapter: MangaChapter, + private data class ChapterBucket( + val number: Float, + val lang: String, ) - companion object { - - private const val BASE_URL = "https://${DOMAIN}" - private val GROUP_ID_REGEX = Regex("[a-z0-9]{24}") - private val CHAPTER_IMAGES_REGEX = Regex("""const\s+chapterImages\s*=\s*JSON\.parse\(`([^`]+)`\)""") - private val ADULT_TAG_TITLES = setOf( - "Adult", - "Ecchi", - "Mature", - "Smut", - "Hentai", - "Manhwa 18+", - "Sexual Violence", - ) - - private val TYPE_TAG_IDS = mapOf( - ContentType.COMICS to "68ecab8507ec62d87e62780f", - ContentType.MANGA to "68ecab1e07ec62d87e627806", - ContentType.MANHUA to "68ecab4807ec62d87e62780b", - ContentType.MANHWA to "68ecab3b07ec62d87e627809", - ) - - private data class TagDef( - val id: String, - val title: String, - ) + private data class TagDef(val id: String, val title: String) + companion object { private val TAG_DEFINITIONS = listOf( TagDef("685148d115e8b86aae68e4f3", "Gore"), TagDef("685146c5f3ed681c80f257e7", "Sexual Violence"), @@ -673,6 +566,7 @@ internal abstract class MangaBallParser( TagDef("689371f0a943baf927094f03", "Adult"), TagDef("685146c5f3ed681c80f257e6", "Adventure"), TagDef("685148ef15e8b86aae68e573", "Boys' Love"), + TagDef("68ecab8507ec62d87e62780f", "Comic"), TagDef("685146c5f3ed681c80f257e5", "Comedy"), TagDef("685148da15e8b86aae68e51f", "Crime"), TagDef("685148cf15e8b86aae68e4dd", "Drama"), @@ -682,15 +576,21 @@ internal abstract class MangaBallParser( TagDef("685148db15e8b86aae68e527", "Historical"), TagDef("685148da15e8b86aae68e520", "Horror"), TagDef("685146c5f3ed681c80f257e9", "Isekai"), + TagDef("694cc2d9f8014f5e0a63ac73", "Josei(W)"), TagDef("6851490d15e8b86aae68e5d4", "Magical Girls"), + TagDef("68ecab1e07ec62d87e627806", "Manga"), + TagDef("68ecab4807ec62d87e62780b", "Manhua"), + TagDef("68ecab3b07ec62d87e627809", "Manhwa"), TagDef("68932d11a943baf927094e7b", "Mature"), TagDef("6851490c15e8b86aae68e5d2", "Mecha"), TagDef("6851494e15e8b86aae68e66e", "Medical"), TagDef("685148d215e8b86aae68e4f4", "Mystery"), TagDef("685148e215e8b86aae68e544", "Philosophical"), TagDef("685148d715e8b86aae68e507", "Psychological"), + TagDef("694cc2d9f8014f5e0a63ac75", "Revenge"), TagDef("685148cf15e8b86aae68e4db", "Romance"), TagDef("685148cf15e8b86aae68e4da", "Sci-Fi"), + TagDef("694cc2d9f8014f5e0a63ac74", "Shoujo(G)"), TagDef("689f0ab1f2e66744c6091524", "Shounen Ai"), TagDef("685148d015e8b86aae68e4e3", "Slice of Life"), TagDef("689371f2a943baf927094f04", "Smut"), @@ -702,6 +602,7 @@ internal abstract class MangaBallParser( TagDef("6851490715e8b86aae68e5c3", "Wuxia"), TagDef("68932f68a943baf927094eaa", "Yaoi"), TagDef("6896a885a943baf927094f66", "Yuri"), + TagDef("6a0026ba63a8d384c0a4be13", "3D"), TagDef("6851490d15e8b86aae68e5d5", "Aliens"), TagDef("685148e715e8b86aae68e54b", "Animals"), TagDef("68bf09ff8fdeab0b6a9bc2b7", "Comics"), @@ -732,6 +633,7 @@ internal abstract class MangaBallParser( TagDef("685148df15e8b86aae68e533", "Reverse Harem"), TagDef("6851490415e8b86aae68e5b9", "Samurai"), TagDef("685148d015e8b86aae68e4e7", "School Life"), + TagDef("6a0025c263a8d384c0a4be07", "Seinen"), TagDef("685148d115e8b86aae68e4ed", "Shota"), TagDef("685148db15e8b86aae68e528", "Supernatural"), TagDef("685148cf15e8b86aae68e4dc", "Survival"), @@ -745,6 +647,7 @@ internal abstract class MangaBallParser( ) } + @MangaSourceParser("MANGABALL_AR", "Manga Ball (Arabic)", "ar") class Arabic(context: MangaLoaderContext) : MangaBallParser(context, MangaParserSource.MANGABALL_AR, setOf("ar")) @@ -819,7 +722,7 @@ internal abstract class MangaBallParser( ) @MangaSourceParser("MANGABALL_JA", "Manga Ball (Japanese)", "ja") - class Japanese(context: MangaLoaderContext) : MangaBallParser(context, MangaParserSource.MANGABALL_JA, setOf("jp")) + class Japanese(context: MangaLoaderContext) : MangaBallParser(context, MangaParserSource.MANGABALL_JA, setOf("jp", "ja")) @MangaSourceParser("MANGABALL_KO", "Manga Ball (Korean)", "ko") class Korean(context: MangaLoaderContext) : MangaBallParser(context, MangaParserSource.MANGABALL_KO, setOf("kr")) @@ -861,7 +764,7 @@ internal abstract class MangaBallParser( class PortugueseBrazil(context: MangaLoaderContext) : MangaBallParser( context, MangaParserSource.MANGABALL_PTBR, - setOf("pt-br", "pt-pt"), + setOf("pt-br", "pt-pt", "pt"), ) @MangaSourceParser("MANGABALL_RO", "Manga Ball (Romanian)", "ro") @@ -912,6 +815,37 @@ internal abstract class MangaBallParser( class Chinese(context: MangaLoaderContext) : MangaBallParser( context, MangaParserSource.MANGABALL_ZH, - setOf("zh", "zh-cn", "zh-hk", "zh-mo", "zh-sg", "zh-tw"), + setOf("zh", "zh-cn", "zh-hk", "zh-mo", "zh-sg", "zh-tw", "cn"), + ) + + @MangaSourceParser("MANGABALL_KA", "Manga Ball (Georgian)", "ka") + class Georgian(context: MangaLoaderContext) : MangaBallParser( + context, + MangaParserSource.MANGABALL_KA, + setOf("ka"), + ) + + @MangaSourceParser("MANGABALL_LT", "Manga Ball (Lithuanian)", "lt") + class Lithuanian(context: MangaLoaderContext) : MangaBallParser( + context, + MangaParserSource.MANGABALL_LT, + setOf("lt"), + ) + + @MangaSourceParser("MANGABALL_MN", "Manga Ball (Mongolian)", "mn") + class Mongolian(context: MangaLoaderContext) : MangaBallParser( + context, + MangaParserSource.MANGABALL_MN, + setOf("mn"), + ) + + @MangaSourceParser("MANGABALL_MY", "Manga Ball (Burmese)", "my") + class Burmese(context: MangaLoaderContext) : MangaBallParser( + context, + MangaParserSource.MANGABALL_MY, + setOf("my"), ) + /** Latin and Tagalog language missing + * getDetails rating not working? + */ } From 123e9c119930f79a6cb60b9a69434d48a071fa4b Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:02:35 +0200 Subject: [PATCH 08/33] fix description --- src/main/kotlin/tsuki/site/en/nsfw/Manhwa18CC.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/tsuki/site/en/nsfw/Manhwa18CC.kt b/src/main/kotlin/tsuki/site/en/nsfw/Manhwa18CC.kt index e2883830..03ade62a 100644 --- a/src/main/kotlin/tsuki/site/en/nsfw/Manhwa18CC.kt +++ b/src/main/kotlin/tsuki/site/en/nsfw/Manhwa18CC.kt @@ -44,6 +44,7 @@ internal class Manhwa18CC(context: MangaLoaderContext) : override val tagPrefix = "webtoon-genre/" override val withoutAjax = true override val selectTestAsync = "ul.row-content-chapter" + override val selectDesc = "div.panel-story-description div.dsct, div.dsct" override val selectDate = "span.chapter-time" override val selectChapter = "li.a-h" override val selectBodyPage = "div.read-content" From 1994bfc7de8a7bfbdcf08c181971136ae6c19ec7 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:04:30 +0200 Subject: [PATCH 09/33] fix images --- src/main/kotlin/tsuki/site/en/MangaTown.kt | 65 ++++++++++++---------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/src/main/kotlin/tsuki/site/en/MangaTown.kt b/src/main/kotlin/tsuki/site/en/MangaTown.kt index 212af601..8547b49e 100644 --- a/src/main/kotlin/tsuki/site/en/MangaTown.kt +++ b/src/main/kotlin/tsuki/site/en/MangaTown.kt @@ -37,6 +37,7 @@ import tsuki.util.urlEncoded import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl +import org.jsoup.nodes.Document import java.text.SimpleDateFormat import java.util.Calendar import java.util.EnumSet @@ -75,7 +76,6 @@ internal class MangaTown(context: MangaLoaderContext) : isYearSupported = false, ) - @Volatile private var allTagsCache: List? = null @@ -104,7 +104,7 @@ internal class MangaTown(context: MangaLoaderContext) : 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() || @@ -282,15 +282,7 @@ internal class MangaTown(context: MangaLoaderContext) : 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()) - } - } + val name = linkEl.ownText().trim() MangaChapter( id = generateUid(href), url = href, @@ -308,7 +300,7 @@ internal class MangaTown(context: MangaLoaderContext) : } ?: emptyList(), ) } - + private suspend fun bypassLicensedChapters(manga: Manga): List { val subdomain = "m." + domain.removePrefix("www.") val doc = webClient.httpGet(manga.url.toAbsoluteUrl(subdomain), getRequestHeaders()).parseHtml() @@ -334,7 +326,7 @@ internal class MangaTown(context: MangaLoaderContext) : ) } } - + private fun parseChapterDate(dateFormat: SimpleDateFormat, date: String?): Long { return when { date.isNullOrEmpty() -> 0L @@ -362,22 +354,39 @@ internal class MangaTown(context: MangaLoaderContext) : } 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 desktopUrl = chapter.url.toAbsoluteUrl(domain) + var doc = webClient.httpGet(desktopUrl, getRequestHeaders()).parseHtml() + var pages = parsePagesFromDoc(doc) + if (pages.isNotEmpty()) return pages + + val mobileDomain = "m." + domain.removePrefix("www.") + val mobileUrl = chapter.url.toAbsoluteUrl(mobileDomain) + doc = webClient.httpGet(mobileUrl, getRequestHeaders()).parseHtml() + pages = parsePagesFromDoc(doc) + if (pages.isNotEmpty()) return pages + + return emptyList() + } + + private fun parsePagesFromDoc(doc: Document): List { + val pageSelect = doc.body().selectFirst("div.page_select select") + if (pageSelect != null) { + 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") + + val imgElements = doc.select("div#viewer img, div.mangaread-img img") return imgElements.map { img -> val src = img.attrAsAbsoluteUrl("src") MangaPage( @@ -395,6 +404,6 @@ internal class MangaTown(context: MangaLoaderContext) : return doc.selectFirst("div#viewer img")?.attrAsAbsoluteUrl("src") ?: throw Exception("Could not find image") } - + private fun String.nullIfEmpty(): String? = ifEmpty { null } } From ca544cc19b9557a7a0680a0aa72a1943a729cdad Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:19:04 +0200 Subject: [PATCH 10/33] fix poor quality images --- .../kotlin/tsuki/site/all/nsfw/HentaiEnvy.kt | 45 ++++++++----------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/src/main/kotlin/tsuki/site/all/nsfw/HentaiEnvy.kt b/src/main/kotlin/tsuki/site/all/nsfw/HentaiEnvy.kt index 5e3e81d2..99735e1d 100644 --- a/src/main/kotlin/tsuki/site/all/nsfw/HentaiEnvy.kt +++ b/src/main/kotlin/tsuki/site/all/nsfw/HentaiEnvy.kt @@ -20,11 +20,11 @@ import tsuki.util.parseHtml import tsuki.util.toAbsoluteUrl import tsuki.util.urlEncoded +import org.json.JSONArray import org.jsoup.nodes.Element import java.util.EnumSet import java.util.Locale - @MangaSourceParser("HENTAIENVY", "HentaiEnvy", type = ContentType.HENTAI) internal class HentaiEnvy(context: MangaLoaderContext) : GalleryAdultsParser(context, MangaParserSource.HENTAIENVY, "hentaienvy.com", pageSize = 24) { @@ -40,7 +40,7 @@ internal class HentaiEnvy(context: MangaLoaderContext) : override val selectGalleryLink = "a.hnv-gallery-card__cover" override val selectGalleryImg = "a.hnv-gallery-card__cover img" override val selectGalleryTitle = ".hnv-gallery-card__title" - + override val selectTags = "ul.hnv-legacy-taxonomy__items" override fun Element.parseTags(): Set = select("a").mapNotNull { a -> @@ -192,38 +192,29 @@ internal class HentaiEnvy(context: MangaLoaderContext) : return parseMangaList(webClient.httpGet(url).parseHtml()) } - /** 50/50 if endpoint of images after the number has a "t" */ override suspend fun getPages(chapter: MangaChapter): List { - val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() - - val grid = doc.selectFirst("#js-thumbs-grid") - - val totalPages = grid?.attr("data-total-pages")?.trim()?.toIntOrNull() - ?: doc.selectFirst("[data-total-pages]")?.attr("data-total-pages")?.trim()?.toIntOrNull() - ?: doc.selectFirst(".hnv-gallery-entity-group:has(:contains(Pages:)) .hnv-gallery-entity-items") - ?.text()?.trim()?.toIntOrNull() - ?: return emptyList() - - if (totalPages <= 0) return emptyList() - - val firstThumb = grid?.selectFirst("img")?.attr("src")?.trim()?.takeIf { it.isNotBlank() } - ?: doc.selectFirst(".hnv-gallery-cover img")?.attr("src")?.trim()?.takeIf { it.isNotBlank() } - ?: return emptyList() + val galleryId = chapter.url.trimEnd('/') + .substringAfterLast('/') + .toIntOrNull() ?: return emptyList() - val base = firstThumb.substringBeforeLast('/') + "/" + val readerDoc = webClient.httpGet("https://$domain/g/$galleryId/1/").parseHtml() + val reader = readerDoc.selectFirst("section#readerApp") ?: return emptyList() - val ext = firstThumb.substringAfterLast('.').substringBefore('?').trim() - .takeIf { it.isNotBlank() } ?: "jpg" + val base = reader.attr("data-reader-image-base").trim().trimEnd('/') + if (base.isEmpty()) return emptyList() + val json = readerDoc.selectFirst("script#readerPagesJson")?.data() ?: return emptyList() + val arr = JSONArray(json) - val firstThumbName = firstThumb.substringAfterLast('/') - val suffix = if (firstThumbName.matches(Regex("""\d+t\.[a-zA-Z0-9]+"""))) "t" else "" + return (0 until arr.length()).mapNotNull { i -> + val obj = arr.optJSONObject(i) ?: return@mapNotNull null + val page = obj.optInt("page", 0) + val ext = obj.optString("ext", "").trim().removePrefix(".") + if (page <= 0 || ext.isBlank()) return@mapNotNull null - return (1..totalPages).map { idx -> - val imageUrl = "${base}${idx}${suffix}.${ext}" MangaPage( - id = generateUid("$chapter.url#$idx"), - url = imageUrl, + id = generateUid("$chapter.url#$page"), + url = "$base/$page.$ext", preview = null, source = source, ) From d46f037aa9ddd26b7c598c805adcbb594b7977fe Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:55:40 +0200 Subject: [PATCH 11/33] fix --- src/main/kotlin/tsuki/site/en/ManhwaZ.kt | 309 +++++++++++++++++------ 1 file changed, 230 insertions(+), 79 deletions(-) diff --git a/src/main/kotlin/tsuki/site/en/ManhwaZ.kt b/src/main/kotlin/tsuki/site/en/ManhwaZ.kt index af8d3061..03bacc62 100644 --- a/src/main/kotlin/tsuki/site/en/ManhwaZ.kt +++ b/src/main/kotlin/tsuki/site/en/ManhwaZ.kt @@ -2,121 +2,272 @@ package tsuki.site.en import tsuki.MangaLoaderContext import tsuki.MangaSourceParser -import tsuki.parsers.MadaraParser +import tsuki.config.ConfigKey +import tsuki.core.PagedMangaParser -import tsuki.model.ContentRating import tsuki.model.ContentType import tsuki.model.Manga +import tsuki.model.MangaChapter import tsuki.model.MangaListFilter +import tsuki.model.MangaListFilterCapabilities +import tsuki.model.MangaListFilterOptions +import tsuki.model.MangaPage import tsuki.model.MangaParserSource import tsuki.model.MangaState import tsuki.model.MangaTag import tsuki.model.SortOrder -import tsuki.util.attrAsRelativeUrlOrNull import tsuki.util.generateUid -import tsuki.util.host import tsuki.util.mapNotNullToSet import tsuki.util.oneOrThrowIfMany -import tsuki.util.parseFailed import tsuki.util.parseHtml -import tsuki.util.removeSuffix -import tsuki.util.src import tsuki.util.toAbsoluteUrl import tsuki.util.toTitleCase import tsuki.util.urlEncoded -@MangaSourceParser("MANHWAZ", "ManhwaZ", "en", ContentType.MANGA) +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.time.Instant +import java.util.EnumSet + +@MangaSourceParser("MANHWAZ", "ManhwaZ", "en", ContentType.MANHWA) internal class Manhwaz(context: MangaLoaderContext) : - MadaraParser(context, MangaParserSource.MANHWAZ, "manhwaz.com", 40) { + PagedMangaParser(context, MangaParserSource.MANHWAZ, 40) { + + override val configKeyDomain = ConfigKey.Domain("manhwaz.cc") - override val listUrl = "genre/manhwa" - override val tagPrefix = "genre/" - override val withoutAjax = true - override val selectTestAsync = "div.list-chapter" + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = false, + isMultipleTagsSupported = false, + isTagsExclusionSupported = false, + isYearSupported = false, + isAuthorSearchSupported = false, + ) init { paginator.firstPage = 1 searchPaginator.firstPage = 1 } - override suspend fun getFilterOptions() = super.getFilterOptions().copy( - availableStates = emptySet(), - availableContentRating = emptySet(), - ) + @Volatile + private var tagsCache: Set? = null + + override suspend fun getFilterOptions(): MangaListFilterOptions { + val tags = tagsCache ?: fetchTags().also { tagsCache = it } + return MangaListFilterOptions( + availableTags = tags, + availableStates = emptySet(), + availableContentRating = emptySet(), + ) + } + + private suspend fun fetchTags(): Set { + val doc = webClient.httpGet("https://$domain/genre").parseHtml() + return doc.select("div.genre-directory a[href^=/genre/]").mapNotNullToSet { a -> + val key = a.attr("href").removeSuffix("/").substringAfterLast("/") + if (key.isEmpty()) return@mapNotNullToSet null + val title = a.ownText().trim().toTitleCase() + if (title.isEmpty()) return@mapNotNullToSet null + MangaTag(key = key, title = title, source = source) + } + } override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val url = buildString { - append("https://") - append(domain) - when { - !filter.query.isNullOrEmpty() -> { - append("/search?s=") - append(filter.query.urlEncoded()) - append("&page=") - append(page.toString()) - } - - else -> { - - val tag = filter.tags.oneOrThrowIfMany() - if (filter.tags.isNotEmpty()) { - append("/$tagPrefix") - append(tag?.key.orEmpty()) - append("?page=") - append(page.toString()) - append("&") - } else { - append("/$listUrl") - append("?page=") - append(page.toString()) - append("&") - } - - append("m_orderby=") - when (order) { - SortOrder.POPULARITY -> append("views") - SortOrder.UPDATED -> append("latest") - SortOrder.NEWEST -> append("new") - SortOrder.RATING -> append("rating") - else -> append("latest") - } - } + if (!filter.query.isNullOrEmpty()) { + val doc = webClient.httpGet( + "https://$domain/search?s=${filter.query.urlEncoded()}&page=$page", + ).parseHtml() + return parseGenreList(doc) + } + + if (filter.tags.isNotEmpty()) { + val tag = filter.tags.oneOrThrowIfMany() ?: return emptyList() + val doc = webClient.httpGet( + "https://$domain/genre/${tag.key}?page=$page", + ).parseHtml() + return parseGenreList(doc) + } + + return when (order) { + SortOrder.POPULARITY -> { + val doc = webClient.httpGet("https://$domain/").parseHtml() + parsePopularList(doc) + } + else -> { + val doc = webClient.httpGet("https://$domain/?page=$page").parseHtml() + parseLatestList(doc) } } - val doc = webClient.httpGet(url).parseHtml() - - return doc.select("div.row.c-tabs-item__content").ifEmpty { - doc.select("div.page-item-detail") - }.map { div -> - val href = div.selectFirst("a")?.attrAsRelativeUrlOrNull("href") ?: div.parseFailed("Link not found") - val summary = div.selectFirst(".tab-summary") ?: div.selectFirst(".item-summary") - val author = summary?.selectFirst(".mg_author")?.selectFirst("a")?.ownText() + } + + private fun parseCardList(doc: Document, containerClass: String, cardClass: String, coverClass: String): List { + return doc.select("div.$containerClass article.$cardClass").mapNotNull { article -> + val cover = article.selectFirst("a.$coverClass") ?: return@mapNotNull null + val href = cover.attr("href").takeIf { it.isNotBlank() } ?: return@mapNotNull null + val title = article.selectFirst("h3 a")?.text()?.trim() + ?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + Manga( id = generateUid(href), url = href, - publicUrl = href.toAbsoluteUrl(div.host ?: domain), - coverUrl = div.selectFirst("img")?.src(), - title = (summary?.selectFirst("h3") ?: summary?.selectFirst("h4"))?.text().orEmpty(), + publicUrl = href.toAbsoluteUrl(domain), + coverUrl = cover.selectFirst("img")?.imgAttr(), + title = title, altTitles = emptySet(), - rating = div.selectFirst("span.total_votes")?.ownText()?.toFloatOrNull()?.div(5f) ?: -1f, - tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapNotNullToSet { a -> - MangaTag( - key = a.attr("href").removeSuffix('/').substringAfterLast('/'), - title = a.text().ifEmpty { return@mapNotNullToSet null }.toTitleCase(), + rating = -1f, + tags = emptySet(), + authors = emptySet(), + state = null, + source = source, + contentRating = null, + ) + } + } + + private fun parsePopularList(doc: Document) = + parseCardList(doc, "popular-grid", "popular-card", "popular-card__cover") + + private fun parseLatestList(doc: Document) = + parseCardList(doc, "latest-grid", "latest-card", "latest-card__cover") + + private fun parseGenreList(doc: Document) = + parseCardList(doc, "listing-grid", "popular-card", "popular-card__cover") + + override suspend fun getDetails(manga: Manga): Manga { + val baseUrl = manga.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(baseUrl).parseHtml() + val article = doc.selectFirst("article.series-page") ?: return manga + + val title = article.selectFirst("h1")?.text()?.trim() ?: manga.title + val cover = article.selectFirst(".series-cover-frame img")?.imgAttr() ?: manga.coverUrl + val description = article.selectFirst("p.series-summary")?.text()?.trim() + + val author = article.fact("Author(s)") + val state = when (article.fact("status")?.lowercase()) { + null -> null + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + else -> null + } + + val tags = article.select("dd.fact-genres a").mapNotNull { a -> + val key = a.attr("href").removeSuffix("/").substringAfterLast("/") + if (key.isBlank()) return@mapNotNull null + MangaTag(key = key, title = a.text().trim().toTitleCase(), source = source) + }.toSet() + + val chapters = loadAllChapters(doc, baseUrl) + + return manga.copy( + title = title, + coverUrl = cover, + description = description, + tags = tags, + authors = setOfNotNull(author), + state = state, + chapters = chapters, + ) + } + + private suspend fun loadAllChapters(firstPage: Document, firstUrl: String): List { + val chapters = mutableListOf() + val seenHrefs = mutableSetOf() + + var currentDoc = firstPage + var currentUrl = firstUrl + + while (true) { + currentDoc.select("a.release-row").forEach { a -> + val href = a.attr("href").takeIf { it.isNotBlank() } ?: return@forEach + if (!seenHrefs.add(href)) return@forEach + + val name = a.selectFirst("strong")?.text()?.trim() ?: a.text().trim() + val uploadDate = a.selectFirst("time") + ?.attr("datetime") + ?.takeIf { it.isNotBlank() } + ?.let(::parseIsoDate) + ?: 0L + + chapters.add( + MangaChapter( + id = generateUid(href), + title = name, + number = extractChapterNumber(name), + volume = 0, + url = href, + uploadDate = uploadDate, + scanlator = null, + branch = null, source = source, - ) - }.orEmpty(), - authors = setOfNotNull(author), - state = when (summary?.selectFirst(".mg_status")?.selectFirst(".summary-content")?.ownText() - ?.lowercase().orEmpty()) { - in ongoing -> MangaState.ONGOING - in finished -> MangaState.FINISHED - else -> null - }, + ), + ) + } + + val nextHref = currentDoc + .select(".pagination a.page-arrow") + .firstOrNull { it.text().trim() == "ยป" } + ?.attr("href") + ?.takeIf { it.isNotBlank() && !it.startsWith("#") } + ?: break + + val nextAbs = nextHref.toAbsoluteUrl(domain) + if (nextAbs == currentUrl) break + currentUrl = nextAbs + currentDoc = webClient.httpGet(currentUrl).parseHtml() + } + return chapters.reversed() + } + + private fun Element.fact(label: String): String? = + select(".series-facts dl > div").firstOrNull { div -> + div.selectFirst("dt")?.text()?.trim()?.equals(label, ignoreCase = true) == true + }?.selectFirst("dd")?.text()?.trim()?.takeIf { it.isNotBlank() && it != "-" } + + private fun parseIsoDate(iso: String): Long = + try { Instant.parse(iso).toEpochMilli() } catch (_: Exception) { 0L } + + private fun extractChapterNumber(name: String): Float { + val m = Regex("""(\d+(?:\.\d+)?)""").find(name) ?: return 0f + return m.groupValues[1].toFloatOrNull() ?: 0f + } + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() + val images = doc.select("figure.reader-page-image img") + if (images.isEmpty()) return emptyList() + + return images.mapIndexedNotNull { index, img -> + val src = img.attr("src").trim().takeIf { it.isNotBlank() } + ?: img.attr("data-src").trim().takeIf { it.isNotBlank() } + ?: return@mapIndexedNotNull null + + MangaPage( + id = generateUid("${chapter.url}#$index"), + url = src.toAbsoluteUrl(domain), + preview = null, source = source, - contentRating = if (isNsfwSource) ContentRating.ADULT else null, ) } } + + private fun Element.imgAttr(): String = when { + hasAttr("data-src") -> attr("abs:data-src") + hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") + hasAttr("srcset") -> attr("abs:srcset").substringBefore(" ") + hasAttr("data-cfsrc") -> attr("abs:data-cfsrc") + else -> attr("abs:src") + } } From 3c6a6a321a15fe44d815b3064688fb5294f7cddd Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:56:55 +0200 Subject: [PATCH 12/33] Update Manhuaus.kt --- src/main/kotlin/tsuki/site/en/Manhuaus.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/tsuki/site/en/Manhuaus.kt b/src/main/kotlin/tsuki/site/en/Manhuaus.kt index f169d4b1..e351e9f6 100644 --- a/src/main/kotlin/tsuki/site/en/Manhuaus.kt +++ b/src/main/kotlin/tsuki/site/en/Manhuaus.kt @@ -2,12 +2,12 @@ package tsuki.site.en import tsuki.MangaLoaderContext import tsuki.MangaSourceParser -import tsuki.model.MangaListFilterCapabilities import tsuki.parsers.MadaraParser import tsuki.model.MangaListFilterOptions import tsuki.model.MangaParserSource import tsuki.model.MangaTag +import tsuki.model.MangaListFilterCapabilities import androidx.collection.arraySetOf @@ -77,4 +77,4 @@ internal class Manhuaus(context: MangaLoaderContext): MangaTag("Yaoi", "yaoi", source), MangaTag("Yuri", "yuri", source), ) -} \ No newline at end of file +} From cafd79d0668ceb3e8cc4e293507e6f82f8215383 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:57:38 +0200 Subject: [PATCH 13/33] Update TopManhua.kt --- src/main/kotlin/tsuki/site/en/TopManhua.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/tsuki/site/en/TopManhua.kt b/src/main/kotlin/tsuki/site/en/TopManhua.kt index 3ca840d1..199f9faa 100644 --- a/src/main/kotlin/tsuki/site/en/TopManhua.kt +++ b/src/main/kotlin/tsuki/site/en/TopManhua.kt @@ -4,12 +4,13 @@ import tsuki.MangaLoaderContext import tsuki.MangaSourceParser import tsuki.parsers.MadaraParser +import tsuki.model.ContentType import tsuki.model.MangaParserSource -@MangaSourceParser("TOPMANHUA", "ManhuaTop", "en") +@MangaSourceParser("TOPMANHUA", "ManhuaTop", "en", ContentType.MANHUA) internal class TopManhua(context: MangaLoaderContext) : MadaraParser(context, MangaParserSource.TOPMANHUA, "manhuatop.org") { override val tagPrefix = "manhua-genre/" override val listUrl = "manhua/" override val datePattern = "MM/dd/yyyy" -} \ No newline at end of file +} From fb60db4736c782d4c1f4c61f4b5fb8245882852a Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:58:19 +0200 Subject: [PATCH 14/33] Update ManhwaTop.kt --- src/main/kotlin/tsuki/site/en/ManhwaTop.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/tsuki/site/en/ManhwaTop.kt b/src/main/kotlin/tsuki/site/en/ManhwaTop.kt index 9d9506d7..a7ee4cfa 100644 --- a/src/main/kotlin/tsuki/site/en/ManhwaTop.kt +++ b/src/main/kotlin/tsuki/site/en/ManhwaTop.kt @@ -4,8 +4,9 @@ import tsuki.MangaLoaderContext import tsuki.MangaSourceParser import tsuki.parsers.MadaraParser +import tsuki.model.ContentType import tsuki.model.MangaParserSource -@MangaSourceParser("MANHWATOP", "ManhwaTop", "en") +@MangaSourceParser("MANHWATOP", "ManhwaTop", "en", ContentType.MANHWA) internal class ManhwaTop(context: MangaLoaderContext) : - MadaraParser(context, MangaParserSource.MANHWATOP, "manhwatop.com") \ No newline at end of file + MadaraParser(context, MangaParserSource.MANHWATOP, "manhwatop.com") From 936eb7a71ffb87436869d391e061d5d45ac1cdde Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:59:05 +0200 Subject: [PATCH 15/33] Update KeyoApp.kt --- src/main/kotlin/tsuki/parsers/KeyoApp.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/tsuki/parsers/KeyoApp.kt b/src/main/kotlin/tsuki/parsers/KeyoApp.kt index 536b4fdb..f8fc29ab 100644 --- a/src/main/kotlin/tsuki/parsers/KeyoApp.kt +++ b/src/main/kotlin/tsuki/parsers/KeyoApp.kt @@ -25,12 +25,11 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.jsoup.nodes.Document import org.jsoup.nodes.Element -import java.time.LocalDate -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter +import java.text.SimpleDateFormat import java.util.Calendar import java.util.EnumSet import java.util.Locale +import java.util.TimeZone abstract class KeyoApp( context: MangaLoaderContext, @@ -44,7 +43,7 @@ abstract class KeyoApp( override val webClient = OkHttpWebClient(context.httpClient, source) protected val baseUrl get() = "https://$domain" - protected open val dateFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH) + protected open val dateFormat: String = "MMM d, yyyy" protected open val showPaidChapters: Boolean = false @@ -258,7 +257,6 @@ abstract class KeyoApp( protected open val descriptionSelector: String = "#expand_content p" protected open val altNameSelector: String = "div.font-medium:containsOwn(Alternative titles) ~ div span" - protected open val altNamePrefix: String = "Alternative names:" protected open val statusSelector: String = "div:has(span:containsOwn(Status)) ~ div" protected open val authorSelector: String = "div:has(span:containsOwn(Author)) ~ div" protected open val artistSelector: String = "div:has(span:containsOwn(Artist)) ~ div" @@ -415,10 +413,9 @@ abstract class KeyoApp( if (text.isEmpty()) return 0L if (text.contains("ago", ignoreCase = true)) return parseRelativeDate() return try { - LocalDate.parse(text, dateFormat) - .atStartOfDay(ZoneOffset.UTC) - .toInstant() - .toEpochMilli() + SimpleDateFormat(dateFormat, Locale.ENGLISH).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.parse(text)?.time ?: 0L } catch (_: Exception) { 0L } From 544dfefb6a305bddcf1602394e66a6902e1ab61e Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:57:22 +0200 Subject: [PATCH 16/33] Update TempleScan.kt --- .../kotlin/tsuki/site/en/nsfw/TempleScan.kt | 363 ++++++++---------- 1 file changed, 153 insertions(+), 210 deletions(-) diff --git a/src/main/kotlin/tsuki/site/en/nsfw/TempleScan.kt b/src/main/kotlin/tsuki/site/en/nsfw/TempleScan.kt index 242b3dd5..cfba460c 100644 --- a/src/main/kotlin/tsuki/site/en/nsfw/TempleScan.kt +++ b/src/main/kotlin/tsuki/site/en/nsfw/TempleScan.kt @@ -4,8 +4,10 @@ import tsuki.MangaLoaderContext import tsuki.MangaSourceParser import tsuki.config.ConfigKey import tsuki.core.PagedMangaParser +import tsuki.network.CommonHeaders import tsuki.model.ContentRating +import tsuki.model.ContentType import tsuki.model.Manga import tsuki.model.MangaChapter import tsuki.model.MangaListFilter @@ -17,19 +19,18 @@ import tsuki.model.MangaState import tsuki.model.MangaTag import tsuki.model.RATING_UNKNOWN import tsuki.model.SortOrder -import tsuki.model.ContentType +import tsuki.util.extractChapterNumber import tsuki.util.generateUid import tsuki.util.json.extractNextJs +import tsuki.util.parseHtml import tsuki.util.parseSafe import tsuki.util.toAbsoluteUrl -import tsuki.util.extractChapterNumber -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.Response +import okhttp3.Headers import org.json.JSONArray import org.json.JSONObject -import org.jsoup.Jsoup +import org.jsoup.nodes.Document import java.text.SimpleDateFormat import java.util.EnumSet import java.util.Locale @@ -40,31 +41,24 @@ internal class TempleScan(context: MangaLoaderContext) : override val configKeyDomain = ConfigKey.Domain("templetoons.com") - override fun onCreateConfig(keys: MutableCollection>) { - super.onCreateConfig(keys) - keys.add(userAgentKey) - } - - override fun getRequestHeaders(): okhttp3.Headers { - val builder = super.getRequestHeaders().newBuilder() - .set("Referer", "https://$domain/") - .set("Origin", "https://$domain") - .set("User-Agent", config[userAgentKey]) - return builder.build() - } - - private val rscHeaders get() = getRequestHeaders().newBuilder() - .set("rsc", "1") - .build() - - @Volatile - private var seriesCache: List? = null + override fun getRequestHeaders(): Headers = + super.getRequestHeaders().newBuilder() + .set(CommonHeaders.REFERER, "https://$domain/") + .set(CommonHeaders.ORIGIN, "https://$domain") + .set(CommonHeaders.USER_AGENT, USER_AGENT) + .set(CommonHeaders.ACCEPT, "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") + .set(CommonHeaders.ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .set(CommonHeaders.SEC_FETCH_DEST, "document") + .set(CommonHeaders.SEC_FETCH_MODE, "navigate") + .set(CommonHeaders.SEC_FETCH_SITE, "none") + .set(CommonHeaders.UPGRADE_INSECURE_REQUESTS, "1") + .build() override val availableSortOrders: Set = EnumSet.of( SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.NEWEST, - SortOrder.ALPHABETICAL + SortOrder.ALPHABETICAL, ) override val filterCapabilities: MangaListFilterCapabilities @@ -85,20 +79,15 @@ internal class TempleScan(context: MangaLoaderContext) : ) override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - if (seriesCache == null) { - val response = webClient.httpGet("https://$domain/comics".toHttpUrl(), rscHeaders) - seriesCache = parseBrowseSeries(response) - } - - val allSeries = seriesCache ?: return emptyList() + val allSeries = fetchCatalog() val statusFilter = filter.states.firstOrNull() + val query = filter.query?.trim()?.takeIf(String::isNotBlank) + val filtered = allSeries.filter { series -> - val query = filter.query?.trim()?.takeIf(String::isNotBlank) - val matchesQuery = if (query != null) { - series.title.contains(query, ignoreCase = true) || - (series.alternativeNames?.contains(query, ignoreCase = true) == true) - } else true + val matchesQuery = query == null || + series.title.contains(query, ignoreCase = true) || + (series.alternativeNames?.contains(query, ignoreCase = true) == true) val matchesStatus = statusFilter == null || series.status == stateToApi(statusFilter) matchesQuery && matchesStatus } @@ -112,8 +101,8 @@ internal class TempleScan(context: MangaLoaderContext) : } val startIndex = (page - 1) * pageSize - val endIndex = minOf(startIndex + pageSize, sorted.size) if (startIndex >= sorted.size) return emptyList() + val endIndex = minOf(startIndex + pageSize, sorted.size) return sorted.subList(startIndex, endIndex).map { item -> Manga( @@ -124,13 +113,7 @@ internal class TempleScan(context: MangaLoaderContext) : altTitles = emptySet(), coverUrl = item.thumbnail?.toAbsoluteUrl(domain), authors = emptySet(), - state = when (item.status) { - "Ongoing" -> MangaState.ONGOING - "Hiatus" -> MangaState.PAUSED - "Completed" -> MangaState.FINISHED - "Canceled", "Dropped" -> MangaState.ABANDONED - else -> null - }, + state = item.status.toMangaState(), contentRating = null, tags = emptySet(), rating = RATING_UNKNOWN, @@ -141,71 +124,93 @@ internal class TempleScan(context: MangaLoaderContext) : override suspend fun getDetails(manga: Manga): Manga { val slug = manga.url.removePrefix("/comic/") - val response = webClient.httpGet("https://$domain/comic/$slug".toHttpUrl(), rscHeaders) - val details = parseSeriesDetails(response) + val doc = webClient.httpGet("https://$domain/comic/$slug").parseHtml() + + val seriesLd = doc.extractComicSeriesLd() + val seriesData = doc.extractNextJs { it is JSONObject && it.has("seriesData") } as? JSONObject + val catalogEntry = fetchCatalog().firstOrNull { it.slug == slug } + + val genres = seriesLd?.optJSONArray("genre")?.let { arr -> + (0 until arr.length()).map { arr.getString(it) } + }.orEmpty() + val adult = genres.any { it.equals("+18", ignoreCase = true) } + + val altName = seriesLd?.optString("alternateName")?.takeIf { it.isNotBlank() } + val authorName = seriesLd?.optJSONObject("author")?.optString("name")?.takeIf { it.isNotBlank() } + + val rawDescription = doc.synopsis() + ?: seriesLd?.optString("description")?.takeIf { it.isNotBlank() } + + val description = buildString { + append(rawDescription.orEmpty()) + if (altName != null) { + if (isNotEmpty()) append("\n\n") + append("Alternative Name: ").append(altName) + } + } + + val chapters = parseChapters(slug, seriesData) return manga.copy( - title = details.title, - altTitles = details.alternativeNames?.let { setOf(it) } ?: emptySet(), - coverUrl = details.thumbnail?.toAbsoluteUrl(domain), - description = details.description?.let { desc -> - val cleanDesc = if (desc.contains("#")) desc.substringBefore("#") - .replace(TRAILING_WORD_REGEX, "").trim() - else desc - Jsoup.parse(cleanDesc).text() - }?.trim().orEmpty(), - authors = details.author?.let { setOf(it) } ?: emptySet(), - state = when (details.status) { - "Ongoing" -> MangaState.ONGOING - "Hiatus" -> MangaState.PAUSED - "Completed" -> MangaState.FINISHED - "Canceled", "Dropped" -> MangaState.ABANDONED - else -> null - }, - contentRating = if (details.adult) ContentRating.ADULT else ContentRating.SAFE, + title = seriesLd?.optString("name")?.takeIf { it.isNotBlank() } + ?: catalogEntry?.title ?: slug, + altTitles = altName?.let { setOf(it) } ?: emptySet(), + coverUrl = seriesLd?.optString("image")?.takeIf { it.isNotBlank() } + ?: catalogEntry?.thumbnail?.toAbsoluteUrl(domain) + ?: manga.coverUrl, + description = description, + authors = authorName?.let { setOf(it) } ?: emptySet(), + state = catalogEntry?.status.toMangaState(), + contentRating = if (adult) ContentRating.ADULT else ContentRating.SAFE, tags = buildSet { - details.badge?.let { add(MangaTag(it.lowercase(), it, source)) } - details.year?.let { add(MangaTag(it, it, source)) } - if (details.adult) add(MangaTag("adult", "Adult", source)) - details.tags?.forEach { tag -> - add(MangaTag(tag.tag.name.lowercase(), tag.tag.name, source)) - } - details.description?.let { desc -> - TAG_REGEX.findAll(desc).forEach { match -> - val tagName = match.groupValues[1] - add(MangaTag(tagName.lowercase(), tagName, source)) - } + catalogEntry?.badge?.let { add(MangaTag(it.lowercase(), it, source)) } + if (adult) add(MangaTag("adult", "Adult", source)) + genres.filterNot { it.equals("+18", ignoreCase = true) }.forEach { + add(MangaTag(it.lowercase(), it, source)) } }, - chapters = getChapterList(slug), + chapters = chapters, ) } - private suspend fun getChapterList(mangaSlug: String): List { - val response = webClient.httpGet("https://$domain/comic/$mangaSlug".toHttpUrl(), rscHeaders) - val chapterData = parseChapterList(response) ?: return emptyList() - - return chapterData.seasons.flatMap { season -> - season.chapters.filter { it.price == 0 }.map { chapter -> - MangaChapter( - id = generateUid("$mangaSlug/${chapter.slug}"), - title = chapter.name, - number = chapter.name.extractChapterNumber(), - url = "/comic/$mangaSlug/${chapter.slug}", - uploadDate = chapter.created, + private fun parseChapters(slug: String, seriesDataWrapper: JSONObject?): List { + val seriesData = seriesDataWrapper?.optJSONObject("seriesData") ?: return emptyList() + val seasons = seriesData.optJSONArray(FIELD_SEASONS) ?: return emptyList() + + val chapters = mutableListOf() + for (s in 0 until seasons.length()) { + val arr = seasons.getJSONObject(s).optJSONArray(FIELD_CHAPTERS) ?: continue + for (c in 0 until arr.length()) { + val chap = arr.getJSONObject(c) + if (chap.optInt(FIELD_PRICE, 0) > 0) continue + + val name = chap.optString(FIELD_CHAPTER_NAME).takeIf { it.isNotBlank() } ?: continue + val chapSlug = chap.optString(FIELD_CHAPTER_SLUG).takeIf { it.isNotBlank() } ?: continue + val title = chap.optString("chapter_title").takeIf { it.isNotBlank() } + + chapters += MangaChapter( + id = generateUid("$slug/$chapSlug"), + title = if (title != null) "$name: $title" else name, + number = name.extractChapterNumber(), + url = "/comic/$slug/$chapSlug", + uploadDate = parseIso(chap.optString("created_at", null)), source = source, volume = 0, scanlator = null, branch = null, ) } - }.sortedBy { it.number } + } + return chapters.reversed() } override suspend fun getPages(chapter: MangaChapter): List { - val response = webClient.httpGet("https://$domain${chapter.url}".toHttpUrl(), rscHeaders) - val pagesData = parsePagesList(response) - return pagesData.pages.mapIndexed { _, url -> + val response = webClient.httpGet("https://$domain${chapter.url}") + val obj = response.extractNextJs { it is JSONObject && it.has(FIELD_IMAGES) } as? JSONObject + ?: return emptyList() + val images = obj.optJSONArray(FIELD_IMAGES) ?: return emptyList() + return (0 until images.length()).map { i -> + val url = images.getString(i) MangaPage( id = generateUid(url), url = url.toAbsoluteUrl(domain), @@ -215,101 +220,59 @@ internal class TempleScan(context: MangaLoaderContext) : } } - private fun parseBrowseSeries(response: Response): List { - val jsonArray: JSONArray = response.extractNextJs { - it is JSONArray && it.length() > 0 && it.optJSONObject(0)?.has("series_slug") == true - } as? JSONArray - ?: throw RuntimeException("Browse series array not found") + private suspend fun fetchCatalog(): List { + val response = webClient.httpGet("https://$domain/comics") + val array = response.extractNextJs { + it is JSONArray && it.length() > 0 && it.optJSONObject(0)?.has(FIELD_SLUG) == true + } as? JSONArray ?: return emptyList() - return (0 until jsonArray.length()).map { i -> - jsonArray.getJSONObject(i).toSeriesItem() - } + return (0 until array.length()).map { array.getJSONObject(it).toSeriesItem() } } - private fun parseSeriesDetails(response: Response): SeriesDetails { - val obj: JSONObject = response.extractNextJs { - it is JSONObject && it.has("series_slug") && it.has("title") - } as? JSONObject - ?: throw RuntimeException("Series details not found") - return obj.toSeriesDetails() - } - - private fun parseChapterList(response: Response): ChapterList? { - val obj: JSONObject? = response.extractNextJs { - it is JSONObject && it.has("Season") - } as? JSONObject - return obj?.toChapterList() - } - - private fun parsePagesList(response: Response): PagesList { - val obj: JSONObject = response.extractNextJs { - it is JSONObject && it.has("pages") - } as? JSONObject - ?: throw RuntimeException("Pages list not found") - return obj.toPagesList() - } - - private fun JSONObject.toSeriesItem(): SeriesItem { - val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH) - return SeriesItem( - slug = getString("series_slug"), - title = getString("title"), - alternativeNames = optString("alternative_names", null), - thumbnail = optString("thumbnail", null), - status = optString("status", null), - updated = dateFormat.parseSafe(optString("update_chapter", null)), - created = dateFormat.parseSafe(optString("created_at", null)), - views = optLong("total_views", 0), - ) - } + private fun JSONObject.toSeriesItem(): SeriesItem = SeriesItem( + slug = getString(FIELD_SLUG), + title = getString(FIELD_TITLE), + alternativeNames = optString("alternative_names", null), + thumbnail = optString("thumbnail", null), + badge = optString("badge", null), + status = optString("status", null), + updated = parseIso(optString("update_chapter", null)), + created = parseIso(optString("created_at", null)), + views = optLong("total_views", 0), + ) - private fun JSONObject.toSeriesDetails(): SeriesDetails { - return SeriesDetails( - slug = getString("series_slug"), - title = getString("title"), - thumbnail = optString("thumbnail", null), - author = optString("author", null), - studio = optString("studio", null), - year = optString("release_year", null), - alternativeNames = optString("alternative_names", null), - adult = optBoolean("adult", false), - badge = optString("badge", null), - status = optString("status", null), - description = optString("description", null), - tags = optJSONArray("tag_series")?.let { arr -> - (0 until arr.length()).map { i -> - val tagObj = arr.getJSONObject(i).getJSONObject("tag") - TagWrapper(Tag(tagObj.getString("name"))) + private fun Document.extractComicSeriesLd(): JSONObject? = + select("script[type=application/ld+json]").mapNotNull { script -> + runCatching { JSONObject(script.data()) }.getOrNull() + }.firstNotNullOfOrNull { root -> + when { + root.optString("@type") == "ComicSeries" -> root + root.has("@graph") -> root.optJSONArray("@graph")?.let { graph -> + (0 until graph.length()) + .mapNotNull { graph.optJSONObject(it) } + .firstOrNull { it.optString("@type") == "ComicSeries" } } - }, - ) - } - - private fun JSONObject.toChapterList(): ChapterList { - val seasonsArray = getJSONArray("Season") - val seasons = (0 until seasonsArray.length()).map { i -> - val seasonObj = seasonsArray.getJSONObject(i) - val chaptersArray = seasonObj.getJSONArray("Chapter") - val chapters = (0 until chaptersArray.length()).map { j -> - val chap = chaptersArray.getJSONObject(j) - ChapterItem( - name = chap.getString("chapter_name"), - title = chap.optString("chapter_title", null), - slug = chap.getString("chapter_slug"), - price = chap.getInt("price"), - created = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH) - .parseSafe(chap.optString("created_at", null)), - ) + else -> null } - Season(chapters) } - return ChapterList(seasons) + + private fun Document.synopsis(): String? = + selectFirst("#series-synopsis-text")?.let { el -> + val ps = el.select("p") + if (ps.isNotEmpty()) ps.joinToString("\n\n") { it.text() } else el.text() + }?.takeIf { it.isNotBlank() } + + private fun parseIso(date: String?): Long { + if (date.isNullOrBlank()) return 0L + return DATE_FORMAT.parseSafe(date) } - private fun JSONObject.toPagesList(): PagesList { - val pagesArray = getJSONArray("pages") - val pages = (0 until pagesArray.length()).map { pagesArray.getString(it) } - return PagesList(pages) + private fun String?.toMangaState(): MangaState? = when (this?.lowercase()) { + "ongoing" -> MangaState.ONGOING + "hiatus" -> MangaState.PAUSED + "completed" -> MangaState.FINISHED + "canceled", "dropped" -> MangaState.ABANDONED + else -> null } private fun stateToApi(state: MangaState): String = when (state) { @@ -325,44 +288,24 @@ internal class TempleScan(context: MangaLoaderContext) : val title: String, val alternativeNames: String?, val thumbnail: String?, + val badge: String?, val status: String?, val updated: Long, val created: Long, val views: Long, ) - data class SeriesDetails( - val slug: String, - val title: String, - val thumbnail: String?, - val author: String?, - val studio: String?, - val year: String?, - val alternativeNames: String?, - val adult: Boolean, - val badge: String?, - val status: String?, - val description: String?, - val tags: List?, - ) - - data class TagWrapper(val tag: Tag) - data class Tag(val name: String) - - data class ChapterList(val seasons: List) - data class Season(val chapters: List) - data class ChapterItem( - val name: String, - val title: String?, - val slug: String, - val price: Int, - val created: Long, - ) - - data class PagesList(val pages: List) - companion object { - private val TAG_REGEX = Regex("""(?i)#(\w+)""") - private val TRAILING_WORD_REGEX = Regex("""[\w\s]+:?\s*$""") + private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" + private const val FIELD_TITLE = "pnsk6q" + private const val FIELD_SLUG = "s20a8oj" + private const val FIELD_SEASONS = "u2ytwc" + private const val FIELD_CHAPTERS = "qmy3ca" + private const val FIELD_CHAPTER_NAME = "u171tuh" + private const val FIELD_CHAPTER_SLUG = "y26ma5t" + private const val FIELD_PRICE = "u1e8nmi" + private const val FIELD_IMAGES = "nu7315" + + private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH) } } From 36a5f7eb0b0c6a5bb9d666bd77b6c9a5b5d2e8ed Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:59:33 +0200 Subject: [PATCH 17/33] Update MangaKParser.kt --- src/main/kotlin/tsuki/parsers/MangaKParser.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/kotlin/tsuki/parsers/MangaKParser.kt b/src/main/kotlin/tsuki/parsers/MangaKParser.kt index 8779d17f..b15c0849 100644 --- a/src/main/kotlin/tsuki/parsers/MangaKParser.kt +++ b/src/main/kotlin/tsuki/parsers/MangaKParser.kt @@ -161,6 +161,11 @@ internal abstract class MangaKParser( } } + filter.author?.trim()?.takeIf { it.isNotEmpty() }?.let { author -> + append("&author=") + append(author.urlEncoded()) + } + if (filter.tags.isNotEmpty()) { append("&genres=") append(filter.tags.joinToString(",") { it.key }) From 8c3b023ebc597fd9fc59c248ed73bbe7ef64b900 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:10:25 +0200 Subject: [PATCH 18/33] Update NHentaiParser.kt --- .../tsuki/site/all/nsfw/NHentaiParser.kt | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/tsuki/site/all/nsfw/NHentaiParser.kt b/src/main/kotlin/tsuki/site/all/nsfw/NHentaiParser.kt index 1684790d..9e35558c 100644 --- a/src/main/kotlin/tsuki/site/all/nsfw/NHentaiParser.kt +++ b/src/main/kotlin/tsuki/site/all/nsfw/NHentaiParser.kt @@ -55,6 +55,7 @@ internal class NHentaiParser(context: MangaLoaderContext) : override val filterCapabilities = MangaListFilterCapabilities( isSearchSupported = true, isSearchWithFiltersSupported = true, + isMultipleTagsSupported = true, ) private val preferredServerKey = ConfigKey.PreferredImageServer( @@ -816,10 +817,10 @@ internal class NHentaiParser(context: MangaLoaderContext) : } override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = nhTagMap.map { (id, name) -> + availableTags = nhTagMap.map { (_, name) -> MangaTag( title = name.replace("-", " ").replaceFirstChar { it.uppercase() }, - key = id, // numeric tag ID + key = name.replace(" ", "-"), source = source, ) }.toSet(), @@ -848,11 +849,7 @@ internal class NHentaiParser(context: MangaLoaderContext) : } - override suspend fun getListPage( - page: Int, - order: SortOrder, - filter: MangaListFilter, - ): List { + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { ensureNhConfig() val directId = extractGalleryId(filter.query) @@ -864,18 +861,17 @@ internal class NHentaiParser(context: MangaLoaderContext) : } } - 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 { LANG_MAP[it.language] } + val searchQuery = buildString { if (!filter.query.isNullOrBlank()) { - append("title:\"${filter.query}\"") + append("title:\"${filter.query!!.trim()}\"") + } + filter.tags.forEach { tag -> + if (isNotEmpty()) append(" ") + append("tag:\"") + append(tag.key) + append("\"") } if (lang != null) { if (isNotEmpty()) append(" ") From 750347d8ae0e49eda8e1c2bc7617c61871359044 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:32:15 +0200 Subject: [PATCH 19/33] Update MangaThemesia.kt --- .../kotlin/tsuki/parsers/MangaThemesia.kt | 81 ++++++++++++++++++- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/tsuki/parsers/MangaThemesia.kt b/src/main/kotlin/tsuki/parsers/MangaThemesia.kt index 289ba158..85f44129 100644 --- a/src/main/kotlin/tsuki/parsers/MangaThemesia.kt +++ b/src/main/kotlin/tsuki/parsers/MangaThemesia.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import okhttp3.Headers import org.json.JSONArray import org.jsoup.nodes.Document import org.jsoup.nodes.Element @@ -55,9 +56,16 @@ abstract class MangaThemesia( keys.add(userAgentKey) } - override fun getRequestHeaders() = super.getRequestHeaders().newBuilder() - .set(CommonHeaders.REFERER, "https://$domain/") - .build() + override fun getRequestHeaders(): Headers { + val builder = super.getRequestHeaders().newBuilder() + .set(CommonHeaders.REFERER, "https://$domain/") + + val userAgent = builder["User-Agent"] + ?: config[userAgentKey] + applyClientHints(builder, userAgent) + + return builder.build() + } protected open val mangaDirectory = "manga" protected open val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale("en")) @@ -429,7 +437,9 @@ abstract class MangaThemesia( ) } - protected fun Element.imgAttr(): String { + protected open fun Element.imgAttr(): String = imgAttrFallback() + + protected fun Element.imgAttrFallback(): String { 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 @@ -447,7 +457,70 @@ abstract class MangaThemesia( private fun SimpleDateFormat.parseSafe(date: String): Long = runCatching { parse(date)?.time ?: 0L }.getOrDefault(0L) + private fun applyClientHints(builder: Headers.Builder, userAgent: String) { + val info = detectBrowser(userAgent) ?: return + + val isMobile = userAgent.contains("Mobile") || + userAgent.contains("Android") || + userAgent.contains("iPhone") || + userAgent.contains("iPad") + + builder["Sec-CH-UA"] = buildSecChUa(info) + builder["Sec-CH-UA-Mobile"] = if (isMobile) "?1" else "?0" + builder["Sec-CH-UA-Platform"] = detectPlatform(userAgent) + builder["DNT"] = "1" + } + + /** Firefox and Safari don't send client hints*/ + private fun detectBrowser(userAgent: String): BrowserInfo? = when { + userAgent.contains("Firefox/") && !userAgent.contains("Chrome") -> null + userAgent.contains("Safari/") && + !userAgent.contains("Chrome") && + !userAgent.contains("Chromium") -> null + + userAgent.contains("Edg/") || userAgent.contains("EdgA/") || userAgent.contains("EdgiOS/") -> { + val edgeVersion = EDGE_REGEX.find(userAgent)?.groupValues?.get(1) ?: "134" + val chromiumVersion = CHROME_REGEX.find(userAgent)?.groupValues?.get(1) ?: edgeVersion + BrowserInfo("Microsoft Edge", edgeVersion, chromiumVersion) + } + + userAgent.contains("OPR/") -> { + val operaVersion = OPERA_REGEX.find(userAgent)?.groupValues?.get(1) ?: "118" + val chromiumVersion = CHROME_REGEX.find(userAgent)?.groupValues?.get(1) ?: "134" + BrowserInfo("Opera", operaVersion, chromiumVersion) + } + + userAgent.contains("Chrome/") -> { + val chromeVersion = CHROME_REGEX.find(userAgent)?.groupValues?.get(1) ?: "134" + BrowserInfo("Google Chrome", chromeVersion, chromeVersion) + } + + else -> null + } + + private fun detectPlatform(userAgent: String): String = when { + userAgent.contains("Windows") -> "\"Windows\"" + userAgent.contains("Android") -> "\"Android\"" + userAgent.contains("iPhone") || userAgent.contains("iPad") -> "\"iOS\"" + userAgent.contains("Macintosh") || userAgent.contains("Mac OS X") -> "\"macOS\"" + userAgent.contains("Linux") -> "\"Linux\"" + else -> "\"Windows\"" + } + + private fun buildSecChUa(info: BrowserInfo): String = + "\"${info.name}\";v=\"${info.version}\", \"Chromium\";v=\"${info.chromiumVersion}\", \"Not A(Brand\";v=\"24\"" + + private data class BrowserInfo( + val name: String, + val version: String, + val chromiumVersion: String, + ) + companion object { + private val CHROME_REGEX = Regex("""Chrome/(\d+)""") + private val EDGE_REGEX = Regex("""Edg[^/]*/(\d+)""") + private val OPERA_REGEX = Regex("""OPR/(\d+)""") + private val AUTHOR_PLACEHOLDERS = setOf("n/a", "N/A", "Updating") private val ongoingWordSet = WordSet( From a5803a379fc642211f4d5afd2de105727ffe5b3d Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:32:36 +0200 Subject: [PATCH 20/33] Create ManhwaDesu.kt --- .../kotlin/tsuki/site/id/nsfw/ManhwaDesu.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/main/kotlin/tsuki/site/id/nsfw/ManhwaDesu.kt diff --git a/src/main/kotlin/tsuki/site/id/nsfw/ManhwaDesu.kt b/src/main/kotlin/tsuki/site/id/nsfw/ManhwaDesu.kt new file mode 100644 index 00000000..1954ca2e --- /dev/null +++ b/src/main/kotlin/tsuki/site/id/nsfw/ManhwaDesu.kt @@ -0,0 +1,31 @@ +package tsuki.site.id.nsfw + +import tsuki.MangaLoaderContext +import tsuki.MangaSourceParser +import tsuki.parsers.MangaThemesia + +import tsuki.model.ContentType +import tsuki.model.MangaParserSource + +import tsuki.util.toAbsoluteUrl + +import org.jsoup.nodes.Element + +@MangaSourceParser("MANHWADESU", "ManhwaDesu", "id", ContentType.HENTAI) +internal class ManhwaDesu(context: MangaLoaderContext) : + MangaThemesia(context, MangaParserSource.MANHWADESU, "manhwadesu.wiki") { + + override val mangaDirectory = "komik" + + override fun Element.imgAttr(): String { + attributes() + .firstOrNull { it.key.endsWith("original-src") } + ?.let { attribute -> + val value = attr(attribute.key).trim() + if (value.isNotEmpty()) { + return value.toAbsoluteUrl(domain).substringBefore("?") + } + } + return imgAttrFallback() + } +} From 72b00e8476f32e750ccd12ab694fa427767e30bc Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:48:33 +0200 Subject: [PATCH 21/33] Create MmrcmsParser.kt --- src/main/kotlin/tsuki/parsers/MmrcmsParser.kt | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 src/main/kotlin/tsuki/parsers/MmrcmsParser.kt diff --git a/src/main/kotlin/tsuki/parsers/MmrcmsParser.kt b/src/main/kotlin/tsuki/parsers/MmrcmsParser.kt new file mode 100644 index 00000000..6ad3dad4 --- /dev/null +++ b/src/main/kotlin/tsuki/parsers/MmrcmsParser.kt @@ -0,0 +1,323 @@ +package tsuki.parsers + +import tsuki.MangaLoaderContext +import tsuki.config.ConfigKey +import tsuki.core.PagedMangaParser +import tsuki.network.CommonHeaders + +import tsuki.model.RATING_UNKNOWN +import tsuki.model.ContentRating +import tsuki.model.Manga +import tsuki.model.MangaChapter +import tsuki.model.MangaListFilter +import tsuki.model.MangaListFilterCapabilities +import tsuki.model.MangaListFilterOptions +import tsuki.model.MangaPage +import tsuki.model.MangaParserSource +import tsuki.model.MangaTag +import tsuki.model.MangaState +import tsuki.model.SortOrder + +import tsuki.util.generateUid +import tsuki.util.toAbsoluteUrl +import tsuki.util.attrAsRelativeUrl +import tsuki.util.parseHtml +import tsuki.util.urlEncoded +import tsuki.util.mapChapters +import tsuki.util.mapNotNullToSet +import tsuki.util.parseSafe +import tsuki.util.src +import tsuki.util.requireSrc +import tsuki.util.textOrNull +import tsuki.util.toRelativeUrl +import tsuki.util.removeSuffix + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.select.Elements +import java.text.SimpleDateFormat +import java.util.EnumSet +import java.util.Locale + +abstract class MmrcmsParser( + 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() + .set(CommonHeaders.REFERER, "https://$domain/") + .build() + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.POPULARITY, + SortOrder.POPULARITY_ASC, + SortOrder.UPDATED, + SortOrder.ALPHABETICAL, + SortOrder.ALPHABETICAL_DESC, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + ) + + init { + paginator.firstPage = 1 + searchPaginator.firstPage = 1 + } + + protected open val itemPath = "manga" + protected open val dateFormat = SimpleDateFormat("d MMM. yyyy", Locale.US) + protected open val fetchFilterOptions = true + protected open val supportsAdvancedSearch = true + protected open val detailsTitleSelector = ".listmanga-header, .widget-title" + protected open val chapterNamePrefix = "" + protected open val chapterString = "Chapter" + + protected open val imgUpdated = "/cover/cover_250x350.jpg" + protected open val listUrl = "filterList" + protected open val tagUrl = "manga-list" + protected open val datePattern = "dd MMM. yyyy" + + protected open val selectDesc = "div.well" + protected open val selectState = "dt:contains(Statut)" + protected open val selectAlt = "dt:contains(Autres noms)" + protected open val selectAut = "dt:contains(Auteur(s))" + protected open val selectTag = "dt:contains(Catรฉgories)" + protected open val selectDate = "div.date-chapter-title-rtl" + protected open val selectChapter = "ul.chapters > li:not(.btn)" + protected open val selectPage = "div#all img" + + @Volatile + private var tagsCache: Set? = null + private val tagsMutex = Mutex() + + override suspend fun getFilterOptions(): MangaListFilterOptions { + val tags = if (fetchFilterOptions) { + tagsCache ?: fetchAvailableTags().also { tagsCache = it } + } else { + emptySet() + } + return MangaListFilterOptions( + availableTags = tags, + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, + MangaState.PAUSED, + MangaState.ABANDONED, + ), + ) + } + + protected open suspend fun fetchAvailableTags(): Set { + val doc = webClient.httpGet("https://$domain/$tagUrl/").parseHtml() + return doc.select("ul.list-category li").mapNotNullToSet { li -> + val a = li.selectFirst("a") ?: return@mapNotNullToSet null + val href = a.attr("href").substringAfterLast("cat=") + if (href.isBlank()) return@mapNotNullToSet null + MangaTag(key = href, title = a.text(), source = source) + } + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + if (order == SortOrder.UPDATED && (filter.query != null || filter.tags.isNotEmpty())) { + throw IllegalArgumentException( + "Sorting by update with filters is not supported by this source.", + ) + } + + if (order == SortOrder.UPDATED) { + val url = "https://$domain/latest-release?page=$page" + return parseMangaListUpdated(webClient.httpGet(url).parseHtml()) + } + + val url = buildString { + append("https://") + append(domain) + append('/') + append(listUrl) + append("/?page=") + append(page) + + append("&author=&tag=&alpha=") + filter.query?.let { append(it.urlEncoded()) } + + append("&cat=") + filter.tags.firstOrNull()?.let { append(it.key) } + + append("&sortBy=") + when (order) { + SortOrder.POPULARITY -> append("views&asc=false") + SortOrder.POPULARITY_ASC -> append("views&asc=true") + SortOrder.ALPHABETICAL -> append("name&asc=true") + SortOrder.ALPHABETICAL_DESC -> append("name&asc=false") + else -> append("name&asc=true") + } + } + return parseMangaList(webClient.httpGet(url).parseHtml()) + } + + protected open fun parseMangaList(doc: Document): List = + doc.select("div.media").mapNotNull { div -> + val href = div.selectFirst("a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null + Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + coverUrl = div.selectFirst("img")?.src(), + title = div.selectFirst("div.media-body h5")?.text().orEmpty(), + altTitles = emptySet(), + rating = div.selectFirst("span")?.ownText()?.toFloatOrNull()?.div(5f) ?: RATING_UNKNOWN, + tags = emptySet(), + authors = emptySet(), + state = null, + source = source, + contentRating = if (isNsfwSource) ContentRating.ADULT else null, + ) + } + + protected open fun parseMangaListUpdated(doc: Document): List = + doc.select("div.manga-item").mapNotNull { div -> + val href = div.selectFirst("a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null + val deeplink = href.substringAfterLast("/") + Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + coverUrl = "https://$domain/uploads/manga/$deeplink$imgUpdated", + title = div.selectFirst("h3 a")?.text().orEmpty(), + 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 body = doc.body().selectFirst("dl.dl-horizontal") + + val chaptersDeferred = async { loadChapters(doc) } + + val desc = doc.selectFirst(selectDesc)?.text().orEmpty() + val stateDiv = body?.selectFirst(selectState)?.nextElementSibling() + val state = stateDiv?.let { + when (it.text()) { + in ongoing -> MangaState.ONGOING + in finished -> MangaState.FINISHED + else -> null + } + } + val alt = doc.body().selectFirst(selectAlt)?.nextElementSibling()?.textOrNull() + val author = doc.body().selectFirst(selectAut)?.nextElementSibling()?.textOrNull() + val tags = doc.body().selectFirst(selectTag)?.nextElementSibling()?.select("a").orEmpty() + + manga.copy( + tags = tags.mapNotNullToSet { a -> + MangaTag( + key = a.attr("href").removeSuffix('/').substringAfterLast('/'), + title = a.text(), + source = source, + ) + }, + authors = setOfNotNull(author), + description = desc, + altTitles = setOfNotNull(alt), + state = state, + chapters = chaptersDeferred.await(), + ) + } + + protected open suspend fun loadChapters(doc: Document): List { + val fmt = SimpleDateFormat(datePattern, Locale.US) + return doc.body().select(selectChapter) + .mapChapters(reversed = true) { i, li -> + val a = li.selectFirst("a") ?: return@mapChapters null + val href = a.attrAsRelativeUrl("href") + val dateText = li.selectFirst(selectDate)?.text() + MangaChapter( + id = generateUid(href), + title = li.selectFirst("h5")?.textOrNull(), + number = i + 1f, + volume = 0, + url = href, + uploadDate = fmt.parseSafe(dateText), + source = source, + scanlator = null, + branch = null, + ) + } + } + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() + return doc.select(selectPage).mapNotNull { img -> + val url = img.requireSrc().toRelativeUrl(domain) + MangaPage(id = generateUid(url), url = url, preview = null, source = source) + } + } + + protected fun String?.toMangaState(): MangaState? = when (this?.lowercase(Locale.US)) { + "complete", "completed" -> MangaState.FINISHED + "ongoing", "on going" -> MangaState.ONGOING + "dropped", "cancelled", "canceled" -> MangaState.ABANDONED + else -> null + } + + @JvmField + protected val ongoing: Set = hashSetOf( + "On Going", + "Ongoing", + "En cours", + "En curso", + "DEVAM EDฤฐYOR", + "ู…ุณุชู…ุฑุฉ", + ) + + @JvmField + protected val finished: Set = hashSetOf( + "Completed", + "Completo", + "Complete", + "Terminรฉ", + "TAMAMLANDI", + "ู…ูƒุชู…ู„ุฉ", + ) + + protected open fun guessCover(mangaUrl: String, url: String?): String? = + if (url == null || url.endsWith("no-image.png")) { + "https://$domain/uploads/manga/${mangaUrl.substringAfterLast('/')}/cover/cover_250x350.jpg" + } else { + url + } + + protected fun Element.imgAttr(): String = when { + hasAttr("data-background-image") -> absUrl("data-background-image") + hasAttr("data-cfsrc") -> absUrl("data-cfsrc") + hasAttr("data-lazy-src") -> absUrl("data-lazy-src") + hasAttr("data-src") -> absUrl("data-src") + else -> absUrl("src") + } + + protected fun Elements.textWithNewlines() = run { + select("p, br").prepend("\\n") + text().replace("\\n", "\n").replace("\n ", "\n") + } +} From 8c3eb4060c72d70ce6c878459e307734ed09b9e3 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:49:00 +0200 Subject: [PATCH 22/33] Create ReadComicsOnline.kt --- .../kotlin/tsuki/site/en/ReadComicsOnline.kt | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 src/main/kotlin/tsuki/site/en/ReadComicsOnline.kt diff --git a/src/main/kotlin/tsuki/site/en/ReadComicsOnline.kt b/src/main/kotlin/tsuki/site/en/ReadComicsOnline.kt new file mode 100644 index 00000000..2c5ddb73 --- /dev/null +++ b/src/main/kotlin/tsuki/site/en/ReadComicsOnline.kt @@ -0,0 +1,231 @@ +package tsuki.site.en + +import tsuki.MangaLoaderContext +import tsuki.MangaSourceParser +import tsuki.parsers.MmrcmsParser + +import tsuki.model.ContentRating +import tsuki.model.ContentType +import tsuki.model.Manga +import tsuki.model.MangaChapter +import tsuki.model.MangaListFilter +import tsuki.model.MangaListFilterCapabilities +import tsuki.model.MangaPage +import tsuki.model.MangaParserSource +import tsuki.model.MangaState +import tsuki.model.MangaTag +import tsuki.model.RATING_UNKNOWN +import tsuki.model.SortOrder +import tsuki.model.MangaListFilterOptions + +import tsuki.util.generateUid +import tsuki.util.parseJson +import tsuki.util.urlEncoded +import tsuki.util.attrAsRelativeUrl +import tsuki.util.mapChapters +import tsuki.util.nullIfEmpty +import tsuki.util.parseHtml +import tsuki.util.parseSafe +import tsuki.util.requireSrc +import tsuki.util.src +import tsuki.util.textOrNull +import tsuki.util.toAbsoluteUrl +import tsuki.util.toRelativeUrl + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import tsuki.util.mapNotNullToSet +import java.text.SimpleDateFormat +import java.util.EnumSet +import java.util.Locale + +// TODO: getListpage use advanced-search + +@MangaSourceParser("READCOMICSONLINE", "ReadComicsOnline.ru", "en", ContentType.COMICS) +internal class ReadComicsOnline(context: MangaLoaderContext) : + MmrcmsParser(context, MangaParserSource.READCOMICSONLINE, "readcomicsonline.ru") { + + private val chapterDateFormat = SimpleDateFormat("d MMM yyyy", Locale.US) + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.POPULARITY, + SortOrder.UPDATED, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = false, + ) + + override suspend fun getFilterOptions(): MangaListFilterOptions = MangaListFilterOptions() + + override val fetchFilterOptions = false + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + if (!filter.query.isNullOrEmpty()) { + return search(page, filter.query!!) + } + if (filter.tags.isNotEmpty() || filter.states.isNotEmpty()) { + return emptyList() + } + + val sort = when (order) { + SortOrder.UPDATED -> "latest" + else -> "views" + } + val doc = webClient.httpGet("https://$domain/comic-list?sort=$sort&page=$page").parseHtml() + return doc.select("div.comic-list-layout .grid > .group").mapNotNull(::parseMangaListItem) + } + + private suspend fun search(page: Int, query: String): List { + if (page > 1) return emptyList() + + val url = "https://$domain/search?query=${query.trim().urlEncoded()}" + val suggestions = webClient.httpGet(url).parseJson() + .optJSONArray("suggestions") ?: return emptyList() + + return (0 until suggestions.length()).mapNotNull { i -> + val jo = suggestions.optJSONObject(i) ?: return@mapNotNull null + val slug = jo.optString("data").nullIfEmpty() ?: return@mapNotNull null + val href = "/comic/$slug" + Manga( + id = generateUid(href), + title = jo.optString("value").nullIfEmpty() ?: slug, + altTitles = emptySet(), + url = href, + publicUrl = jo.optString("url").nullIfEmpty() + ?: href.toAbsoluteUrl(domain), + rating = RATING_UNKNOWN, + contentRating = if (isNsfwSource) ContentRating.ADULT else null, + coverUrl = guessCover(href, jo.optString("cover").nullIfEmpty()), + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + ) + } + } + + private fun parseMangaListItem(element: Element): Manga? { + val anchor = element.selectFirst("a.block.text-sm.font-semibold") ?: return null + val href = anchor.attrAsRelativeUrl("href") + return Manga( + id = generateUid(href), + title = anchor.text(), + altTitles = emptySet(), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + rating = RATING_UNKNOWN, + contentRating = if (isNsfwSource) ContentRating.ADULT else null, + coverUrl = guessCover(href, element.selectFirst("img")?.src()), + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + ) + } + + override suspend fun getDetails(manga: Manga): Manga = coroutineScope { + val fullUrl = manga.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl).parseHtml() + val chaptersDeferred = async { getChapters(doc, manga.title) } + + val title = doc.selectFirst("h1.text-2xl")?.textOrNull() ?: manga.title + + val coverFromPage = doc.selectFirst("img.w-full.rounded-xl") + ?.attr("src") + ?.nullIfEmpty() + ?.takeUnless { it.contains("cover_missing", ignoreCase = true) } + val coverUrl = coverFromPage + ?: guessCover(manga.url, null) + ?: manga.coverUrl + + val description = doc.selectFirst("div.bg-ink-900 > p.text-sm")?.textOrNull() + + val statusText = doc.select("div.mt-4.flex.flex-wrap.gap-2 > span") + .mapNotNull { it.textOrNull() } + .firstOrNull { it.lowercase(Locale.US) in STATUS_KEYWORDS } + + val publisher = doc.selectFirst("span.rc-chip")?.textOrNull() + + val tags = doc.select("dl div:contains(Genres:) a").mapNotNullToSet { a -> + val key = a.attr("href").removeSuffix("/").substringAfterLast('/') + if (key.isBlank()) return@mapNotNullToSet null + MangaTag(key = key, title = a.text(), source = source) + } + + manga.copy( + title = title, + coverUrl = coverUrl, + description = description, + state = parseState(statusText), + tags = tags, + authors = setOfNotNull(publisher), + chapters = chaptersDeferred.await(), + ) + } + + private fun parseState(value: String?): MangaState? = + when (value?.lowercase(Locale.US)) { + "complete", "completed" -> MangaState.FINISHED + "ongoing", "on going" -> MangaState.ONGOING + "dropped", "cancelled", "canceled" -> MangaState.ABANDONED + else -> null + } + + private fun getChapters(doc: Document, mangaTitle: String): List { + return doc.select(".overflow-hidden.border-ink-600 > a") + .mapChapters(reversed = true) { i, element -> + val href = element.attrAsRelativeUrl("href") + val chapterName = element.selectFirst(".text-brand-400")?.textOrNull() + ?: element.text() + MangaChapter( + id = generateUid(href), + title = cleanChapterName(mangaTitle, chapterName), + number = i + 1f, + volume = 0, + url = href, + uploadDate = chapterDateFormat.parseSafe( + element.selectFirst(".text-slate-500")?.text(), + ), + source = source, + scanlator = null, + branch = null, + ) + } + } + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() + return doc.select("#reader-all img").mapNotNull { img -> + val url = img.requireSrc().toRelativeUrl(domain) + MangaPage(id = generateUid(url), url = url, preview = null, source = source) + } + } + + private fun cleanChapterName(mangaTitle: String, chapterName: String): String { + return chapterName + .removePrefix(mangaTitle) + .trimStart(' ', '-', ':') + .nullIfEmpty() + ?: chapterName + } + + override fun guessCover(mangaUrl: String, url: String?): String? { + url?.takeUnless { it.contains("/cover/cover_missing.", ignoreCase = true) }?.let { + return it + } + val slug = mangaUrl.removeSuffix("/").substringAfterLast('/').nullIfEmpty() ?: return null + return "https://$domain/uploads/manga/$slug/cover/cover_250x350.jpg" + } + + companion object { + private val STATUS_KEYWORDS = setOf( + "ongoing", "on going", "completed", "complete", + "dropped", "cancelled", "canceled", "hiatus", + ) + } +} From b1d9078e71e858c152def1b9464a9f24be6bf07e Mon Sep 17 00:00:00 2001 From: biglag Date: Mon, 28 Sep 2026 21:40:59 +0800 Subject: [PATCH 23/33] Fix MangaLib timeout and MangaBall details rating --- src/main/kotlin/tsuki/parsers/LibSocialParser.kt | 15 ++++++++++----- src/main/kotlin/tsuki/site/all/Mangaball.kt | 7 +++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/tsuki/parsers/LibSocialParser.kt b/src/main/kotlin/tsuki/parsers/LibSocialParser.kt index 5a4adbbf..edb0548f 100644 --- a/src/main/kotlin/tsuki/parsers/LibSocialParser.kt +++ b/src/main/kotlin/tsuki/parsers/LibSocialParser.kt @@ -51,7 +51,6 @@ import androidx.collection.ScatterMap import androidx.collection.intObjectMapOf import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.runBlocking import okhttp3.HttpUrl import okhttp3.Interceptor import okhttp3.Response @@ -71,17 +70,19 @@ internal abstract class LibSocialParser( protected open val apiHost = "api.cdnlibs.org" + @Volatile + private var accessToken: String? = null + override val userAgentKey = ConfigKey.UserAgent(UserAgents.CHROME_MOBILE) override val authUrl: String get() = "https://$domain/ru/front/auth" override suspend fun isAuthorized(): Boolean { - val token = getAuthData()?.optJSONObject("token")?.getStringOrNull("access_token") - return !token.isNullOrEmpty() + return !refreshAuthData()?.optJSONObject("token")?.getStringOrNull("access_token").isNullOrEmpty() } - override suspend fun getUsername(): String = getAuthData() + override suspend fun getUsername(): String = refreshAuthData() ?.getJSONObject("auth") ?.getString("username") ?: throw AuthRequiredException(source) @@ -117,7 +118,7 @@ internal abstract class LibSocialParser( ) override fun intercept(chain: Interceptor.Chain): Response { - val token = runBlocking { getAuthData() }?.optJSONObject("token")?.getStringOrNull("access_token") + val token = accessToken val requestBuilder = chain.request().newBuilder() if (!token.isNullOrEmpty()) { requestBuilder.header("Authorization", "Bearer $token") @@ -460,6 +461,10 @@ internal abstract class LibSocialParser( return result } + private suspend fun refreshAuthData(): JSONObject? = getAuthData().also { authData -> + accessToken = authData?.optJSONObject("token")?.getStringOrNull("access_token") + } + private suspend fun getAuthData(): JSONObject? { val raw = WebViewHelper(context).getLocalStorageValue(domain, "auth") ?: return null return JSONObject(raw.unescapeJson().removeSurrounding('"')) diff --git a/src/main/kotlin/tsuki/site/all/Mangaball.kt b/src/main/kotlin/tsuki/site/all/Mangaball.kt index a6ffb97a..4ea19eb9 100644 --- a/src/main/kotlin/tsuki/site/all/Mangaball.kt +++ b/src/main/kotlin/tsuki/site/all/Mangaball.kt @@ -167,6 +167,12 @@ internal abstract class MangaBallParser( val title = doc.selectFirst("#comicDetail h6")?.ownTextOrNull()?.trim()?.nullIfEmpty() ?: manga.title val cover = doc.selectFirst("img.featured-cover")?.absUrl("src")?.nullIfEmpty() ?: manga.coverUrl val description = doc.selectFirst("#descriptionContent p")?.wholeText()?.trim()?.nullIfEmpty() + val rating = doc.selectFirst("button[title='Click to view rating details & rate title'] > span") + ?.text() + ?.toFloatOrNull() + ?.takeIf { it in 0f..10f } + ?.div(10f) + ?: RATING_UNKNOWN val contentRating = when { tags.any { it.title in ADULT_TAG_TITLES } -> ContentRating.ADULT else -> manga.contentRating @@ -182,6 +188,7 @@ internal abstract class MangaBallParser( state = status, authors = authors, description = description, + rating = rating, chapters = getChapterList(manga.url), contentRating = contentRating, ) From 9775ce3977c4d7091350893c62ddc00b515d2b12 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:33:05 +0200 Subject: [PATCH 24/33] Create NexusToons.kt --- .../kotlin/tsuki/site/pt/nsfw/NexusToons.kt | 605 ++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 src/main/kotlin/tsuki/site/pt/nsfw/NexusToons.kt diff --git a/src/main/kotlin/tsuki/site/pt/nsfw/NexusToons.kt b/src/main/kotlin/tsuki/site/pt/nsfw/NexusToons.kt new file mode 100644 index 00000000..e460e8c6 --- /dev/null +++ b/src/main/kotlin/tsuki/site/pt/nsfw/NexusToons.kt @@ -0,0 +1,605 @@ +package tsuki.site.pt.nsfw + +import tsuki.MangaLoaderContext +import tsuki.MangaSourceParser +import tsuki.config.ConfigKey +import tsuki.core.PagedMangaParser + +import tsuki.model.ContentRating +import tsuki.model.ContentType +import tsuki.model.Manga +import tsuki.model.MangaChapter +import tsuki.model.MangaListFilter +import tsuki.model.MangaListFilterCapabilities +import tsuki.model.MangaListFilterOptions +import tsuki.model.MangaPage +import tsuki.model.MangaParserSource +import tsuki.model.MangaState +import tsuki.model.MangaTag +import tsuki.model.RATING_UNKNOWN +import tsuki.model.SortOrder + +import tsuki.util.extractChapterNumber +import tsuki.util.generateUid +import tsuki.util.nullIfEmpty +import tsuki.util.parseJson +import tsuki.util.urlEncoded + +import org.json.JSONObject +import tsuki.util.mapNotNullToSet +import java.text.SimpleDateFormat +import java.util.EnumSet +import java.util.Locale +import java.util.TimeZone +import kotlin.random.Random +import java.security.MessageDigest +import java.util.Base64 + +internal object NexusDecrypt { + + private const val CRYPTO_SECRET = "OrionNexus2025CryptoKey!Secure" + private const val NUM_KEYS = 5 + + private var keys: List? = null + private var initialized = false + + private data class KeyData( + val key: ByteArray, + val sbox: IntArray, + val rsbox: IntArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as KeyData + + if (!key.contentEquals(other.key)) return false + if (!sbox.contentEquals(other.sbox)) return false + if (!rsbox.contentEquals(other.rsbox)) return false + + return true + } + + override fun hashCode(): Int { + var result = key.contentHashCode() + result = 31 * result + sbox.contentHashCode() + result = 31 * result + rsbox.contentHashCode() + return result + } + } + + @Synchronized + private fun initialize() { + if (initialized) return + val derived = mutableListOf() + for (i in 0 until NUM_KEYS) { + val pattern = "_orion_key_${i}_v2_$CRYPTO_SECRET" + val hash = MessageDigest.getInstance("SHA-256").digest(pattern.toByteArray()) + val hex = hash.joinToString("") { "%02x".format(it) } + val keyBytes = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val kd = KeyData(keyBytes, IntArray(256), IntArray(256)) + initSBoxForKey(kd) + derived.add(kd) + } + keys = derived + initialized = true + } + + private fun initSBoxForKey(kd: KeyData) { + val key = kd.key + for (i in 0 until 256) kd.sbox[i] = i + var j = 0 + for (i in 0 until 256) { + j = (j + kd.sbox[i] + (key[i % key.size].toInt() and 0xFF)) % 256 + val tmp = kd.sbox[i] + kd.sbox[i] = kd.sbox[j] + kd.sbox[j] = tmp + } + for (i in 0 until 256) kd.rsbox[kd.sbox[i]] = i + } + + private fun rotateRight(byte: Int, shift: Int): Int { + val s = shift % 8 + return ((byte ushr s) or (byte shl (8 - s))) and 0xFF + } + + fun decrypt(keyIndex: Int, base64Data: String): String { + initialize() + val keyList = keys ?: throw IllegalStateException("Not initialized") + require(keyIndex in 0 until NUM_KEYS) { "Invalid key index: $keyIndex" } + + val kd = keyList[keyIndex] + val key = kd.key + val rsbox = kd.rsbox + + val input = Base64.getDecoder().decode(base64Data) + val output = ByteArray(input.size) + val keyLen = key.size + + for (i in input.size - 1 downTo 0) { + var byte = input[i].toInt() and 0xFF + + byte = if (i > 0) { + byte xor (input[i - 1].toInt() and 0xFF) + } else { + byte xor (key[keyLen - 1].toInt() and 0xFF) + } + + byte = rsbox[byte] + + val rotAmount = + ((key[(i + 3) % keyLen].toInt() and 0xFF) + (i and 0xFF) and 0xFF) % 7 + 1 + + byte = rotateRight(byte, rotAmount) + byte = byte xor (key[i % keyLen].toInt() and 0xFF) + + output[i] = byte.toByte() + } + + return String(output, Charsets.UTF_8) + } +} + +@MangaSourceParser("NEXUSTOONS", "Nexus Toons", "pt", ContentType.HENTAI) +internal class NexusToons(context: MangaLoaderContext) : + PagedMangaParser(context, MangaParserSource.NEXUSTOONS, pageSize = 50) { + + override val configKeyDomain = ConfigKey.Domain("nx-toons.xyz") + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override fun getRequestHeaders() = super.getRequestHeaders().newBuilder() + .set("Accept", "application/json") + .set("Referer", "https://$domain/") + .build() + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.NEWEST, + SortOrder.ALPHABETICAL, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isMultipleTagsSupported = true, + ) + + init { + paginator.firstPage = 1 + searchPaginator.firstPage = 1 + } + + override suspend fun getFilterOptions(): MangaListFilterOptions = MangaListFilterOptions( + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, + MangaState.PAUSED, + MangaState.ABANDONED, + ), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ContentType.COMICS, + ), + availableTags = NEXUS_GENRES.map { (title, slug) -> + MangaTag(key = slug, title = title, source = source) + }.toSet(), + ) + + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val url = buildString { + append("https://$domain/api/mangas") + append("?page=") + append(page) + append("&limit=50") + append("&includeNsfw=true") + + append("&sortBy=") + append( + when (order) { + SortOrder.POPULARITY -> "views" + SortOrder.UPDATED -> "lastChapterAt" + SortOrder.NEWEST -> "created" + SortOrder.ALPHABETICAL -> "title" + else -> "updatedAt" + }, + ) + append("&sortOrder=") + append(if (order == SortOrder.ALPHABETICAL) "asc" else "desc") + + filter.query?.trim()?.takeIf { it.isNotEmpty() }?.let { + append("&search=") + append(it.urlEncoded()) + } + + if (filter.states.isNotEmpty()) { + append("&status=") + append(filter.states.joinToString(",") { it.toApiStatus() }) + } + + if (filter.types.isNotEmpty()) { + append("&type=") + append(filter.types.joinToString(",") { it.toApiType() }) + } + + if (filter.tags.isNotEmpty()) { + append("&genres=") + append(filter.tags.joinToString(",") { it.key }) + } + } + + val json = fetchApi(url) + return parseMangaList(json) + } + + private fun parseMangaList(json: JSONObject): List { + val data = json.optJSONArray("data") ?: return emptyList() + return (0 until data.length()).mapNotNull { i -> + val item = data.optJSONObject(i) ?: return@mapNotNull null + val slug = item.optString("slug").nullIfEmpty() ?: return@mapNotNull null + Manga( + id = generateUid("/manga/$slug"), + url = "/manga/$slug", + publicUrl = "https://$domain/manga/$slug", + title = item.optString("title").orEmpty(), + altTitles = emptySet(), + coverUrl = item.optString("coverImage").nullIfEmpty(), + rating = RATING_UNKNOWN, + tags = emptySet(), + authors = emptySet(), + state = null, + source = source, + contentRating = ContentRating.ADULT, + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga { + val slug = manga.url.substringAfter("/manga/").trimEnd('/') + val json = fetchApi("https://$domain/api/manga/$slug") + + val chapters = json.optJSONArray("chapters")?.let { arr -> + (0 until arr.length()).mapNotNull { i -> + val ch = arr.optJSONObject(i) ?: return@mapNotNull null + val id = ch.optInt("id") + if (id == 0) return@mapNotNull null + val number = ch.optString("number").orEmpty() + val title = ch.optString("title").nullIfEmpty() + val createdAt = ch.optString("createdAt").orEmpty() + + MangaChapter( + id = generateUid("/read/$id/$slug"), + title = if (!title.isNullOrBlank()) { + "$title $number" + } else { + "Capรญtulo ${number.removeSuffix(".0")}" + }, + number = number.extractChapterNumber(), + volume = 0, + url = "/read/$id/$slug", + uploadDate = parseIsoDate(createdAt), + scanlator = null, + branch = null, + source = source, + ) + } + }.orEmpty() + + val categories = json.optJSONArray("categories")?.let { arr -> + (0 until arr.length()) + .mapNotNull { arr.optJSONObject(it)?.optString("name")?.nullIfEmpty() } + }.orEmpty() + + return manga.copy( + title = json.optString("title").nullIfEmpty() ?: manga.title, + coverUrl = json.optString("coverImage").nullIfEmpty() ?: manga.coverUrl, + description = json.optString("description").nullIfEmpty(), + authors = setOfNotNull( + json.optString("author").nullIfEmpty(), + json.optString("artist").nullIfEmpty(), + ), + state = json.optString("status").parseState(), + tags = categories.mapNotNullToSet { name -> + MangaTag( + key = name.lowercase(Locale.ROOT).replace(' ', '-'), + title = name, + source = source, + ) + }, + chapters = chapters, + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val chapterId = chapter.url + .substringAfter("/read/") + .substringBefore("/") + if (chapterId.isBlank()) return emptyList() + + val json = fetchApi("https://$domain/api/read/$chapterId") + + val pageToken = json.optString("pageToken").nullIfEmpty() + val pagesArr = json.optJSONArray("pages") ?: return emptyList() + + return (0 until pagesArr.length()).mapNotNull { i -> + val page = pagesArr.optJSONObject(i) ?: return@mapNotNull null + val imageUrl = page.optString("imageUrl").nullIfEmpty() + ?: pageToken?.let { "https://$domain/api/p/$it/$i" } + ?: return@mapNotNull null + MangaPage( + id = generateUid(imageUrl), + url = imageUrl, + preview = null, + source = source, + ) + } + } + + private suspend fun fetchApi(url: String): JSONObject { + val raw = webClient.httpGet(url).parseJson() + + val v = raw.optInt("v", 0) + if (v != 1 && v != 2) return raw + + val d = raw.optString("d").nullIfEmpty() ?: return raw + val k = if (v == 1) 0 else raw.optInt("k", 0) + + return JSONObject(NexusDecrypt.decrypt(k, d)) + } + + + private fun encodeChapterUrl(chapterId: String, mangaSlug: String): String { + val timestamp = System.currentTimeMillis().toString(36) + val padding = randomString(20 + Random.nextInt(11)) + val data = "$chapterId|$mangaSlug|$timestamp|$padding" + + val xored = xorCipher(data, CHAPTER_ENCRYPTION_KEY) + val firstEncode = base64UrlEncode(xored) + val secondEncode = base64UrlEncode("$firstEncode|${randomString(10)}") + + return if (secondEncode.length >= 64) { + secondEncode + } else { + secondEncode + randomString(64 - secondEncode.length) + } + } + + @Suppress("SameParameterValue") + private fun xorCipher(input: String, key: String): String = input.mapIndexed { i, char -> + (char.code xor key[i % key.length].code).toChar() + }.joinToString("") + + private fun base64UrlEncode(input: String): String { + val bytes = input.map { it.code.toByte() }.toByteArray() + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } + + private fun randomString(length: Int): String { + val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + return buildString(length) { + repeat(length) { append(chars.random()) } + } + } + + private fun MangaState.toApiStatus(): String = when (this) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + MangaState.PAUSED -> "hiatus" + MangaState.ABANDONED -> "cancelled" + else -> "" + } + + private fun ContentType.toApiType(): String = when (this) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.MANHUA -> "manhua" + ContentType.COMICS -> "comic" + else -> "" + } + + private fun String?.parseState(): MangaState? = when (this?.lowercase(Locale.ROOT)) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + "cancelled", "canceled" -> MangaState.ABANDONED + else -> null + } + + private fun parseIsoDate(text: String): Long { + if (text.isBlank()) return 0L + return try { + ISO_FORMAT.parse(text.substringBefore("."))?.time ?: 0L + } catch (_: Exception) { + 0L + } + } + + private companion object { + private const val CHAPTER_ENCRYPTION_KEY = + "NexusToons2026SecretKeyForChapterEncryption!@#$" + + private val ISO_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private val NEXUS_GENRES: List> = listOf( + "Academia de Magia" to "academia-de-magia", + "Acadรชmica" to "academica", + "Aรงรฃo" to "acao", + "Adaptaรงรฃo" to "adaptacao", + "Adaptaรงรฃo de Novel" to "adaptacao-de-novel", + "Adultรฉrio" to "adulterio", + "Adulto" to "adulto", + "Ahegao" to "ahegao", + "Apocalipse" to "apocalipse", + "App" to "app", + "Artes Marciais" to "artes-marciais", + "Aventura" to "aventura", + "BDSM" to "bdsm", + "Bondage" to "bondage", + "Bullying" to "bullying", + "Bunda Grande" to "bunda-grande", + "Campus" to "campus", + "Cartoon" to "cartoon", + "Casada" to "casada", + "Casamento" to "casamento", + "Club" to "club", + "Comรฉdia" to "comedia", + "Comรฉdia Romรขntica" to "comedia-romantica", + "Comida" to "comida", + "Cotidiano" to "cotidiano", + "Crime" to "crime", + "Culinรกria" to "culinaria", + "Cultivo" to "cultivo", + "Curta" to "curta", + "Cyberpunk" to "cyberpunk", + "Dark Romance" to "dark-romance", + "Demรดnio" to "demonio", + "Demรดnios" to "demonios", + "Domingรฃo" to "dominacao", + "Doujinshi" to "doujinshi", + "Dragรตes" to "dragoes", + "Drama" to "drama", + "Ecchi" to "ecchi", + "Escolar" to "escolar", + "Escritรณrio" to "escritorio", + "Esporte" to "esporte", + "Esportes" to "esportes", + "Estratรฉgia" to "estrategia", + "Estudante" to "estudante", + "Exibicionismo" to "exibicionismo", + "Famรญlia" to "familia", + "Famรญlia Real" to "familia-real", + "Fantasia" to "fantasia", + "Fantasmas" to "fantasmas", + "Fetiche" to "fetiche", + "Ficรงรฃo Cientรญfica" to "ficcao-cientifica", + "Futanari" to "futanari", + "Game" to "game", + "Game System" to "game-system", + "Gangster" to "gangster", + "Gender Bender" to "gender-bender", + "Gรชnio" to "genio", + "Gore" to "gore", + "Guerra" to "guerra", + "Hardcore" to "hardcore", + "Harem" to "harem", + "Harรฉm Reverso" to "harem-reverso", + "Hentai" to "hentai", + "Hรญbrido" to "hibrido", + "Histรณria" to "historia", + "Histรณrico" to "historico", + "Horror" to "horror", + "Idols" to "idols", + "Incesto" to "incesto", + "Isekai" to "isekai", + "Jogo" to "jogo", + "Jogos" to "jogos", + "Josei" to "josei", + "Luta" to "luta", + "Mรกfia" to "mafia", + "Magia" to "magia", + "Magia Escolar" to "magia-escolar", + "Mature" to "mature", + "Mecha" to "mecha", + "Mรฉdico" to "medico", + "Metaverso" to "metaverso", + "Milf" to "milf", + "Militar" to "militar", + "Mistรฉrio" to "misterio", + "Mitologia" to "mitologia", + "MMORPG" to "mmorpg", + "Monstros" to "monstros", + "Murim" to "murim", + "Mรบsica" to "musica", + "Musical" to "musical", + "Nerd" to "nerd", + "Netori" to "netori", + "Obsessรฃo" to "obsessao", + "One Shot" to "one-shot", + "Oneshot" to "oneshot", + "Peitรตes" to "peitoes", + "Perda de Memรณria" to "perda-de-memoria", + "Polรญcia" to "policia", + "Policial" to "policial", + "Portais" to "portais", + "Pรณs-apocalรญptico" to "pos-apocaliptico", + "Profecias" to "profecias", + "Prostituiรงรฃo" to "prostituicao", + "Psicolรณgico" to "psicologico", + "Psicopata" to "psicopata", + "Realidade Virtual" to "realidade-virtual", + "Realismo" to "realismo", + "Reencarnaรงรฃo" to "reencarnacao", + "Reencontro" to "reencontro", + "Regressรฃo" to "regressao", + "Retornado" to "retornado", + "Retorno" to "retorno", + "Revenge" to "revenge", + "Romance" to "romance", + "RPG" to "rpg", + "Sangue" to "sangue", + "School Life" to "school-life", + "Sci-Fi" to "sci-fi", + "Seinen" to "seinen", + "Sem Censura" to "sem-censura", + "Shoujo" to "shoujo", + "Shounen" to "shounen", + "Sistema" to "sistema", + "Sistema de Nรญveis" to "sistema-de-niveis", + "Slice of Life" to "slice-of-life", + "Smut" to "smut", + "Sobrenatural" to "sobrenatural", + "Sobrevivรชncia" to "sobrevivencia", + "Sombrio" to "sombrio", + "Sports" to "sports", + "Submissรฃo" to "submissao", + "Sugestivo" to "sugestivo", + "Super Poderes" to "super-poderes", + "Supernatural" to "supernatural", + "Superpoderes" to "superpoderes", + "Suspense" to "suspense", + "Tecnolรณgico" to "tecnologico", + "Tela de Sistema" to "tela-de-sistema", + "Terror" to "terror", + "Thriller" to "thriller", + "Tomboy" to "tomboy", + "Torre" to "torre", + "Tรณxico" to "toxico", + "Tragรฉdia" to "tragedia", + "Traiรงรฃo" to "traicao", + "Triรขngulo" to "triangulo", + "Tsundere" to "tsundere", + "Universitรกrios" to "universitarios", + "Vampiro" to "vampiro", + "Vampiros" to "vampiros", + "Viagem no Tempo" to "viagem-no-tempo", + "Vida Adulta" to "vida-adulta", + "Vida Cotidiana" to "vida-cotidiana", + "Vida Escolar" to "vida-escolar", + "Video Game" to "video-game", + "Video Games" to "video-games", + "Vรญdeo Game" to "vรญdeo-game-1", + "Vinganรงa" to "vinganca", + "Violรชncia" to "violencia", + "Volta no Tempo" to "volta-no-tempo", + "VRMMO" to "vrmmo", + "Web Comic" to "web-comic", + "Webcomic" to "webcomic", + "Wuxia" to "wuxia", + "Xianxia" to "xianxia", + "Xuanhuan" to "xuanhuan", + "Yuri" to "yuri", + "Zumbis" to "zumbis", + ) + } +} From eda3fb7032c5f2003e1a33a8c96706c5f41c67a7 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:34:39 +0200 Subject: [PATCH 25/33] Update NexusToons.kt --- src/main/kotlin/tsuki/site/pt/nsfw/NexusToons.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/tsuki/site/pt/nsfw/NexusToons.kt b/src/main/kotlin/tsuki/site/pt/nsfw/NexusToons.kt index e460e8c6..7dfb8fdf 100644 --- a/src/main/kotlin/tsuki/site/pt/nsfw/NexusToons.kt +++ b/src/main/kotlin/tsuki/site/pt/nsfw/NexusToons.kt @@ -262,7 +262,7 @@ internal class NexusToons(context: MangaLoaderContext) : ) } } - + override suspend fun getDetails(manga: Manga): Manga { val slug = manga.url.substringAfter("/manga/").trimEnd('/') val json = fetchApi("https://$domain/api/manga/$slug") @@ -292,7 +292,7 @@ internal class NexusToons(context: MangaLoaderContext) : source = source, ) } - }.orEmpty() + }.orEmpty().sortedBy { it.number } val categories = json.optJSONArray("categories")?.let { arr -> (0 until arr.length()) @@ -389,7 +389,7 @@ internal class NexusToons(context: MangaLoaderContext) : repeat(length) { append(chars.random()) } } } - + private fun MangaState.toApiStatus(): String = when (this) { MangaState.ONGOING -> "ongoing" MangaState.FINISHED -> "completed" @@ -430,7 +430,7 @@ internal class NexusToons(context: MangaLoaderContext) : private val ISO_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") } - + private val NEXUS_GENRES: List> = listOf( "Academia de Magia" to "academia-de-magia", "Acadรชmica" to "academica", From baafed8dc82b1a1a84ab78c652d7bc0088c7c9bb Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:52:36 +0200 Subject: [PATCH 26/33] Update .gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 80235f8c..a8c5f200 100644 --- a/.gitignore +++ b/.gitignore @@ -76,7 +76,6 @@ fabric.properties build/ bin/ - .idea/**/misc.xml .idea/**/vcs.xml .idea/**/ktlint.xml @@ -101,4 +100,4 @@ src/test/resources/cookies.txt .vscode/ # Config file -/local.properties \ No newline at end of file +/local.properties From 025653cf01d3f47cc92e06651028c921a56d215c Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:20:39 +0200 Subject: [PATCH 27/33] fix templescan again --- .../kotlin/tsuki/site/en/nsfw/TempleScan.kt | 362 ++++++++++-------- 1 file changed, 208 insertions(+), 154 deletions(-) diff --git a/src/main/kotlin/tsuki/site/en/nsfw/TempleScan.kt b/src/main/kotlin/tsuki/site/en/nsfw/TempleScan.kt index cfba460c..6f8c9c01 100644 --- a/src/main/kotlin/tsuki/site/en/nsfw/TempleScan.kt +++ b/src/main/kotlin/tsuki/site/en/nsfw/TempleScan.kt @@ -20,20 +20,20 @@ import tsuki.model.MangaTag import tsuki.model.RATING_UNKNOWN import tsuki.model.SortOrder -import tsuki.util.extractChapterNumber import tsuki.util.generateUid -import tsuki.util.json.extractNextJs import tsuki.util.parseHtml -import tsuki.util.parseSafe -import tsuki.util.toAbsoluteUrl +import tsuki.util.urlEncoded import okhttp3.Headers -import org.json.JSONArray import org.json.JSONObject import org.jsoup.nodes.Document import java.text.SimpleDateFormat +import java.util.Calendar import java.util.EnumSet import java.util.Locale +import java.util.TimeZone + +// TODO: status filter does not work @MangaSourceParser("TEMPLESCAN", "Temple Scan", "en", ContentType.HENTAI) internal class TempleScan(context: MangaLoaderContext) : @@ -77,58 +77,77 @@ internal class TempleScan(context: MangaLoaderContext) : ), availableContentTypes = emptySet(), ) - + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val allSeries = fetchCatalog() - - val statusFilter = filter.states.firstOrNull() - val query = filter.query?.trim()?.takeIf(String::isNotBlank) + val url = buildString { + append("https://$domain/comics") + append("?page=") + append(page) + + filter.states.firstOrNull()?.let { state -> + val statusValue = when (state) { + MangaState.ONGOING -> "Ongoing" + MangaState.FINISHED -> "Completed" + MangaState.PAUSED -> "Hiatus" + MangaState.ABANDONED -> "Canceled" + else -> null + } + if (statusValue != null) { + append("&status=") + append(statusValue) + } + } - val filtered = allSeries.filter { series -> - val matchesQuery = query == null || - series.title.contains(query, ignoreCase = true) || - (series.alternativeNames?.contains(query, ignoreCase = true) == true) - val matchesStatus = statusFilter == null || series.status == stateToApi(statusFilter) - matchesQuery && matchesStatus + filter.query?.trim()?.takeIf { it.isNotBlank() }?.let { q -> + append("&q=") + append(q.urlEncoded()) + } } - val sorted = when (order) { - SortOrder.UPDATED -> filtered.sortedByDescending { it.updated } - SortOrder.NEWEST -> filtered.sortedByDescending { it.created } - SortOrder.POPULARITY -> filtered.sortedByDescending { it.views } - SortOrder.ALPHABETICAL -> filtered.sortedBy { it.title } - else -> filtered - } + val doc = webClient.httpGet(url).parseHtml() + val cards = parseCards(doc) - val startIndex = (page - 1) * pageSize - if (startIndex >= sorted.size) return emptyList() - val endIndex = minOf(startIndex + pageSize, sorted.size) - - return sorted.subList(startIndex, endIndex).map { item -> - Manga( - id = generateUid("/comic/${item.slug}"), - url = "/comic/${item.slug}", - publicUrl = "https://$domain/comic/${item.slug}", - title = item.title, - altTitles = emptySet(), - coverUrl = item.thumbnail?.toAbsoluteUrl(domain), - authors = emptySet(), - state = item.status.toMangaState(), - contentRating = null, - tags = emptySet(), - rating = RATING_UNKNOWN, - source = source, - ) + return when (order) { + SortOrder.ALPHABETICAL -> cards.sortedBy { it.title.lowercase() } + else -> cards } } + private fun parseCards(doc: Document): List = + doc.select("div.grid > div") + .filter { it.selectFirst("a[href*=/comic/]") != null } + .mapNotNull { card -> + val link = card.selectFirst("a[href*=/comic/]") ?: return@mapNotNull null + val href = link.attr("href").takeIf { it.isNotBlank() } ?: return@mapNotNull null + val title = card.selectFirst("h2")?.text()?.trim().orEmpty() + if (title.isBlank()) return@mapNotNull null + + val cover = card.selectFirst("a[href*=/comic/] img") + ?.let { it.attr("src").ifBlank { it.attr("data-src") } } + ?.trim()?.takeIf { it.isNotBlank() } + + Manga( + id = generateUid(href), + url = href, + publicUrl = "https://$domain$href", + title = title, + altTitles = emptySet(), + coverUrl = cover, + authors = emptySet(), + state = null, + contentRating = null, + tags = emptySet(), + rating = RATING_UNKNOWN, + source = source, + ) + } + .distinctBy { it.url } + override suspend fun getDetails(manga: Manga): Manga { - val slug = manga.url.removePrefix("/comic/") + val slug = manga.url.removePrefix("/comic/").trimEnd('/') val doc = webClient.httpGet("https://$domain/comic/$slug").parseHtml() val seriesLd = doc.extractComicSeriesLd() - val seriesData = doc.extractNextJs { it is JSONObject && it.has("seriesData") } as? JSONObject - val catalogEntry = fetchCatalog().firstOrNull { it.slug == slug } val genres = seriesLd?.optJSONArray("genre")?.let { arr -> (0 until arr.length()).map { arr.getString(it) } @@ -149,21 +168,35 @@ internal class TempleScan(context: MangaLoaderContext) : } } - val chapters = parseChapters(slug, seriesData) + val statusText = doc.select("ul[aria-label=\"Series stats\"] li span") + .map { it.text().trim() } + .firstOrNull { text -> + text.equals("Ongoing", ignoreCase = true) || + text.equals("Completed", ignoreCase = true) || + text.equals("Hiatus", ignoreCase = true) || + text.equals("Canceled", ignoreCase = true) || + text.equals("Dropped", ignoreCase = true) + } + + val state = when (statusText?.lowercase()) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + "canceled", "dropped" -> MangaState.ABANDONED + else -> null + } + + val chapters = parseChaptersFromHtml(doc) return manga.copy( - title = seriesLd?.optString("name")?.takeIf { it.isNotBlank() } - ?: catalogEntry?.title ?: slug, + title = seriesLd?.optString("name")?.takeIf { it.isNotBlank() } ?: slug, altTitles = altName?.let { setOf(it) } ?: emptySet(), - coverUrl = seriesLd?.optString("image")?.takeIf { it.isNotBlank() } - ?: catalogEntry?.thumbnail?.toAbsoluteUrl(domain) - ?: manga.coverUrl, + coverUrl = seriesLd?.optString("image")?.takeIf { it.isNotBlank() } ?: manga.coverUrl, description = description, authors = authorName?.let { setOf(it) } ?: emptySet(), - state = catalogEntry?.status.toMangaState(), + state = state, contentRating = if (adult) ContentRating.ADULT else ContentRating.SAFE, tags = buildSet { - catalogEntry?.badge?.let { add(MangaTag(it.lowercase(), it, source)) } if (adult) add(MangaTag("adult", "Adult", source)) genres.filterNot { it.equals("+18", ignoreCase = true) }.forEach { add(MangaTag(it.lowercase(), it, source)) @@ -173,73 +206,124 @@ internal class TempleScan(context: MangaLoaderContext) : ) } - private fun parseChapters(slug: String, seriesDataWrapper: JSONObject?): List { - val seriesData = seriesDataWrapper?.optJSONObject("seriesData") ?: return emptyList() - val seasons = seriesData.optJSONArray(FIELD_SEASONS) ?: return emptyList() - - val chapters = mutableListOf() - for (s in 0 until seasons.length()) { - val arr = seasons.getJSONObject(s).optJSONArray(FIELD_CHAPTERS) ?: continue - for (c in 0 until arr.length()) { - val chap = arr.getJSONObject(c) - if (chap.optInt(FIELD_PRICE, 0) > 0) continue - - val name = chap.optString(FIELD_CHAPTER_NAME).takeIf { it.isNotBlank() } ?: continue - val chapSlug = chap.optString(FIELD_CHAPTER_SLUG).takeIf { it.isNotBlank() } ?: continue - val title = chap.optString("chapter_title").takeIf { it.isNotBlank() } - - chapters += MangaChapter( - id = generateUid("$slug/$chapSlug"), - title = if (title != null) "$name: $title" else name, - number = name.extractChapterNumber(), - url = "/comic/$slug/$chapSlug", - uploadDate = parseIso(chap.optString("created_at", null)), - source = source, - volume = 0, - scanlator = null, - branch = null, - ) + private fun parseChaptersFromHtml(doc: Document): List { + val links = doc.select("ul#chapter-list li a[href*=chapter-]") + .filterNot { a -> + a.select("span").any { span -> + span.ownText().trim().equals("Premium", ignoreCase = true) + } } - } - return chapters.reversed() - } - - override suspend fun getPages(chapter: MangaChapter): List { - val response = webClient.httpGet("https://$domain${chapter.url}") - val obj = response.extractNextJs { it is JSONObject && it.has(FIELD_IMAGES) } as? JSONObject - ?: return emptyList() - val images = obj.optJSONArray(FIELD_IMAGES) ?: return emptyList() - return (0 until images.length()).map { i -> - val url = images.getString(i) - MangaPage( - id = generateUid(url), - url = url.toAbsoluteUrl(domain), - preview = null, + if (links.isEmpty()) return emptyList() + + return links.mapNotNull { a -> + val href = a.attr("href").takeIf { it.isNotBlank() } ?: return@mapNotNull null + val numMatch = Regex("""chapter-([\d.]+)(?:/|$)""").find(href) ?: return@mapNotNull null + val numStr = numMatch.groupValues[1] + val number = numStr.toFloatOrNull() ?: return@mapNotNull null + + val title = a.selectFirst("span.font-semibold")?.text()?.trim() + ?.takeIf { it.isNotBlank() } + ?: "Chapter $numStr" + + val uploadDate = a.selectFirst("time[datetime]") + ?.attr("datetime") + ?.takeIf { it.isNotBlank() } + ?.let { parseDate(it) } + ?: 0L + + MangaChapter( + id = generateUid(href), + title = title, + number = number, + volume = 0, + url = href, + uploadDate = uploadDate, source = source, + scanlator = null, + branch = null, ) + }.distinctBy { it.url }.sortedBy { it.number } + } + + override suspend fun getPages(chapter: MangaChapter): List { + val url = "https://$domain${chapter.url}" + val doc = webClient.httpGet(url).parseHtml() + + val htmlImgs = doc.select("main img, article img, figure img, .reader img, [class*=reader] img") + .mapNotNull { img -> + val src = (img.absUrl("src").takeIf { it.isNotBlank() } + ?: img.absUrl("data-src").takeIf { it.isNotBlank() } + ?: img.absUrl("data-lazy-src").takeIf { it.isNotBlank() }) + ?.takeIf { isValidReaderImage(it) } + ?: return@mapNotNull null + src + } + .distinct() + + if (htmlImgs.isNotEmpty()) { + return htmlImgs.map { MangaPage(id = generateUid(it), url = it, preview = null, source = source) } } + + val html = doc.html().replace("\\/", "/") + val fromRegex = IMAGE_URL_REGEX.findAll(html) + .map { it.value.replace("\\/", "/") } + .filter { isValidReaderImage(it) } + .distinct() + .toList() + + return fromRegex.map { MangaPage(id = generateUid(it), url = it, preview = null, source = source) } } - private suspend fun fetchCatalog(): List { - val response = webClient.httpGet("https://$domain/comics") - val array = response.extractNextJs { - it is JSONArray && it.length() > 0 && it.optJSONObject(0)?.has(FIELD_SLUG) == true - } as? JSONArray ?: return emptyList() + private fun isValidReaderImage(url: String): Boolean { + val lower = url.lowercase() + val hasImageExt = lower.contains(".webp") || lower.contains(".jpg") || + lower.contains(".jpeg") || lower.contains(".png") || lower.contains(".avif") + if (!hasImageExt) return false - return (0 until array.length()).map { array.getJSONObject(it).toSeriesItem() } + val looksLikeContent = lower.contains("media.") || lower.contains("cdn.") || + lower.contains("/file/") || lower.contains("/chapter") + if (!looksLikeContent) return false + + val isChrome = lower.contains("/cover") || lower.contains("logo") || + lower.contains("banner") || lower.contains("favicon") || lower.contains("avatar") || + lower.contains("thumbnail") + return !isChrome } + + private fun parseDate(text: String?): Long { + if (text.isNullOrBlank()) return 0L + val lower = text.lowercase().trim() + + if (lower.contains("ago")) { + val num = Regex("""(\d+)""").find(lower)?.groupValues?.get(1)?.toIntOrNull() ?: return 0L + val cal = Calendar.getInstance() + when { + lower.contains("second") -> cal.add(Calendar.SECOND, -num) + lower.contains("minute") -> cal.add(Calendar.MINUTE, -num) + lower.contains("hour") -> cal.add(Calendar.HOUR, -num) + lower.contains("day") -> cal.add(Calendar.DAY_OF_MONTH, -num) + lower.contains("week") -> cal.add(Calendar.DAY_OF_MONTH, -num * 7) + lower.contains("month") -> cal.add(Calendar.MONTH, -num) + lower.contains("year") -> cal.add(Calendar.YEAR, -num) + else -> return 0L + } + return cal.timeInMillis + } - private fun JSONObject.toSeriesItem(): SeriesItem = SeriesItem( - slug = getString(FIELD_SLUG), - title = getString(FIELD_TITLE), - alternativeNames = optString("alternative_names", null), - thumbnail = optString("thumbnail", null), - badge = optString("badge", null), - status = optString("status", null), - updated = parseIso(optString("update_chapter", null)), - created = parseIso(optString("created_at", null)), - views = optLong("total_views", 0), - ) + try { + return java.time.Instant.parse(text).toEpochMilli() + } catch (_: Exception) {} + + for (pattern in ABSOLUTE_DATE_PATTERNS) { + try { + val fmt = SimpleDateFormat(pattern, Locale.ENGLISH).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + return fmt.parse(text)?.time ?: continue + } catch (_: Exception) {} + } + return 0L + } private fun Document.extractComicSeriesLd(): JSONObject? = select("script[type=application/ld+json]").mapNotNull { script -> @@ -262,50 +346,20 @@ internal class TempleScan(context: MangaLoaderContext) : if (ps.isNotEmpty()) ps.joinToString("\n\n") { it.text() } else el.text() }?.takeIf { it.isNotBlank() } - private fun parseIso(date: String?): Long { - if (date.isNullOrBlank()) return 0L - return DATE_FORMAT.parseSafe(date) - } - - private fun String?.toMangaState(): MangaState? = when (this?.lowercase()) { - "ongoing" -> MangaState.ONGOING - "hiatus" -> MangaState.PAUSED - "completed" -> MangaState.FINISHED - "canceled", "dropped" -> MangaState.ABANDONED - else -> null - } - - private fun stateToApi(state: MangaState): String = when (state) { - MangaState.ONGOING -> "Ongoing" - MangaState.FINISHED -> "Completed" - MangaState.PAUSED -> "Hiatus" - MangaState.ABANDONED -> "Canceled" - else -> "" - } - - data class SeriesItem( - val slug: String, - val title: String, - val alternativeNames: String?, - val thumbnail: String?, - val badge: String?, - val status: String?, - val updated: Long, - val created: Long, - val views: Long, - ) - companion object { private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" - private const val FIELD_TITLE = "pnsk6q" - private const val FIELD_SLUG = "s20a8oj" - private const val FIELD_SEASONS = "u2ytwc" - private const val FIELD_CHAPTERS = "qmy3ca" - private const val FIELD_CHAPTER_NAME = "u171tuh" - private const val FIELD_CHAPTER_SLUG = "y26ma5t" - private const val FIELD_PRICE = "u1e8nmi" - private const val FIELD_IMAGES = "nu7315" - - private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH) + + private val IMAGE_URL_REGEX = Regex( + """https?://[^"\s]+?\.(?:jpe?g|png|webp|avif|gif)""", + RegexOption.IGNORE_CASE, + ) + + private val ABSOLUTE_DATE_PATTERNS = listOf( + "MMM d, yyyy", + "MMMM d, yyyy", + "yyyy-MM-dd", + "d MMM yyyy", + "MM/dd/yyyy", + ) } } From 5ecf130e5f0685ff3048d99d53a81cfda048c14e Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:21:54 +0200 Subject: [PATCH 28/33] Update VoraToon.kt --- src/main/kotlin/tsuki/site/id/VoraToon.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/tsuki/site/id/VoraToon.kt b/src/main/kotlin/tsuki/site/id/VoraToon.kt index 280cd602..5f1389a9 100644 --- a/src/main/kotlin/tsuki/site/id/VoraToon.kt +++ b/src/main/kotlin/tsuki/site/id/VoraToon.kt @@ -33,7 +33,7 @@ import java.util.Locale internal class VoraToon(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.VORATOON, pageSize = 30) { - override val configKeyDomain = ConfigKey.Domain("v2.voratoon.com") + override val configKeyDomain = ConfigKey.Domain("v4.voratoon.com") private val apiBase = "https://api.voratoon.com" From f2d80a8e892e4e07027dc68eeaad4f9096e84683 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:30:44 +0200 Subject: [PATCH 29/33] Update ReadComicsOnline.kt --- .../kotlin/tsuki/site/en/ReadComicsOnline.kt | 163 ++++++++++++------ 1 file changed, 110 insertions(+), 53 deletions(-) diff --git a/src/main/kotlin/tsuki/site/en/ReadComicsOnline.kt b/src/main/kotlin/tsuki/site/en/ReadComicsOnline.kt index 2c5ddb73..b838a513 100644 --- a/src/main/kotlin/tsuki/site/en/ReadComicsOnline.kt +++ b/src/main/kotlin/tsuki/site/en/ReadComicsOnline.kt @@ -10,19 +10,18 @@ import tsuki.model.Manga import tsuki.model.MangaChapter import tsuki.model.MangaListFilter import tsuki.model.MangaListFilterCapabilities +import tsuki.model.MangaListFilterOptions import tsuki.model.MangaPage import tsuki.model.MangaParserSource import tsuki.model.MangaState import tsuki.model.MangaTag import tsuki.model.RATING_UNKNOWN import tsuki.model.SortOrder -import tsuki.model.MangaListFilterOptions import tsuki.util.generateUid -import tsuki.util.parseJson -import tsuki.util.urlEncoded import tsuki.util.attrAsRelativeUrl import tsuki.util.mapChapters +import tsuki.util.mapNotNullToSet import tsuki.util.nullIfEmpty import tsuki.util.parseHtml import tsuki.util.parseSafe @@ -31,18 +30,15 @@ import tsuki.util.src import tsuki.util.textOrNull import tsuki.util.toAbsoluteUrl import tsuki.util.toRelativeUrl +import tsuki.util.urlEncoded import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import tsuki.util.mapNotNullToSet import java.text.SimpleDateFormat import java.util.EnumSet import java.util.Locale -// TODO: getListpage use advanced-search - @MangaSourceParser("READCOMICSONLINE", "ReadComicsOnline.ru", "en", ContentType.COMICS) internal class ReadComicsOnline(context: MangaLoaderContext) : MmrcmsParser(context, MangaParserSource.READCOMICSONLINE, "readcomicsonline.ru") { @@ -57,76 +53,137 @@ internal class ReadComicsOnline(context: MangaLoaderContext) : override val filterCapabilities: MangaListFilterCapabilities get() = MangaListFilterCapabilities( isSearchSupported = true, - isSearchWithFiltersSupported = false, + isSearchWithFiltersSupported = true, + isMultipleTagsSupported = false, ) - override suspend fun getFilterOptions(): MangaListFilterOptions = MangaListFilterOptions() + override suspend fun getFilterOptions(): MangaListFilterOptions { + val doc = webClient.httpGet("https://$domain/advanced-search").parseHtml() + + val categories = doc.select("select[name=category] option") + .mapNotNullToSet { option -> + val id = option.attr("value").trim() + val name = option.text().trim() + if (id.isEmpty() || name.isEmpty()) null + else MangaTag(key = id, title = name, source = source) + } - override val fetchFilterOptions = false + return MangaListFilterOptions( + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), + availableTags = categories, + availableContentTypes = EnumSet.of( + ContentType.MANGA, // DC Comics + ContentType.MANHWA, // Marvel Comics + ContentType.MANHUA, // Other Comics + ), + ) + } override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - if (!filter.query.isNullOrEmpty()) { - return search(page, filter.query!!) - } - if (filter.tags.isNotEmpty() || filter.states.isNotEmpty()) { - return emptyList() - } + val hasFilters = !filter.query.isNullOrEmpty() + || filter.states.isNotEmpty() + || filter.types.isNotEmpty() + || filter.tags.isNotEmpty() - val sort = when (order) { - SortOrder.UPDATED -> "latest" - else -> "views" + val url = if (hasFilters) { + buildAdvancedSearchUrl(page, filter) + } else { + val sort = when (order) { + SortOrder.UPDATED -> "latest" + else -> "views" + } + "https://$domain/comic-list?sort=$sort&page=$page" } - val doc = webClient.httpGet("https://$domain/comic-list?sort=$sort&page=$page").parseHtml() - return doc.select("div.comic-list-layout .grid > .group").mapNotNull(::parseMangaListItem) + + val doc = webClient.httpGet(url).parseHtml() + return parseComicListCards(doc).ifEmpty { parseSearchResultCards(doc) } } - private suspend fun search(page: Int, query: String): List { - if (page > 1) return emptyList() + private fun buildAdvancedSearchUrl(page: Int, filter: MangaListFilter): String { + val params = buildList { + filter.query?.takeIf { it.isNotBlank() }?.let { + add("name=${it.urlEncoded()}") + } + + filter.states.firstOrNull()?.let { state -> + val id = when (state) { + MangaState.ONGOING -> "1" + MangaState.FINISHED -> "2" + else -> null + } + if (id != null) add("status_id=$id") + } + + filter.types.firstOrNull()?.let { type -> + val id = when (type) { + ContentType.MANGA -> "1" + ContentType.MANHWA -> "2" + ContentType.MANHUA -> "3" + else -> null + } + if (id != null) add("type_id=$id") + } + + filter.tags.firstOrNull()?.let { tag -> + add("category=${tag.key}") + } - val url = "https://$domain/search?query=${query.trim().urlEncoded()}" - val suggestions = webClient.httpGet(url).parseJson() - .optJSONArray("suggestions") ?: return emptyList() + add("page=$page") + } + return "https://$domain/advanced-search?" + params.joinToString("&") + } - return (0 until suggestions.length()).mapNotNull { i -> - val jo = suggestions.optJSONObject(i) ?: return@mapNotNull null - val slug = jo.optString("data").nullIfEmpty() ?: return@mapNotNull null - val href = "/comic/$slug" + private fun parseComicListCards(doc: Document): List = + doc.select("div.comic-list-layout .grid > .group").mapNotNull { element -> + val anchor = element.selectFirst("a.block.text-sm.font-semibold") + ?: return@mapNotNull null + val href = anchor.attrAsRelativeUrl("href") Manga( id = generateUid(href), - title = jo.optString("value").nullIfEmpty() ?: slug, + title = anchor.text(), altTitles = emptySet(), url = href, - publicUrl = jo.optString("url").nullIfEmpty() - ?: href.toAbsoluteUrl(domain), + publicUrl = href.toAbsoluteUrl(domain), rating = RATING_UNKNOWN, contentRating = if (isNsfwSource) ContentRating.ADULT else null, - coverUrl = guessCover(href, jo.optString("cover").nullIfEmpty()), + coverUrl = guessCover(href, element.selectFirst("img")?.src()), tags = emptySet(), state = null, authors = emptySet(), source = source, ) } - } - private fun parseMangaListItem(element: Element): Manga? { - val anchor = element.selectFirst("a.block.text-sm.font-semibold") ?: return null - val href = anchor.attrAsRelativeUrl("href") - return Manga( - id = generateUid(href), - title = anchor.text(), - altTitles = emptySet(), - url = href, - publicUrl = href.toAbsoluteUrl(domain), - rating = RATING_UNKNOWN, - contentRating = if (isNsfwSource) ContentRating.ADULT else null, - coverUrl = guessCover(href, element.selectFirst("img")?.src()), - tags = emptySet(), - state = null, - authors = emptySet(), - source = source, - ) - } + private fun parseSearchResultCards(doc: Document): List = + doc.select("a.group[href*=/comic/]").mapNotNull { anchor -> + val href = anchor.attrAsRelativeUrl("href") + if (href.isBlank()) return@mapNotNull null + + val title = anchor.selectFirst("p")?.text()?.trim() + ?.takeIf { it.isNotBlank() } + ?: anchor.selectFirst("img")?.attr("alt")?.trim() + ?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + + val cover = anchor.selectFirst(".rc-cover img, img") + ?.let { it.attr("src").ifBlank { it.attr("data-src") } } + ?.trim()?.takeIf { it.isNotBlank() } + + Manga( + id = generateUid(href), + title = title, + altTitles = emptySet(), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + rating = RATING_UNKNOWN, + contentRating = if (isNsfwSource) ContentRating.ADULT else null, + coverUrl = cover ?: guessCover(href, null), + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + ) + } override suspend fun getDetails(manga: Manga): Manga = coroutineScope { val fullUrl = manga.url.toAbsoluteUrl(domain) From 4d5be72ecc3d3bc95ed8beda656f6bcc0bd5a4de Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:00:22 +0200 Subject: [PATCH 30/33] Create HanamiHeaven.kt --- .../kotlin/tsuki/site/pt/nsfw/HanamiHeaven.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/main/kotlin/tsuki/site/pt/nsfw/HanamiHeaven.kt diff --git a/src/main/kotlin/tsuki/site/pt/nsfw/HanamiHeaven.kt b/src/main/kotlin/tsuki/site/pt/nsfw/HanamiHeaven.kt new file mode 100644 index 00000000..21f3685e --- /dev/null +++ b/src/main/kotlin/tsuki/site/pt/nsfw/HanamiHeaven.kt @@ -0,0 +1,18 @@ +package tsuki.site.pt.nsfw + +import tsuki.MangaLoaderContext +import tsuki.MangaSourceParser +import tsuki.parsers.MadaraParser + +import tsuki.model.ContentType +import tsuki.model.MangaParserSource + +import java.util.Locale + +@MangaSourceParser("HANAMIHEAVEN", "HanamiHeaven", "pt", ContentType.HENTAI) +internal class HanamiHeaven(context: MangaLoaderContext) : + MadaraParser(context, MangaParserSource.HANAMIHEAVEN, "hanamiheaven.org") { + override val sourceLocale: Locale = Locale.forLanguageTag("pt-BR") + override val datePattern = "dd/MM/yyyy" + override val postReq = false +} From c55aacab5cf18f60c5f414e5bf12c233f208949c Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:22:54 +0200 Subject: [PATCH 31/33] mikoroku by kurkitsune768 --- src/main/kotlin/tsuki/site/id/Mikoroku.kt | 481 ++++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 src/main/kotlin/tsuki/site/id/Mikoroku.kt diff --git a/src/main/kotlin/tsuki/site/id/Mikoroku.kt b/src/main/kotlin/tsuki/site/id/Mikoroku.kt new file mode 100644 index 00000000..221c4ed8 --- /dev/null +++ b/src/main/kotlin/tsuki/site/id/Mikoroku.kt @@ -0,0 +1,481 @@ +package tsuki.site.id + +import kotlinx.coroutines.Dispatchers +import tsuki.MangaLoaderContext +import tsuki.MangaSourceParser +import tsuki.config.ConfigKey +import tsuki.core.PagedMangaParser + +import tsuki.model.Manga +import tsuki.model.MangaChapter +import tsuki.model.MangaListFilter +import tsuki.model.MangaListFilterCapabilities +import tsuki.model.MangaListFilterOptions +import tsuki.model.MangaPage +import tsuki.model.MangaParserSource +import tsuki.model.MangaState +import tsuki.model.MangaTag +import tsuki.model.RATING_UNKNOWN +import tsuki.model.SortOrder + +import tsuki.util.generateUid +import tsuki.util.json.mapJSON +import tsuki.util.json.mapJSONNotNull +import tsuki.util.parseJson + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.jsoup.HttpStatusException +import org.json.JSONArray +import org.json.JSONObject +import java.net.URLEncoder +import java.util.EnumSet + +private const val GITHUB_RAW = "https://raw.githubusercontent.com/moemaomao/mymangadata/main/" +private const val MANGA_LIST_URL = GITHUB_RAW + "all-manga.json" +private const val CHAPTER_FEED_BASE = "https://www.mikodrive.my.id/feeds/posts/default" +private const val PAGE_SIZE = 24 +private const val CACHE_MS = 30 * 60 * 1000L +private const val FIRESTORE_SUMMARY_URL = "https://firestore.googleapis.com/v1/projects/widget-comment/databases/(default)/documents/meta/mangaSummary" + + "?key=AIzaSyCxufJubuS95zQnvctINvzj-H8WSIgsse4" + +private const val FIRESTORE_COLLECTION_URL = "https://firestore.googleapis.com/v1/projects/widget-comment/databases/(default)/documents/manga" + + "?key=AIzaSyCxufJubuS95zQnvctINvzj-H8WSIgsse4" +private val FIRESTORE_FIELDS = listOf("title", "cover", "img", "status", "updatedAt", "latestUpdate", "isDraft", "githubSlug") + +private class Entry(val manga: Manga, val updatedAt: Long) + +private class SummaryItem( + val slug: String, + val githubSlug: String, + val title: String, + val cover: String?, + val status: String, + val updatedAt: Long, +) + +private val CHAPTER_NUM_REGEX = Regex("""chapter\s*(\d+(\.\d+)?)""", RegexOption.IGNORE_CASE) +private val IMG_SRC_REGEX = Regex("""]+(?:src|data-src)=["'](https?://[^"']+)["']""", RegexOption.IGNORE_CASE) + +@MangaSourceParser("MIKOROKU", "Mikoroku", "id") +internal class Mikoroku(context: MangaLoaderContext) : + PagedMangaParser(context, MangaParserSource.MIKOROKU, PAGE_SIZE) { + + override val configKeyDomain = ConfigKey.Domain("mikoroku.com") + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.NEWEST, + SortOrder.POPULARITY, + SortOrder.ALPHABETICAL, + ) + + private val cacheMutex = Mutex() + + @Volatile + private var cache: List? = null + + @Volatile + private var cacheTime = 0L + + @Volatile + private var zeroBasedPages = false + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = true, + isSearchWithFiltersSupported = true, + ) + + override suspend fun getFilterOptions(): MangaListFilterOptions { + val tags = loadEntries() + .flatMap { it.manga.tags } + .distinctBy { it.key } + .sortedBy { it.title } + .toSet() + return MangaListFilterOptions( + availableTags = tags, + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, + MangaState.PAUSED, + MangaState.ABANDONED, + ), + ) + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val all = loadEntries() + + val query = filter.query.orEmpty() + val tagKeys = filter.tags.map { it.key } + val states = filter.states + + val filtered = all.filter { e -> + (query.isBlank() || matchesQuery(e.manga, query)) && + (tagKeys.isEmpty() || e.manga.tags.map { it.key }.containsAll(tagKeys)) && + (states.isEmpty() || e.manga.state?.let { it in states } == true) + } + + val sorted = when (order) { + SortOrder.ALPHABETICAL -> filtered.sortedBy { it.manga.title } + // "Terbaru": ikut urutan file JSON (hasil tes: judul terbaru sudah ada di atas) + SortOrder.NEWEST -> filtered + // "Populer": data tidak punya jumlah views, jadi dipakai rating tertinggi + SortOrder.POPULARITY -> filtered.sortedByDescending { it.manga.rating } + // "Baru diperbarui": tanggal chapter terbaru + else -> filtered.sortedByDescending { it.updatedAt } + } + + if (page == 0) zeroBasedPages = true + val pageIndex = if (zeroBasedPages) page else (page - 1).coerceAtLeast(0) + val from = pageIndex * PAGE_SIZE + if (from >= sorted.size) return emptyList() + val to = minOf(from + PAGE_SIZE, sorted.size) + return sorted.subList(from, to).map { it.manga } + } + + // Gabungan GitHub JSON + ringkasan Firestore (sama seperti yang dilakukan web-nya) + private suspend fun loadEntries(): List { + cache?.let { if (System.currentTimeMillis() - cacheTime < CACHE_MS) return it } + return cacheMutex.withLock { + val cached = cache + if (cached != null && System.currentTimeMillis() - cacheTime < CACHE_MS) { + cached + } else { + buildEntries().also { + cache = it + cacheTime = System.currentTimeMillis() + } + } + } + } + + private suspend fun buildEntries(): List { + val github = fetchAllManga() + val summary = fetchSummary() + val blogger = fetchBloggerUpdates() + + val bySlug = HashMap() + val byTitle = HashMap() + for (s in summary) { + bySlug[s.slug] = s + if (s.githubSlug.isNotEmpty()) bySlug[s.githubSlug] = s + byTitle[normalizeKey(s.title)] = s + } + + val used = HashSet() + val result = ArrayList(github.size + summary.size) + + for (m in github) { + val hit = bySlug[m.url] ?: byTitle[normalizeKey(m.title)] + if (hit != null) used.add(hit) + val time = maxOf(hit?.updatedAt ?: 0L, blogger[normalizeKey(m.title)] ?: 0L) + result += Entry(m, time) + } + + // Manga yang hanya ada di Firestore (bagian "Random Doujin" / hasil upload admin) + for (s in summary) { + if (s in used) continue + result += Entry(summaryToManga(s), s.updatedAt) + } + return result + } + + private suspend fun fetchSummary(): List { + return try { + parseSummaryDoc(webClient.httpGet(FIRESTORE_SUMMARY_URL).parseJson()) + } catch (e: HttpStatusException) { + // 404 = dokumen ringkasan belum ada -> baca koleksi (boros kuota, hanya kalau terpaksa). + // 429 (kuota habis), 403, dll -> jangan diulang, cukup pakai GitHub + Blogger. + if (e.statusCode == 404) fetchSummaryFromCollection() else emptyList() + } catch (_: Exception) { + emptyList() + } + } + + private fun parseSummaryDoc(json: JSONObject): List { + val values = json.optJSONObject("fields") + ?.optJSONObject("list") + ?.optJSONObject("arrayValue") + ?.optJSONArray("values") ?: return emptyList() + val result = ArrayList(values.length()) + for (i in 0 until values.length()) { + val f = values.optJSONObject(i)?.optJSONObject("mapValue")?.optJSONObject("fields") ?: continue + val slug = f.fsString("slug") ?: continue + toSummaryItem(slug, f)?.let { result += it } + } + return result + } + + // Tanggal update per seri dari feed Blogger (tidak kena kuota Firestore). + // Setiap chapter = 1 postingan; ambil tanggal terbit terbaru per judul seri. + private suspend fun fetchBloggerUpdates(): Map { + val result = HashMap() + try { + for (pageIndex in 0 until 2) { + val url = "$CHAPTER_FEED_BASE?alt=json&max-results=500&start-index=${pageIndex * 500 + 1}" + val entries = webClient.httpGet(url).parseJson() + .optJSONObject("feed")?.optJSONArray("entry") ?: break + for (i in 0 until entries.length()) { + val e = entries.optJSONObject(i) ?: continue + val title = e.optJSONObject("title")?.optString($$"$t") ?: continue + val match = CHAPTER_NUM_REGEX.find(title) ?: continue + val key = normalizeKey(title.substring(0, match.range.first)) + if (key.isEmpty()) continue + val ts = parseIsoMillis(e.optJSONObject("published")?.optString($$"$t")) + if (ts > (result[key] ?: 0L)) result[key] = ts + } + if (entries.length() < 500) break + } + } catch (_: Exception) { + // feed gagal -> urutan jatuh ke urutan file JSON + } + return result + } + + private fun parseIsoMillis(raw: String?): Long { + if (raw.isNullOrBlank()) return 0L + return try { + java.time.OffsetDateTime.parse(raw).toInstant().toEpochMilli() + } catch (_: Exception) { + 0L + } + } + + private suspend fun fetchSummaryFromCollection(): List { + val result = ArrayList() + var pageToken: String? = null + var guard = 0 + try { + do { + val url = buildString { + append(FIRESTORE_COLLECTION_URL) + append("&pageSize=300") + for (field in FIRESTORE_FIELDS) append("&mask.fieldPaths=").append(field) + pageToken?.let { append("&pageToken=").append(URLEncoder.encode(it, "UTF-8")) } + } + val json = webClient.httpGet(url).parseJson() + val docs = json.optJSONArray("documents") + if (docs != null) { + for (i in 0 until docs.length()) { + val d = docs.optJSONObject(i) ?: continue + val f = d.optJSONObject("fields") ?: continue + val slug = d.optString("name").substringAfterLast('/') + if (slug.isEmpty()) continue + toSummaryItem(slug, f)?.let { result += it } + } + } + pageToken = json.optString("nextPageToken").takeIf { it.isNotEmpty() } + guard++ + } while (pageToken != null && guard < 10) + } catch (_: Exception) { + // kalau Firestore gagal, daftar tetap jalan dari GitHub saja + } + return result + } + + private fun toSummaryItem(slug: String, f: JSONObject): SummaryItem? { + if (f.optJSONObject("isDraft")?.optBoolean("booleanValue") == true) return null + return SummaryItem( + slug = slug, + githubSlug = f.fsString("githubSlug").orEmpty(), + title = f.fsString("title") ?: slug, + cover = f.fsString("cover") ?: f.fsString("img"), + status = f.fsString("status").orEmpty(), + updatedAt = f.fsMillis("updatedAt").takeIf { it > 0 } ?: f.fsMillis("latestUpdate"), + ) + } + + private fun parseState(raw: String): MangaState? = when (raw.trim().lowercase()) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + "dropped" -> MangaState.ABANDONED + else -> null + } + + private fun matchesQuery(m: Manga, query: String): Boolean { + val hay = buildString { + append(m.title) + for (t in m.altTitles) { + append(' ') + append(t) + } + } + if (hay.contains(query, ignoreCase = true)) return true + val compactQuery = normalizeKey(query) + if (compactQuery.isNotEmpty() && normalizeKey(hay).contains(compactQuery)) return true + val tokens = query.lowercase().split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() } + if (tokens.isEmpty()) return false + val words = hay.lowercase().replace(Regex("[^a-z0-9]+"), " ") + return tokens.all { words.contains(it) } + } + + private fun summaryToManga(s: SummaryItem): Manga = Manga( + id = generateUid(s.slug), + title = s.title, + altTitles = emptySet(), + url = s.slug, + publicUrl = "https://mikoroku.com/detail?slug=${s.slug}", + rating = RATING_UNKNOWN, + contentRating = null, + coverUrl = resolveCover(s.cover), + tags = emptySet(), + state = parseState(s.status), + authors = emptySet(), + largeCoverUrl = null, + description = null, + source = source, + ) + + private fun normalizeKey(s: String): String = + s.lowercase().replace(Regex("[^a-z0-9]"), "") + + private fun JSONObject.fsString(name: String): String? = + optJSONObject(name)?.optString("stringValue")?.takeIf { it.isNotEmpty() } + + private fun JSONObject.fsMillis(name: String): Long { + val v = optJSONObject(name) ?: return 0L + fun norm(n: Long) = if (n in 1 until 1_000_000_000_000L) n * 1000 else n + v.optString("integerValue").toLongOrNull()?.let { return norm(it) } + if (v.has("doubleValue")) return norm(v.optDouble("doubleValue").toLong()) + val ts = v.optString("timestampValue") + if (ts.isNotEmpty()) { + return try { + java.time.Instant.parse(ts).toEpochMilli() + } catch (_: Exception) { + 0L + } + } + return 0L + } + + private suspend fun fetchAllManga(): List { + val text = webClient.httpGet(MANGA_LIST_URL).body?.string().orEmpty() + val arr = JSONArray(text) + return arr.mapJSON(::parseManga) + } + + private fun parseManga(jo: JSONObject): Manga { + val title = jo.optString("title") + val slug = jo.optString("slug").ifBlank { slugify(title) } + return Manga( + id = generateUid(slug), + title = title, + altTitles = jo.optString("altTitle").split(';').map { it.trim() }.filter { it.isNotEmpty() }.toSet(), + url = slug, + publicUrl = "https://mikoroku.com/detail?slug=$slug", + rating = parseRating(jo), + contentRating = null, + coverUrl = resolveCover(jo.optString("cover").ifBlank { jo.optString("img") }), + tags = parseGenres(jo.optJSONArray("genres")), + state = parseState(jo.optString("status")), + authors = setOfNotNull(jo.optString("author").ifBlank { null }), + largeCoverUrl = null, + description = jo.optString("desc").ifBlank { null }, + source = source, + ) + } + + private fun parseRating(jo: JSONObject): Float { + val r = jo.optDouble("rating", Double.NaN) + return if (!r.isNaN() && r > 0.0) (r / 10.0).toFloat().coerceIn(0f, 1f) else RATING_UNKNOWN + } + + private fun parseGenres(arr: JSONArray?): Set { + if (arr == null) return emptySet() + val out = LinkedHashSet() + for (i in 0 until arr.length()) { + val g = arr.optString(i).trim() + if (g.isEmpty()) continue + out += MangaTag(key = g.lowercase(), title = g, source = source) + } + return out + } + + private fun resolveCover(raw: String?): String? { + if (raw.isNullOrBlank() || raw.trim() == "-") return null + val cleaned = raw.trim().removeSuffix(".") + return if (Regex("^https?://", RegexOption.IGNORE_CASE).containsMatchIn(cleaned)) { + cleaned + } else { + GITHUB_RAW + cleaned.removePrefix("/").replace(" ", "%20") + } + } + + private fun slugify(title: String): String { + return title.lowercase() + .replace(Regex("[^a-z0-9\\s-]"), "") + .trim() + .replace(Regex("\\s+"), "-") + } + + override suspend fun getDetails(manga: Manga): Manga { + val feed = fetchChapterFeed(manga.title) + val chapters = parseChaptersFromFeed(feed) + return manga.copy(chapters = chapters) + } + + private suspend fun fetchChapterFeed(seriesTitle: String): JSONObject { + val query = withContext(Dispatchers.IO) { + URLEncoder.encode(seriesTitle, "UTF-8") + } + val url = "$CHAPTER_FEED_BASE?alt=json&max-results=500&q=$query" + return webClient.httpGet(url).parseJson() + } + + private fun parseChaptersFromFeed(feed: JSONObject): List { + val entries = feed.optJSONObject("feed")?.optJSONArray("entry") ?: JSONArray() + return entries.mapJSONNotNull { entry -> + val entryTitle = entry.optJSONObject("title")?.optString($$"$t") ?: return@mapJSONNotNull null + if (!entryTitle.contains("chapter", ignoreCase = true)) return@mapJSONNotNull null + val match = CHAPTER_NUM_REGEX.find(entryTitle) ?: return@mapJSONNotNull null + val number = match.groupValues[1].toFloatOrNull() ?: return@mapJSONNotNull null + val entryId = entry.optJSONObject("id")?.optString($$"$t") ?: return@mapJSONNotNull null + MangaChapter( + id = generateUid(entryId), + title = entryTitle, + number = number, + volume = 0, + url = entryId, + scanlator = null, + uploadDate = parseIsoMillis(entry.optJSONObject("published")?.optString($$"$t")), + branch = null, + source = source, + ) + }.sortedBy { it.number } + } + + override suspend fun getPages(chapter: MangaChapter): List { + val seriesTitle = CHAPTER_NUM_REGEX.replace(chapter.title.orEmpty(), "").trim() + val feed = fetchChapterFeed(seriesTitle) + val entries = feed.optJSONObject("feed")?.optJSONArray("entry") ?: JSONArray() + + var contentHtml: String? = null + for (i in 0 until entries.length()) { + val entry = entries.optJSONObject(i) ?: continue + val entryId = entry.optJSONObject("id")?.optString($$"$t") + if (entryId == chapter.url) { + contentHtml = entry.optJSONObject("content")?.optString($$"$t") + break + } + } + + val html = contentHtml ?: return emptyList() + val urls = IMG_SRC_REGEX.findAll(html).map { it.groupValues[1] }.distinct().toList() + + return urls.map { url -> + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ) + } + } + + override suspend fun getPageUrl(page: MangaPage): String = page.url +} From 58984acad1dc2cb379bf39d5890801fbe360b010 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:50:57 +0200 Subject: [PATCH 32/33] Create MangaDNA.kt --- .../kotlin/tsuki/site/en/nsfw/MangaDNA.kt | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 src/main/kotlin/tsuki/site/en/nsfw/MangaDNA.kt diff --git a/src/main/kotlin/tsuki/site/en/nsfw/MangaDNA.kt b/src/main/kotlin/tsuki/site/en/nsfw/MangaDNA.kt new file mode 100644 index 00000000..057c40a6 --- /dev/null +++ b/src/main/kotlin/tsuki/site/en/nsfw/MangaDNA.kt @@ -0,0 +1,292 @@ +package tsuki.site.en.nsfw + +import tsuki.MangaLoaderContext +import tsuki.MangaSourceParser +import tsuki.config.ConfigKey +import tsuki.core.PagedMangaParser + +import tsuki.model.ContentType +import tsuki.model.Manga +import tsuki.model.MangaChapter +import tsuki.model.MangaListFilter +import tsuki.model.MangaListFilterCapabilities +import tsuki.model.MangaListFilterOptions +import tsuki.model.MangaPage +import tsuki.model.MangaParserSource +import tsuki.model.MangaState +import tsuki.model.MangaTag +import tsuki.model.RATING_UNKNOWN +import tsuki.model.SortOrder + +import tsuki.util.generateUid +import tsuki.util.parseHtml + +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import tsuki.model.ContentRating +import java.text.SimpleDateFormat +import java.util.EnumSet +import java.util.Locale +import java.util.TimeZone +import kotlin.takeIf + +@MangaSourceParser("MANGADNA", "MangaDNA", "en", ContentType.HENTAI) +internal class MangaDNA(context: MangaLoaderContext) : + PagedMangaParser(context, MangaParserSource.MANGADNA, 24) { + + override val configKeyDomain = ConfigKey.Domain("mangadna.com") + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.RATING, + SortOrder.NEWEST, + SortOrder.ALPHABETICAL, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isMultipleTagsSupported = true, + isTagsExclusionSupported = false, + ) + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = GENRES.mapTo(mutableSetOf()) { + MangaTag(key = it.second, title = it.first, source = source) + }, + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, + MangaState.PAUSED, + MangaState.ABANDONED, + ), + availableContentTypes = EnumSet.noneOf(ContentType::class.java), + ) + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query?.trim()?.takeIf { it.isNotEmpty() } + if (query != null) { + val url = "https://$domain/search".toHttpUrl().newBuilder() + .addQueryParameter("q", query) + .addQueryParameter("page", page.toString()) + .build() + return parseMangaList(webClient.httpGet(url).parseHtml()) + } + + val genre = filter.tags.firstOrNull()?.key?.takeIf { it.isNotEmpty() } + val orderBy = sortByValue(order) + + val url = if (genre.isNullOrEmpty()) { + "https://$domain/manga/page/$page".toHttpUrl().newBuilder() + } else { + "https://$domain/manga-genre/$genre/$page".toHttpUrl().newBuilder() + } + url.addQueryParameter("orderby", orderBy) + return parseMangaList(webClient.httpGet(url.build()).parseHtml()) + } + + private fun parseMangaList(document: Document): List = + document.select("div.home-item").mapNotNull { card -> + val link = card.selectFirst("h3.htitle a, .hthumb a") ?: return@mapNotNull null + val url = link.attr("abs:href") + Manga( + id = generateUid(url), + title = link.attr("title").ifBlank { link.text() }, + altTitles = emptySet(), + url = url, + publicUrl = url, + rating = RATING_UNKNOWN, + contentRating = null, + coverUrl = card.selectFirst("img")?.imgAttr(), + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + ) + } + + override suspend fun getDetails(manga: Manga): Manga { + val document = webClient.httpGet(manga.publicUrl).parseHtml() + val info = document.selectFirst("div.summary_content_wrap, div.tab-summary") ?: document + + val title = document.selectFirst("h1.entry-title")?.text() + ?: document.selectFirst("div.post-title h1, h1")?.text() + ?: manga.title + + val thumbnail = document.selectFirst("div.summary_image img")?.imgAttr() + ?: document.selectFirst("meta[property=og:image]")?.attr("content") + ?: manga.coverUrl + + val rows = info.select("div.post-content_item").associate { item -> + val label = item.selectFirst(".summary-heading")?.text() + .orEmpty().trimEnd(':').trim() + val value = item.selectFirst(".summary-content")?.text() + .orEmpty().trim() + label to value + } + + val author = info.select("div.author-content a") + .joinToString(", ") { it.text() } + .takeIf { it.isNotEmpty() && it != "Updating" } + val artist = info.select("div.artist-content a") + .joinToString(", ") { it.text() } + .takeIf { it.isNotEmpty() && it != "Updating" } + + val genreNames = info.select("div.genres-content a") + .map { it.text().trim() } + .filter { it.isNotEmpty() } + val type = rows["Type"]?.takeIf { it.isNotEmpty() && it != "Updating" } + val genres = (genreNames + listOfNotNull(type)).distinct() + + val state = parseState(rows["Status"]) + + val altNames = rows["Alternative"] + ?.takeIf { it.isNotEmpty() && it != "Updating" } + ?.split("/", ",", "|") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.toSet() + .orEmpty() + + val rating = ( + document.selectFirst(".avgrate")?.text()?.trim() + ?: document.selectFirst("#averagerate")?.text()?.trim() + )?.toFloatOrNull()?.div(5f)?.coerceIn(0f, 1f) ?: RATING_UNKNOWN + + val isAdult = genres.any { + it.equals("Adult", ignoreCase = true) || + it.equals("Uncensored", ignoreCase = true) || + it.equals("Mature", ignoreCase = true) + } + val contentRating = if (isAdult) ContentRating.ADULT else ContentRating.SAFE + + val description = document.selectFirst("meta[property=og:description]") + ?.attr("content")?.trim()?.takeIf { it.isNotEmpty() } + ?: document.selectFirst("div.dsct, div.summary__content, div.manga-summary, div.manga-content p") + ?.text()?.trim() + + val chapters = document.select("ul.row-content-chapter li.a-h").mapNotNull { li -> + val link = li.selectFirst("a.chapter-name, a") ?: return@mapNotNull null + val url = link.attr("abs:href") + val name = link.text() + val time = li.selectFirst(".chapter-time") + val raw = time?.attr("title")?.takeIf { it.isNotEmpty() } + ?: time?.text().orEmpty() + + MangaChapter( + id = generateUid(url), + title = name, + number = extractChapterNumber(name), + volume = 0, + url = url, + scanlator = null, + uploadDate = parseDate(raw), + branch = null, + source = source, + ) + }.sortedBy { it.number } + + return manga.copy( + title = title, + coverUrl = thumbnail, + largeCoverUrl = thumbnail, + description = description, + altTitles = altNames, + rating = rating, + contentRating = contentRating, + authors = (listOfNotNull(author) + listOfNotNull(artist)).toSet(), + tags = genres.mapTo(mutableSetOf()) { + MangaTag(key = it.lowercase(), title = it, source = source) + }, + state = state, + chapters = chapters, + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val document = webClient.httpGet(chapter.url).parseHtml() + return document.select("div.read-content img").mapNotNull { img -> + val url = img.imgAttr() ?: return@mapNotNull null + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ) + } + } + + private fun parseState(raw: String?): MangaState? = when (raw?.lowercase(Locale.ROOT)) { + "ongoing" -> MangaState.ONGOING + "completed", "complete", "finished" -> MangaState.FINISHED + "hiatus", "on hiatus", "on hold" -> MangaState.PAUSED + "cancelled", "canceled", "dropped" -> MangaState.ABANDONED + else -> null + } + + private fun sortByValue(order: SortOrder): String = when (order) { + SortOrder.UPDATED -> "latest" + SortOrder.POPULARITY -> "trending" + SortOrder.RATING -> "rating" + SortOrder.NEWEST -> "latest" + SortOrder.ALPHABETICAL -> "alphabet" + else -> "latest" + } + + private fun extractChapterNumber(title: String): Float = + Regex("""(\d+(?:\.\d+)?)""").find(title) + ?.groupValues?.get(1)?.toFloatOrNull() ?: 0f + + private fun parseDate(raw: String): Long { + if (raw.isBlank()) return 0L + return try { + SimpleDateFormat("dd MMM yy", Locale.ENGLISH).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.parse(raw.trim())?.time ?: 0L + } catch (_: Exception) { + 0L + } + } + + private fun Element?.imgAttr(): String? = when { + this == null -> null + hasAttr("data-src") -> attr("abs:data-src") + hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") + else -> attr("abs:src") + } + + companion object { + private val GENRES = listOf( + "Action" to "action", + "Adult" to "adult", + "Adventure" to "adventure", + "Comedy" to "comedy", + "Cooking" to "cooking", + "Crime" to "crime", + "Drama" to "drama", + "Fantasy" to "fantasy", + "Harem" to "harem", + "Historical" to "historical", + "Isekai" to "isekai", + "Magic" to "magic", + "Magical" to "magical", + "Manhua" to "manhua", + "Manhwa" to "manhwa", + "Martial Arts" to "martial-arts", + "Mature" to "mature", + "Mystery" to "mystery", + "Romance" to "romance", + "School Life" to "school-life", + "Sci-fi" to "sci-fi", + "Shounen" to "shounen", + "Shounen Ai" to "shounen-ai", + "Slice of Life" to "slice-of-life", + "Supernatural" to "supernatural", + "Thriller" to "thriller", + "Uncensored" to "uncensored", + ) + } +} From 61404588dd8535ae3c94609da45f2030136bda7c Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:10:37 +0200 Subject: [PATCH 33/33] Create XCOMIC.kt --- src/main/kotlin/tsuki/site/all/XCOMIC.kt | 1048 ++++++++++++++++++++++ 1 file changed, 1048 insertions(+) create mode 100644 src/main/kotlin/tsuki/site/all/XCOMIC.kt diff --git a/src/main/kotlin/tsuki/site/all/XCOMIC.kt b/src/main/kotlin/tsuki/site/all/XCOMIC.kt new file mode 100644 index 00000000..274a29fe --- /dev/null +++ b/src/main/kotlin/tsuki/site/all/XCOMIC.kt @@ -0,0 +1,1048 @@ +package tsuki.site.all + +import tsuki.MangaLoaderContext +import tsuki.MangaSourceParser +import tsuki.config.ConfigKey +import tsuki.core.PagedMangaParser + +import tsuki.model.ContentRating +import tsuki.model.Manga +import tsuki.model.MangaChapter +import tsuki.model.MangaListFilter +import tsuki.model.MangaListFilterCapabilities +import tsuki.model.MangaListFilterOptions +import tsuki.model.MangaPage +import tsuki.model.MangaParserSource +import tsuki.model.MangaState +import tsuki.model.MangaTag +import tsuki.model.RATING_UNKNOWN +import tsuki.model.SortOrder + +import tsuki.util.generateUid +import tsuki.util.parseRaw + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import okhttp3.Headers.Companion.toHeaders +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.json.JSONArray +import org.json.JSONObject +import org.jsoup.parser.Parser +import java.util.EnumSet +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap + +/** + * BETA + * idk + * its slow but works, need to rework graph queries + */ + +private object XComicQueries { + const val TITLE_BROWSE = $$""" + query get_title_browse($select: Title_Browse_Select) { + get_title_browse_items(select: $select) { + id + data { + title native_title romanized_title original_language + translated_languages type cover_local_url cover_url + comic_ids chap_last_public_at + } + } + } + """ + + const val TITLE_NODE = $$""" + query get_title_titleNode($id: ID!) { + get_title_titleNode(id: $id) { + id + data { + title alt_titles native_title romanized_title original_language + translated_languages authors artists year type status description + cover_local_url cover_url urlPath total_comics total_chapters + total_follows total_reviews total_comments vote_avg vote_users + chap_last_public_at is_merged merged_to comic_ids + content_rating_id type_id demographic_ids genre_ids format_ids + tracking_sites { + anilist myanimelist mangaupdates kitsu animeplanet shikimori mangabaka + } + } + } + } + """ + + const val COMIC_NODE = $$""" + query get_comicNode($id: ID!) { + get_comicNode(id: $id) { + id + data { + id name subName altNames authors artists + originalLanguage translatedLanguage originalStatus uploadStatus + type demographics contentRating genres tags publishers dbStatus isPublic + follows reviews comments_total score_val is_hot is_new + chaps_normal dateUpload + chapterNode_up_to { id data { dname datePublic } } + summary { text } + extraInfo { text } + urlPath urlCover + } + } + } + """ + + const val COMIC_PROBE = $$""" + query get_comicNode($id: ID!) { + get_comicNode(id: $id) { + id + data { + name subName dbStatus isPublic translatedLanguage chaps_normal urlPath urlCover + } + } + } + """ + + const val CHAPTER_LIST = $$""" + query get_comic_chapterList_fullList($select: Select_Comic_ChapterList) { + get_comic_chapterList_fullList(select: $select) { + paging { next total } + items { + id + data { + id comicId dbStatus isFinal volume serial dname title urlPath + dateCreate datePublic dateModify chaNum volNum count_images is_new + srcName profileNodes { data { name } } + } + } + } + } + """ + + const val CHAPTER_PAGES = $$""" + query($id: ID!) { + get_chapterNode(id: $id) { id data { imageUrls } } + } + """ +} + +private fun JSONObject.strOrNull(k: String): String? = + if (has(k) && !isNull(k)) optString(k).takeIf { it.isNotEmpty() } else null + +private fun JSONObject.longOrNull(k: String): Long? = + if (has(k) && !isNull(k)) optLong(k) else null + +private fun JSONObject.intOrNull(k: String): Int? = + if (has(k) && !isNull(k)) optInt(k) else null + +private fun JSONObject.floatOrNull(k: String): Float? = + if (has(k) && !isNull(k)) optDouble(k).toFloat() else null + +private fun JSONObject.boolOrNull(k: String): Boolean? = + if (has(k) && !isNull(k)) optBoolean(k) else null + +private fun JSONObject.objOrNull(k: String): JSONObject? = + if (has(k) && !isNull(k)) optJSONObject(k) else null + +private fun JSONObject.arrOrNull(k: String): JSONArray? = + if (has(k) && !isNull(k)) optJSONArray(k) else null + +private fun JSONObject.stringList(k: String): List { + val arr = arrOrNull(k) ?: return emptyList() + return (0 until arr.length()).mapNotNull { i -> + if (arr.isNull(i)) null else arr.optString(i).takeIf { it.isNotEmpty() } + } +} + +private fun JSONArray.objects(): List = + (0 until length()).mapNotNull { optJSONObject(it) } + +private fun JSONArray.strings(): List = + (0 until length()).mapNotNull { i -> + if (isNull(i)) null else optString(i).takeIf { it.isNotEmpty() } + } + +// ===================================================================== +// Locale โ†’ XCOMIC language code +// ===================================================================== + +private fun Locale.toXComicLangCode(): String? { + if (this == Locale.ROOT) return null + return when { + language == "pt" && country.equals("BR", true) -> "pt_br" + language == "es" && country == "419" -> "es_419" + language == "zh" && country.equals("TW", true) -> "zh_hk" + language == "zh" -> "zh" + language == "other" -> "_t" + language.isBlank() -> null + else -> language + } +} + +private fun xComicLangDisplayName(code: String): String = + XCOMIC_LANGS.firstOrNull { it.second == code }?.first ?: code.uppercase(Locale.ROOT) + +// ===================================================================== +// Parser +// ===================================================================== + +@MangaSourceParser("XCOMIC", "XCOMIC") +internal class XComic(context: MangaLoaderContext) : + PagedMangaParser(context, MangaParserSource.XCOMIC, pageSize = BROWSE_PAGE_SIZE) { + + override val configKeyDomain = ConfigKey.Domain("xcomic.me") + + init { + paginator.firstPage = 0 + searchPaginator.firstPage = 0 + } + + private val probeCache = ConcurrentHashMap() + private val titleFreshness = ConcurrentHashMap() + + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.RATING, + SortOrder.NEWEST, + SortOrder.ALPHABETICAL, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + ) + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = GENRE_TAGS.mapTo(mutableSetOf()) { (name, slug) -> + MangaTag(key = slug, title = name, source = source) + }, + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, + MangaState.PAUSED, + MangaState.ABANDONED, + ), + availableLocales = XCOMIC_LOCALES, + ) + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val apiPage = page + 1 + + val filterApiLang = filter.locale?.toXComicLangCode() + val allLanguages = filterApiLang == null + + val idMatch = ID_QUERY.matchEntire(filter.query?.trim().orEmpty()) + if (idMatch != null) { + val id = idMatch.groupValues[1].substringBefore("-") + val node = fetchTitleNode(id) ?: return emptyList() + val browseNode: TitleBrowseNode = node.toBrowseNode() + val rows = flattenTitle(browseNode, filterApiLang, forceFresh = true) + return rows.map { (tid, cid, p) -> + p.toBrowseManga(domain, tid, cid, browseNode, allLanguages) + } + } + + val variables = JSONObject().apply { + put("word", filter.query.orEmpty()) + put("page", apiPage) + put("size", BROWSE_PAGE_SIZE) + put("init", (apiPage - 1) * BROWSE_PAGE_SIZE) + put("sortby", sortFor(order)) + put("where", "browse") + + if (filter.year > 0) { + put("releaseYearMin", filter.year) + put("releaseYearMax", filter.year) + } else { + put("releaseYearMin", JSONObject.NULL) + put("releaseYearMax", JSONObject.NULL) + } + + put("incTypes", JSONArray()) + put("incDemographics", JSONArray()) + put("incContentRatings", JSONArray(filter.contentRating.mapNotNull { it.toApiRating() })) + put("incOLangs", JSONArray()) + put("incTLangs", if (allLanguages) JSONArray() else JSONArray(listOf(filterApiLang))) + put("incGenres", JSONArray(filter.tags.map { it.key })) + put("excGenres", JSONArray(filter.tagsExclude.map { it.key })) + put("incGenresMode", JSONObject.NULL) + put("excGenresMode", JSONObject.NULL) + put("origStatus", JSONArray(filter.states.mapNotNull { it.toApiStatus() })) + put("chapCount", JSONObject.NULL) + put("ignoreGlobalGenres", false) + put("ignoreGlobalULangs", false) + put("ignoreGlobalBlocks", false) + } + + val data = postGraphQL( + XComicQueries.TITLE_BROWSE, + JSONObject().apply { put("select", variables) }, + ) + + val root = data.objOrNull("data") ?: return emptyList() + val titleItems = root.arrOrNull("get_title_browse_items") ?: return emptyList() + + val titles: List = + titleItems.objects().mapNotNull { it.toTitleBrowseNode() } + if (titles.isEmpty()) return emptyList() + + val flattened: List> = coroutineScope { + titles.chunked(TITLES_IN_FLIGHT).flatMap { batch -> + batch.map { t -> async { flattenTitle(t, filterApiLang) } }.awaitAll() + } + }.flatten() + + return flattened.mapNotNull { (tid, cid, p) -> + val t = titles.firstOrNull { it.id == tid } ?: return@mapNotNull null + p.toBrowseManga(domain, tid, cid, t, allLanguages) + } + } + + private suspend fun flattenTitle( + t: TitleBrowseNode, + filterApiLang: String?, + forceFresh: Boolean = false, + ): List> { + val titleId = t.id.takeIf { it.isNotBlank() } ?: return emptyList() + val ids = t.comicIds.filter { it.isNotBlank() } + if (ids.isEmpty()) return emptyList() + + val nowPublic = t.chapLastPublicAt ?: 0L + val unchanged = !forceFresh && nowPublic in 1..(titleFreshness[titleId] ?: 0L) + + if (unchanged && ids.all { probeCache.containsKey(it) }) { + return ids.mapNotNull { cid -> + probeCache[cid]?.takeIf { + it.isLive() && (filterApiLang == null || it.translatedLanguage == filterApiLang) + }?.let { Triple(titleId, cid, it) } + }.sortedByDescending { it.third.chapsNormal ?: 0 } + } + + val probes = mutableMapOf() + coroutineScope { + ids.chunked(COMIC_PROBES_PER_TITLE).flatMap { chunk -> + chunk.map { cid -> async { fetchComicProbe(cid)?.let { probes[cid] = it } } } + .awaitAll() + } + } + if (nowPublic > 0) titleFreshness[titleId] = nowPublic + return probes.entries + .filter { it.value.isLive() && (filterApiLang == null || it.value.translatedLanguage == filterApiLang) } + .map { (cid, p) -> Triple(titleId, cid, p) } + .sortedByDescending { it.third.chapsNormal ?: 0 } + } + + private fun ComicProbe.toBrowseManga( + domain: String, + titleId: String, + comicId: String, + t: TitleBrowseNode, + allLanguages: Boolean, + ): Manga { + val url = "$titleId:$comicId" + val displayTitle = cleanTitle(t.title.orEmpty()).ifBlank { titleId } + val titleText = buildString { + append(displayTitle) + subName?.takeIf { it.isNotBlank() }?.let { append(" ยท ", it.unescapeHtml()) } + if (allLanguages) translatedLanguage?.let { append(" [", xComicLangDisplayName(it), "]") } + } + val cover = (t.coverLocalUrl ?: t.coverUrl ?: urlCover) + ?.let { if (it.startsWith("http")) it else "https://$domain$it" } + + return Manga( + id = generateUid(url), + url = url, + publicUrl = "https://$domain/title/$titleId", + title = titleText, + altTitles = emptySet(), + rating = RATING_UNKNOWN, + contentRating = null, + coverUrl = cover, + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + ) + } + + override suspend fun getDetails(manga: Manga): Manga = coroutineScope { + val (titleId, pinned) = splitMangaUrl(manga.url) + val title = fetchTitleNode(titleId) + ?: return@coroutineScope manga.copy(chapters = emptyList()) + + val resolved = if (title.isMerged == true && + !title.mergedTo.isNullOrBlank() && + title.mergedTo != titleId + ) { + fetchTitleNode(title.mergedTo) ?: title + } else title + + val pair = pinned?.let { pid -> + fetchComicNode(pid)?.takeIf { it.isLive() }?.let { pid to it } + } ?: pickComic(resolved.comicIds.filter { it.isNotBlank() }, null) + ?: return@coroutineScope manga.copy(chapters = emptyList()) + + val (comicId, comic) = pair + val chapters = fetchChapters(comicId) + + val base = comic.toManga(domain, comicId) + + val isAdult = listOfNotNull( + resolved.contentRating, + comic.contentRating, + ).any { it.contains("explicit", true) || it.contains("adult", true) } + + base.copy( + title = resolved.title?.let { cleanTitle(it) } ?: base.title, + altTitles = resolved.altTitles.toSet(), + description = buildDescription(resolved, comic), + authors = (resolved.authors + comic.authorNames).toSet(), + tags = (resolved.genreIds + comic.genres).mapTo(mutableSetOf()) { + MangaTag(key = it, title = it.toTagCase(), source = source) + }, + state = comic.status().toMangaState(), + rating = resolved.voteAvg?.div(5f)?.coerceIn(0f, 1f) ?: RATING_UNKNOWN, + contentRating = if (isAdult) ContentRating.ADULT else ContentRating.SAFE, + coverUrl = resolved.coverLocalUrl?.let { + if (it.startsWith("http")) it else "https://$domain$it" + } ?: base.coverUrl, + chapters = chapters, + ) + } + + private fun ComicNode.toManga(domain: String, comicId: String): Manga = Manga( + id = generateUid(comicId), + url = comicId, + publicUrl = "https://$domain/source/$comicId", + title = cleanTitle(name), + altTitles = emptySet(), + rating = scoreVal?.div(5f)?.coerceIn(0f, 1f) ?: RATING_UNKNOWN, + contentRating = if (contentRating?.contains("explicit", true) == true || + contentRating?.contains("adult", true) == true + ) ContentRating.ADULT else ContentRating.SAFE, + coverUrl = urlCover?.let { if (it.startsWith("http")) it else "https://$domain$it" }, + tags = emptySet(), + state = status().toMangaState(), + authors = authorNames.toSet(), + source = source, + ) + + private fun buildDescription(t: TitleNodeData, c: ComicNode): String = buildString { + if (c.isHot == true) append("๐Ÿ”ฅ HOT ") + if (c.isNew == true) append("โœจ NEW") + if (c.isHot == true || c.isNew == true) append("\n\n") + + val meta = buildList { + t.originalLanguage?.let { add("**Original**: ${xComicLangDisplayName(it)}") } + t.translatedLanguages.takeIf { it.isNotEmpty() }?.let { + add("**Translated**: ${it.joinToString { l -> xComicLangDisplayName(l) }}") + } + t.year?.takeIf { it > 0 }?.let { add("**Released**: $it") } + c.type?.let { add("**Type**: ${it.toTagCase()}") } + } + if (meta.isNotEmpty()) { + append(meta.joinToString("\n")) + append("\n\n") + } + + c.summary?.takeIf { it.isNotBlank() }?.let { append(it.toMarkdownUrls()) } + + val stats = buildList { + t.voteAvg?.takeIf { it > 0 }?.let { add("**Score**: %.1f".format(it)) } + t.totalFollows?.takeIf { it > 0 }?.let { add("**Follows**: $it") } + t.totalReviews?.takeIf { it > 0 }?.let { add("**Reviews**: $it") } + t.totalComments?.takeIf { it > 0 }?.let { add("**Comments**: $it") } + } + if (stats.isNotEmpty()) { + append("\n\n**Statistics**\n${stats.joinToString(" ยท ")}") + } + + t.altTitles.filter { it.isNotBlank() && it != t.title }.takeIf { it.isNotEmpty() }?.let { + append("\n\n**Alternative Titles**:\n") + append(it.joinToString("\n") { a -> "- $a" }) + } + } + + private suspend fun fetchChapters(comicId: String): List = coroutineScope { + val pageSize = 100 + val first = fetchChapterPage(comicId, 1, pageSize) + val all = first.chapters.toMutableList() + val total = first.total ?: 0 + if (total > pageSize && first.hasNext) { + val totalPages = (total + pageSize - 1) / pageSize + (2..totalPages).chunked(3).forEach { batch -> + val pages = batch.map { p -> async { fetchChapterPage(comicId, p, pageSize).chapters } } + all.addAll(pages.awaitAll().flatten()) + } + } + all.sortedBy { it.number } + } + + private data class ChapterPage(val chapters: List, val total: Int?, val hasNext: Boolean) + + private suspend fun fetchChapterPage(comicId: String, page: Int, size: Int): ChapterPage { + val variables = JSONObject().apply { + put( + "select", + JSONObject().apply { + put("comic_id", comicId) + put("page", page) + put("size", size) + put("sortby", "chapter_desc") + }, + ) + } + + val data = postGraphQL(XComicQueries.CHAPTER_LIST, variables) + val root = data.objOrNull("data")?.objOrNull("get_comic_chapterList_fullList") + ?: return ChapterPage(emptyList(), 0, false) + val paging = root.objOrNull("paging") + val items = root.arrOrNull("items") ?: return ChapterPage(emptyList(), 0, false) + + return ChapterPage( + chapters = items.objects().mapNotNull { it.toChapter() }, + total = paging?.intOrNull("total"), + hasNext = (paging?.intOrNull("next") ?: 0) != 0, + ) + } + + private fun JSONObject.toChapter(): MangaChapter? { + val wrapperData = objOrNull("data") ?: return null + val chapterId = wrapperData.strOrNull("id") ?: return null + val number = wrapperData.floatOrNull("chaNum") ?: wrapperData.floatOrNull("serial") ?: 0f + val dname = wrapperData.optString("dname", "") + val title = wrapperData.strOrNull("title") + val srcName = wrapperData.strOrNull("srcName") + val profileNames = wrapperData.arrOrNull("profileNodes") + ?.objects() + ?.mapNotNull { it.objOrNull("data")?.strOrNull("name") } + ?.joinToString() + ?.takeIf { it.isNotEmpty() } + val date = wrapperData.longOrNull("dateModify") + ?: wrapperData.longOrNull("dateCreate") + ?: wrapperData.longOrNull("datePublic") + ?: 0L + + val name = buildString { + val n = number.toString().removeSuffix(".0") + if (!dname.contains(n)) append("Chapter ", n) + if (dname.isNotEmpty()) { + if (isNotEmpty()) append(": ") + append(dname) + } + if (!title.isNullOrEmpty()) { + if (isNotEmpty()) append(": ") + append(title) + } + } + + return MangaChapter( + id = generateUid(chapterId), + title = name, + number = number, + volume = 0, + url = chapterId, + scanlator = srcName ?: profileNames, + uploadDate = date, + branch = null, + source = source, + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val chapterId = chapter.url.substringBefore(":") + val data = postGraphQL( + XComicQueries.CHAPTER_PAGES, + JSONObject().apply { put("id", chapterId) }, + ) + val urls = data.objOrNull("data") + ?.objOrNull("get_chapterNode") + ?.objOrNull("data") + ?.arrOrNull("imageUrls") + ?.strings() + ?: return emptyList() + + return urls.map { url -> + val abs = if (url.startsWith("http")) url else "https://$domain$url" + MangaPage(id = generateUid(abs), url = abs, preview = null, source = source) + } + } + + private suspend fun postGraphQL(query: String, variables: JSONObject): JSONObject { + val payload = JSONObject().apply { + put("query", query) + put("variables", variables) + } + + val headers = mapOf( + "Origin" to "https://$domain", + "Referer" to "https://$domain/", + ).toHeaders() + + val response = webClient.httpPost( + "https://$domain/query/".toHttpUrl(), + payload, + headers, + ) + + val text = response.parseRaw() + return runCatching { JSONObject(text) }.getOrElse { JSONObject() } + } + + private suspend fun fetchTitleNode(id: String): TitleNodeData? { + return try { + val data = postGraphQL( + XComicQueries.TITLE_NODE, + JSONObject().apply { put("id", id) }, + ) + data.objOrNull("data") + ?.objOrNull("get_title_titleNode") + ?.objOrNull("data") + ?.toTitleNodeData() + } catch (_: Exception) { + null + } + } + + private suspend fun fetchComicNode(id: String): ComicNode? { + return try { + val data = postGraphQL( + XComicQueries.COMIC_NODE, + JSONObject().apply { put("id", id) }, + ) + data.objOrNull("data") + ?.objOrNull("get_comicNode") + ?.objOrNull("data") + ?.toComicNode(id) + } catch (_: Exception) { + null + } + } + + private suspend fun fetchComicProbe(id: String): ComicProbe? { + probeCache[id]?.let { return it } + val fetched = try { + val data = postGraphQL( + XComicQueries.COMIC_PROBE, + JSONObject().apply { put("id", id) }, + ) + data.objOrNull("data") + ?.objOrNull("get_comicNode") + ?.objOrNull("data") + ?.toComicProbe() + } catch (_: Exception) { + null + } + if (fetched != null) probeCache[id] = fetched + return fetched + } + + private suspend fun pickComic(ids: List, filterApiLang: String?): Pair? { + if (ids.isEmpty()) return null + val nodes = coroutineScope { + ids.chunked(COMIC_PROBES_PER_TITLE).flatMap { chunk -> + chunk.map { cid -> async { fetchComicNode(cid)?.let { cid to it } } } + .awaitAll().filterNotNull() + } + }.filter { it.second.isLive() } + + return nodes.filter { filterApiLang == null || it.second.translatedLanguage == filterApiLang } + .maxByOrNull { it.second.chapsNormal ?: 0 } + ?: nodes.firstOrNull()?.takeIf { filterApiLang == null } + } + + private fun JSONObject.toTitleBrowseNode(): TitleBrowseNode? { + val id = strOrNull("id") ?: return null + val d = objOrNull("data") ?: return null + return TitleBrowseNode( + id = id, + title = d.strOrNull("title"), + coverLocalUrl = d.strOrNull("cover_local_url"), + coverUrl = d.strOrNull("cover_url"), + chapLastPublicAt = d.longOrNull("chap_last_public_at"), + comicIds = d.stringList("comic_ids"), + ) + } + + private fun JSONObject.toTitleNodeData(): TitleNodeData = TitleNodeData( + id = strOrNull("id"), + title = strOrNull("title"), + altTitles = stringList("alt_titles"), + originalLanguage = strOrNull("original_language"), + translatedLanguages = stringList("translated_languages"), + authors = stringList("authors"), + artists = stringList("artists"), + contentRating = strOrNull("content_rating_id"), + genreIds = stringList("genre_ids"), + year = intOrNull("year"), + type = strOrNull("type"), + status = strOrNull("status"), + description = strOrNull("description"), + coverLocalUrl = strOrNull("cover_local_url"), + coverUrl = strOrNull("cover_url"), + voteAvg = floatOrNull("vote_avg"), + totalFollows = intOrNull("total_follows"), + totalReviews = intOrNull("total_reviews"), + totalComments = intOrNull("total_comments"), + chapLastPublicAt = longOrNull("chap_last_public_at"), + isMerged = boolOrNull("is_merged"), + mergedTo = strOrNull("merged_to"), + comicIds = stringList("comic_ids"), + ) + + private fun JSONObject.toComicNode(fallbackId: String): ComicNode = ComicNode( + id = strOrNull("id") ?: fallbackId, + name = strOrNull("name").orEmpty(), + subName = strOrNull("subName"), + translatedLanguage = strOrNull("translatedLanguage"), + originalStatus = strOrNull("originalStatus"), + uploadStatus = strOrNull("uploadStatus"), + type = strOrNull("type"), + contentRating = strOrNull("contentRating"), + genres = stringList("genres"), + authorNames = arrOrNull("authorNodes") + ?.objects() + ?.mapNotNull { it.objOrNull("data")?.strOrNull("name") } + ?: stringList("authors"), + summary = objOrNull("summary")?.strOrNull("text"), + dbStatus = strOrNull("dbStatus"), + isPublic = boolOrNull("isPublic"), + isHot = boolOrNull("is_hot"), + isNew = boolOrNull("is_new"), + scoreVal = floatOrNull("score_val"), + chapsNormal = intOrNull("chaps_normal"), + urlCover = strOrNull("urlCover"), + ) + + private fun JSONObject.toComicProbe(): ComicProbe = ComicProbe( + name = strOrNull("name"), + subName = strOrNull("subName"), + dbStatus = strOrNull("dbStatus"), + isPublic = boolOrNull("isPublic"), + translatedLanguage = strOrNull("translatedLanguage"), + chapsNormal = intOrNull("chaps_normal"), + urlCover = strOrNull("urlCover"), + ) + + private data class TitleBrowseNode( + val id: String, + val title: String?, + val coverLocalUrl: String?, + val coverUrl: String?, + val chapLastPublicAt: Long?, + val comicIds: List, + ) + + private data class TitleNodeData( + val id: String?, + val title: String?, + val altTitles: List, + val originalLanguage: String?, + val translatedLanguages: List, + val authors: List, + val artists: List, + val contentRating: String?, + val genreIds: List, + val year: Int?, + val type: String?, + val status: String?, + val description: String?, + val coverLocalUrl: String?, + val coverUrl: String?, + val voteAvg: Float?, + val totalFollows: Int?, + val totalReviews: Int?, + val totalComments: Int?, + val chapLastPublicAt: Long?, + val isMerged: Boolean?, + val mergedTo: String?, + val comicIds: List, + ) { + fun toBrowseNode() = TitleBrowseNode( + id = id.orEmpty(), + title = title, + coverLocalUrl = coverLocalUrl, + coverUrl = coverUrl, + chapLastPublicAt = chapLastPublicAt, + comicIds = comicIds, + ) + } + + private data class ComicNode( + val id: String, + val name: String, + val subName: String?, + val translatedLanguage: String?, + val originalStatus: String?, + val uploadStatus: String?, + val type: String?, + val contentRating: String?, + val genres: List, + val authorNames: List, + val summary: String?, + val dbStatus: String?, + val isPublic: Boolean?, + val isHot: Boolean?, + val isNew: Boolean?, + val scoreVal: Float?, + val chapsNormal: Int?, + val urlCover: String?, + ) { + fun isLive() = isPublic != false && (dbStatus == null || dbStatus == "normal") + fun status(): String? = originalStatus ?: uploadStatus + } + + private data class ComicProbe( + val name: String?, + val subName: String?, + val dbStatus: String?, + val isPublic: Boolean?, + val translatedLanguage: String?, + val chapsNormal: Int?, + val urlCover: String?, + ) { + fun isLive() = isPublic != false && (dbStatus == null || dbStatus == "normal") + } + + private fun splitMangaUrl(url: String): Pair { + val i = url.indexOf(':') + return if (i < 0) url to null else url.substring(0, i) to url.substring(i + 1) + } + + private fun sortFor(order: SortOrder): String = when (order) { + SortOrder.POPULARITY -> "field_score" + SortOrder.UPDATED, SortOrder.NEWEST -> "field_update" + SortOrder.RATING -> "field_score" + SortOrder.ALPHABETICAL -> "field_name_asc" + else -> "field_update" + } + + private fun MangaState.toApiStatus(): String? = when (this) { + MangaState.ONGOING -> "releasing" + MangaState.FINISHED -> "completed" + MangaState.PAUSED -> "hiatus" + MangaState.ABANDONED -> "cancelled" + else -> null + } + + private fun ContentRating.toApiRating(): String? = when (this) { + ContentRating.ADULT -> "explicit" + ContentRating.SAFE -> "safe" + else -> null + } + + private fun String?.toMangaState(): MangaState? = when { + this == null -> null + contains("pending") -> null + contains("ongoing") || contains("releasing") -> MangaState.ONGOING + contains("cancelled") -> MangaState.ABANDONED + contains("hiatus") -> MangaState.PAUSED + contains("completed") -> MangaState.FINISHED + else -> null + } + + private fun String.toTagCase(): String = replace("_", " ") + .split(" ").joinToString(" ") { w -> + w.lowercase(Locale.ROOT) + .replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() } + } + + private fun String.unescapeHtml(): String = Parser.unescapeEntities(this, false) + + private fun cleanTitle(title: String): String = title + .replace(TITLE_REGEX, "") + .trim() + + private fun String.toMarkdownUrls(): String = + replace(URL_REGEX) { "[${it.value}](${it.value})" } +} + +private const val BROWSE_PAGE_SIZE = 12 +private const val TITLES_IN_FLIGHT = 3 +private const val COMIC_PROBES_PER_TITLE = 5 + +private val ID_QUERY = Regex("^id\\s*:?\\s*([a-zA-Z0-9-_]+)\\s*$", RegexOption.IGNORE_CASE) + +private val URL_REGEX = Regex("""(?> = listOf( + "English" to "en", + "French" to "fr", + "Portuguese" to "pt", + "Portuguese (BR)" to "pt_br", + "Spanish" to "es", + "Spanish (LA)" to "es_419", + "Korean" to "ko", + "Japanese" to "ja", + "Indonesian" to "id", + "Chinese" to "zh", + "Chinese (Traditional)" to "zh_hk", + "Russian" to "ru", + "German" to "de", + "Italian" to "it", + "Arabic" to "ar", + "Thai" to "th", + "Vietnamese" to "vi", + "Turkish" to "tr", + "Polish" to "pl", + "Ukrainian" to "uk", + "Filipino" to "fil", + "Abkhazian" to "ab", + "Afrikaans" to "af", + "Albanian" to "sq", + "Amharic" to "am", + "Armenian" to "hy", + "Azerbaijani" to "az", + "Belarusian" to "be", + "Bengali" to "bn", + "Bosnian" to "bs", + "Bulgarian" to "bg", + "Burmese" to "my", + "Cambodian" to "km", + "Catalan" to "ca", + "Cebuano" to "ceb", + "Croatian" to "hr", + "Czech" to "cs", + "Chuvash" to "cv", + "Danish" to "da", + "Dutch" to "nl", + "Estonian" to "et", + "Esperanto" to "eo", + "Basque" to "eu", + "Faroese" to "fo", + "Finnish" to "fi", + "Georgian" to "ka", + "Greek" to "el", + "Guarani" to "gn", + "Gujarati" to "gu", + "Haitian Creole" to "ht", + "Hausa" to "ha", + "Hebrew" to "he", + "Hindi" to "hi", + "Hungarian" to "hu", + "Icelandic" to "is", + "Igbo" to "ig", + "Irish" to "ga", + "Galician" to "gl", + "Javanese" to "jv", + "Kannada" to "kn", + "Kazakh" to "kk", + "Kurdish" to "ku", + "Kyrgyz" to "ky", + "Latin" to "la", + "Laothian" to "lo", + "Latvian" to "lv", + "Lithuanian" to "lt", + "Luxembourgish" to "lb", + "Macedonian" to "mk", + "Malagasy" to "mg", + "Malay" to "ms", + "Malayalam" to "ml", + "Maltese" to "mt", + "Maori" to "mi", + "Marathi" to "mr", + "Moldavian" to "mo", + "Mongolian" to "mn", + "Nepali" to "ne", + "Norwegian" to "no", + "Nyanja" to "ny", + "Pashto" to "ps", + "Persian" to "fa", + "Romanian" to "ro", + "Romansh" to "rm", + "Samoan" to "sm", + "Serbian" to "sr", + "Serbo-Croatian" to "sh", + "Siswati" to "ss", + "Sesotho" to "st", + "Shona" to "sn", + "Sindhi" to "sd", + "Sinhalese" to "si", + "Slovak" to "sk", + "Slovenian" to "sl", + "Somali" to "so", + "Swahili" to "sw", + "Swedish" to "sv", + "Tajik" to "tg", + "Tamil" to "ta", + "Telugu" to "te", + "Tigrinya" to "ti", + "Tonga" to "to", + "Turkmen" to "tk", + "Urdu" to "ur", + "Uzbek" to "uz", + "Yoruba" to "yo", + "Zulu" to "zu", + "Other" to "_t", +) + +private val XCOMIC_LOCALES: Set = mutableSetOf().apply { + add(Locale.ENGLISH) + add(Locale.FRENCH) + add(Locale.GERMAN) + add(Locale.ITALIAN) + add(Locale.JAPANESE) + add(Locale.KOREAN) + add(Locale("es")) + add(Locale("es", "419")) + add(Locale("pt")) + add(Locale("pt", "BR")) + add(Locale.SIMPLIFIED_CHINESE) + add(Locale.TRADITIONAL_CHINESE) + add(Locale("ru")) + add(Locale("id")) + add(Locale("ar")) + add(Locale("th")) + add(Locale("vi")) + add(Locale("tr")) + add(Locale("pl")) + add(Locale("uk")) + add(Locale("fil")) + add(Locale("other")) + + val alreadyAdded = setOf( + "en", "fr", "de", "it", "ja", "ko", + "es", "pt", "zh", "ru", "id", "ar", "th", + "vi", "tr", "pl", "uk", "fil", + ) + XCOMIC_LANGS + .map { it.second } + .filter { it.length == 2 && it !in alreadyAdded } + .forEach { add(Locale(it)) } +}.toSet() + +private val GENRE_TAGS: List> = listOf( + "Action" to "action", + "Adult" to "adult", + "Adventure" to "adventure", + "Comedy" to "comedy", + "Cooking" to "cooking", + "Crime" to "crime", + "Drama" to "drama", + "Fantasy" to "fantasy", + "Harem" to "harem", + "Historical" to "historical", + "Isekai" to "isekai", + "Magic" to "magic", + "Mature" to "mature", + "Mystery" to "mystery", + "Romance" to "romance", + "School Life" to "school_life", + "Sci-fi" to "sci_fi", + "Shounen" to "shounen", + "Shounen Ai" to "shounen_ai", + "Slice of Life" to "slice_of_life", + "Supernatural" to "supernatural", + "Thriller" to "thriller", + "Uncensored" to "uncensored", +)