From 8e9b88d2e4fcce30b8bd9a471bc64024fa052f1c Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:14:24 +0200 Subject: [PATCH 01/13] Update configKeyDomain from v1 to v2 --- 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 13288505..680e1214 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("v1.voratoon.com") + override val configKeyDomain = ConfigKey.Domain("v2.voratoon.com") private val apiBase = "https://$domain/backend" From 9822b891e2f10abbef63bff9c6496a158b2076d7 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:57:50 +0200 Subject: [PATCH 02/13] Update Mangadotnet.kt --- src/main/kotlin/tsuki/site/all/Mangadotnet.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/tsuki/site/all/Mangadotnet.kt b/src/main/kotlin/tsuki/site/all/Mangadotnet.kt index 0a6b4040..d81055b3 100644 --- a/src/main/kotlin/tsuki/site/all/Mangadotnet.kt +++ b/src/main/kotlin/tsuki/site/all/Mangadotnet.kt @@ -145,6 +145,7 @@ internal class Mangadotnet(context: MangaLoaderContext) : ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, + ContentType.ONE_SHOT, ContentType.OTHER, ), availableDemographics = EnumSet.of( @@ -201,7 +202,8 @@ internal class Mangadotnet(context: MangaLoaderContext) : ContentType.MANGA -> addQueryParameter("origin", "JP") ContentType.MANHWA -> addQueryParameter("origin", "KR") ContentType.MANHUA -> addQueryParameter("origin", "CN") - ContentType.OTHER -> addQueryParameter("origin", "ONESHOT") + ContentType.ONE_SHOT -> addQueryParameter("origin", "ONESHOT") + ContentType.OTHER -> addQueryParameter("origin", "EN") else -> {} } } From 0572433539cdc34ceb3baf8111105997f4f70bfc Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:05:22 +0200 Subject: [PATCH 03/13] fix search --- src/main/kotlin/tsuki/site/id/VoraToon.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/kotlin/tsuki/site/id/VoraToon.kt b/src/main/kotlin/tsuki/site/id/VoraToon.kt index 680e1214..0e91f5aa 100644 --- a/src/main/kotlin/tsuki/site/id/VoraToon.kt +++ b/src/main/kotlin/tsuki/site/id/VoraToon.kt @@ -109,8 +109,7 @@ internal class VoraToon(context: MangaLoaderContext) : } if (!filter.query.isNullOrEmpty()) { - val q = URLEncoder.encode(filter.query, "UTF-8") - append("&filter=title=like=\"$q\",nativeTitle=like=\"$q\"") + append("&title=").append(URLEncoder.encode(filter.query, "UTF-8")) } filter.states.oneOrThrowIfMany()?.let { state -> From 4daf2c27c6f2460d6dfd605cec0fed8647df853c Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:11:12 +0200 Subject: [PATCH 04/13] fix images --- src/main/kotlin/tsuki/site/en/Mgread.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/tsuki/site/en/Mgread.kt b/src/main/kotlin/tsuki/site/en/Mgread.kt index 491a1e5b..370eb72d 100644 --- a/src/main/kotlin/tsuki/site/en/Mgread.kt +++ b/src/main/kotlin/tsuki/site/en/Mgread.kt @@ -347,11 +347,11 @@ internal class MgreadIo(context: MangaLoaderContext) : override suspend fun getPages(chapter: MangaChapter): List { val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() - return doc.select("#chapter-content img[src]").mapIndexed { _, img -> - val src = img.absUrl("src") + return doc.select("#chapter-content img[data-original-src], #chapter-content img[src]").mapIndexed { _, img -> + val url = img.absUrl("data-original-src").ifEmpty { img.absUrl("src") } MangaPage( - id = generateUid(src), - url = src, + id = generateUid(url), + url = url, preview = null, source = source, ) From c6fa131fe3e9afd780b9419c048d6b2fe7817de4 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:42:28 +0200 Subject: [PATCH 05/13] Update print statement from 'Hello' to 'Goodbye' --- src/main/kotlin/tsuki/site/en/Mangago.kt | 440 +++++++++++------------ 1 file changed, 219 insertions(+), 221 deletions(-) diff --git a/src/main/kotlin/tsuki/site/en/Mangago.kt b/src/main/kotlin/tsuki/site/en/Mangago.kt index 9f6b90dd..99236757 100644 --- a/src/main/kotlin/tsuki/site/en/Mangago.kt +++ b/src/main/kotlin/tsuki/site/en/Mangago.kt @@ -6,6 +6,7 @@ import tsuki.config.ConfigKey import tsuki.core.PagedMangaParser import tsuki.bitmap.Bitmap import tsuki.bitmap.Rect +import tsuki.network.OkHttpWebClient import tsuki.model.ContentRating import tsuki.model.Manga @@ -35,6 +36,7 @@ import tsuki.util.extractChapterNumber import okhttp3.Interceptor import okhttp3.Response import org.jsoup.nodes.Document +import java.security.MessageDigest import java.text.SimpleDateFormat import java.util.EnumSet import java.util.Locale @@ -45,14 +47,30 @@ import kotlin.math.min @MangaSourceParser("MANGAGO", "Mangago", "en") internal class MangagoParser(context: MangaLoaderContext) : - PagedMangaParser(context, MangaParserSource.MANGAGO, pageSize = 20), Interceptor { + PagedMangaParser(context, MangaParserSource.MANGAGO, pageSize = 20), + Interceptor { - override val configKeyDomain = ConfigKey.Domain("www.mangago.me") + override val configKeyDomain = ConfigKey.Domain("mangago.me") + + private val baseUrl = "https://$domain" + + override val webClient = OkHttpWebClient( + context.httpClient.newBuilder() + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .header("Cookie", "_m_superu=1") + .build() + chain.proceed(request) + } + .addInterceptor(this) + .build(), + source, + ) override val availableSortOrders: Set = EnumSet.of( SortOrder.POPULARITY, SortOrder.UPDATED, - SortOrder.NEWEST + SortOrder.NEWEST, ) override val filterCapabilities: MangaListFilterCapabilities @@ -67,6 +85,14 @@ internal class MangagoParser(context: MangaLoaderContext) : availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), ) + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + private val removeRaws: Boolean = true + private val removeTitleVersion: Boolean = false + private fun getAvailableTags(): Set = setOf( MangaTag("Yaoi", "Yaoi", source), MangaTag("Doujinshi", "Doujinshi", source), @@ -107,19 +133,12 @@ internal class MangagoParser(context: MangaLoaderContext) : MangaTag("Webtoons", "Webtoons", source), ) - override fun onCreateConfig(keys: MutableCollection>) { - super.onCreateConfig(keys) - keys.add(userAgentKey) - } - private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH) override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - if (filter.query != null) { + if (!filter.query.isNullOrBlank()) { val url = buildString { - append("https://") - append(domain) - append("/r/l_search?name=") + append("$baseUrl/r/l_search?name=") append(filter.query.urlEncoded()) append("&page=") append(page) @@ -127,54 +146,33 @@ internal class MangagoParser(context: MangaLoaderContext) : return parseMangaList(webClient.httpGet(url).parseHtml()) } - val url = when (order) { - SortOrder.NEWEST -> { - buildString { - append("https://") - append(domain) - append("/list/new/") - if (filter.tags.isNotEmpty()) { - filter.tags.joinTo(this, ",") { it.key } - } else { - append("all") - } - append("/") - append(page) - append("/") - } + val url = buildString { + append("$baseUrl/genre/") + if (filter.tags.isNotEmpty()) { + filter.tags.joinTo(this, ",") { it.key } + } else { + append("all") } - else -> { - buildString { - append("https://") - append(domain) - append("/genre/") - if (filter.tags.isNotEmpty()) { - filter.tags.joinTo(this, ",") { it.key } - } else { - append("all") - } - append("/") - append(page) - append("/?") - val states = filter.states - val showFinished = states.isEmpty() || states.contains(MangaState.FINISHED) - val showOngoing = states.isEmpty() || states.contains(MangaState.ONGOING) - append("f=").append(if (showFinished) "1" else "0") - append("&o=").append(if (showOngoing) "1" else "0") - append("&sortby=") - when (order) { - SortOrder.POPULARITY -> append("view") - SortOrder.UPDATED -> append("update_date") - else -> append("update_date") - } - append("&e=") - if (filter.tagsExclude.isNotEmpty()) { - filter.tagsExclude.joinTo(this, ",") { it.key } - } - } + append("/$page/?") + + val states = filter.states + val showFinished = states.isEmpty() || states.contains(MangaState.FINISHED) + val showOngoing = states.isEmpty() || states.contains(MangaState.ONGOING) + append("f=").append(if (showFinished) "1" else "0") + append("&o=").append(if (showOngoing) "1" else "0") + + append("&sortby=") + when (order) { + SortOrder.POPULARITY -> append("view") + SortOrder.UPDATED, SortOrder.NEWEST -> append("update_date") + else -> append("update_date") } - } + append("&e=") + if (filter.tagsExclude.isNotEmpty()) { + filter.tagsExclude.joinTo(this, ",") { it.key } + } + } return parseMangaList(webClient.httpGet(url).parseHtml()) } @@ -189,7 +187,6 @@ internal class MangagoParser(context: MangaLoaderContext) : val thumbnailUrl = thumbnailElem.attr("abs:data-src").ifEmpty { thumbnailElem.attr("abs:src") } - Manga( id = generateUid(href), url = href, @@ -213,10 +210,11 @@ internal class MangagoParser(context: MangaLoaderContext) : val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val infoBlock = doc.requireElementById("information") - val title = doc.selectFirstOrThrow(".w-title h1").text() + val rawTitle = doc.selectFirstOrThrow(".w-title h1").text() + val title = if (removeTitleVersion) rawTitle.replace(TITLE_REGEX, "").trim() else rawTitle val thumbnail = infoBlock.selectFirstOrThrow("img").absUrl("src") - var description = infoBlock.selectFirst(".manga_summary")?.let { summary -> + val description = infoBlock.selectFirst(".manga_summary")?.let { summary -> summary.selectFirst("font")?.remove() summary.text() }.orEmpty() @@ -224,10 +222,17 @@ internal class MangagoParser(context: MangaLoaderContext) : var author: String? = null var genres = emptySet() var state: MangaState? = null + val altNames = mutableListOf() infoBlock.select(".manga_info li, .manga_right tr").forEach { el -> - when (el.selectFirst("b, label")?.text()?.lowercase()) { - "alternative:" -> description += "\n\n${el.text()}" + val label = el.selectFirst("b, label")?.text()?.lowercase() ?: return@forEach + when (label) { + "alternative:" -> { + val raw = el.text().removePrefix(el.selectFirst("b, label")?.text() ?: "").trim() + val split = if ('/' in raw || ';' in raw) raw.split('/', ';') else raw.split(',') + altNames += split.map { it.trim() } + .filter { it.isNotEmpty() && !it.equals("None", ignoreCase = true) } + } "status:" -> state = when (el.selectFirstOrThrow("span").text().lowercase()) { "ongoing" -> MangaState.ONGOING "completed" -> MangaState.FINISHED @@ -235,11 +240,7 @@ internal class MangagoParser(context: MangaLoaderContext) : } "author(s):", "author:" -> author = el.select("a").joinToString { it.text() } "genre(s):" -> genres = el.select("a").mapToSet { - MangaTag( - key = it.text(), - title = it.text(), - source = source, - ) + MangaTag(key = it.text(), title = it.text(), source = source) } } } @@ -248,8 +249,9 @@ internal class MangagoParser(context: MangaLoaderContext) : return manga.copy( title = title, + altTitles = altNames.toSet(), coverUrl = thumbnail, - description = description, + description = description.ifEmpty { null }, authors = author?.let { setOf(it) } ?: emptySet(), tags = genres, state = state, @@ -269,97 +271,66 @@ internal class MangagoParser(context: MangaLoaderContext) : .mapNotNull { element -> val link = element.selectFirst("a.chico") ?: return@mapNotNull null val url = link.attrAsAbsoluteUrlOrNull("href") ?: return@mapNotNull null + if (removeRaws && url.contains("/raw/")) return@mapNotNull null val name = link.text().trim() - val dateText = element.select("td:last-child").text().trim() - val dateUpload = runCatching { - dateFormat.parse(dateText)?.time - }.getOrNull() ?: 0L - + val dateUpload = runCatching { dateFormat.parse(dateText)?.time }.getOrNull() ?: 0L val scanlator = element.selectFirst("td.no a, td.uk-table-shrink a")?.text()?.trim() ?.ifEmpty { null } - - ChapterParseData( - name, - url, - dateUpload, - scanlator - ) + ChapterParseData(name, url, dateUpload, scanlator) } return buildChapterList(rawChapters) } - @Suppress("ControlFlowWithEmptyBody") private fun buildChapterList(chapters: List): List { - val scanlatorCounts = mutableMapOf() - for (chapter in chapters) { - val scanlator = chapter.scanlator ?: extractTitleSuffix(chapter.name) ?: continue - scanlatorCounts[scanlator] = (scanlatorCounts[scanlator] ?: 0) + 1 - } - val preferredScanlator = scanlatorCounts.maxByOrNull { it.value }?.key + val result = mutableListOf() - val regularChapters = mutableMapOf() - val specialChapters = mutableListOf() + val seen = mutableSetOf>() + val regular = mutableListOf>() + val special = mutableListOf() for (chapter in chapters) { - val chapterNum = extractChapterNumber(chapter.name) - if (chapterNum != null) { - val existing = regularChapters[chapterNum] - if (existing == null) { - regularChapters[chapterNum] = chapter - } else { - val existingSource = existing.scanlator ?: extractTitleSuffix(existing.name) - val newSource = chapter.scanlator ?: extractTitleSuffix(chapter.name) - - val existingIsPreferred = existingSource == preferredScanlator - val newIsPreferred = newSource == preferredScanlator - - if (newIsPreferred && !existingIsPreferred) { - regularChapters[chapterNum] = chapter - } else if (!newIsPreferred && existingIsPreferred) { - } else { - if (chapter.name.length > existing.name.length) { - regularChapters[chapterNum] = chapter - } - } - } + val num = extractChapterNumber(chapter.name) + val scanlator = chapter.scanlator ?: extractTitleSuffix(chapter.name) ?: "Unknown" + if (num != null) { + val key = num to scanlator + if (seen.add(key)) regular.add(num to chapter) } else { - specialChapters.add(chapter) + special.add(chapter) } } - val result = mutableListOf() - - val sortedRegular = regularChapters.entries.sortedBy { it.key } - for ((chapterNum, chapter) in sortedRegular) { + regular.sortedBy { it.first }.forEach { (num, chapter) -> + val scanlator = chapter.scanlator ?: extractTitleSuffix(chapter.name) ?: "Unknown" result.add( MangaChapter( - id = generateUid(chapter.url), + id = stableChapterId(chapter.dateUpload, chapter.name, scanlator), url = chapter.url, title = chapter.name, - number = chapterNum, + number = num, volume = 0, uploadDate = chapter.dateUpload, - scanlator = chapter.scanlator, - branch = null, + scanlator = scanlator, + branch = scanlator, source = source, ), ) } - val baseSpecialNumber = (regularChapters.keys.maxOrNull() ?: 0f) + 10000f - for ((index, chapter) in specialChapters.withIndex()) { + val baseSpecial = (regular.maxOfOrNull { it.first } ?: 0f) + 10000f + special.forEachIndexed { index, chapter -> + val scanlator = chapter.scanlator ?: extractTitleSuffix(chapter.name) ?: "Unknown" result.add( MangaChapter( - id = generateUid(chapter.url), + id = stableChapterId(chapter.dateUpload, chapter.name, scanlator), url = chapter.url, title = chapter.name, - number = baseSpecialNumber + index, + number = baseSpecial + index, volume = 0, uploadDate = chapter.dateUpload, - scanlator = chapter.scanlator, - branch = null, + scanlator = scanlator, + branch = scanlator, source = source, ), ) @@ -368,6 +339,13 @@ internal class MangagoParser(context: MangaLoaderContext) : return result } + private fun stableChapterId(date: Long, name: String, scanlator: String?): Long = + MessageDigest.getInstance("MD5") + .digest("$date:$name:${scanlator.orEmpty()}".toByteArray()) + .joinToString("") { "%02x".format(it) } + .takeLast(15) + .toLong(16) + private fun extractChapterNumber(title: String): Float? { val num = title.extractChapterNumber() return if (num == 0f) null else num @@ -378,6 +356,60 @@ internal class MangagoParser(context: MangaLoaderContext) : return regex.find(title)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() } } + override suspend fun getRelatedManga(seed: Manga): List { + val doc = webClient.httpGet(seed.url.toAbsoluteUrl(domain)).parseHtml() + + val sameAuthor = doc + .select("div.also_like:has(h4:contains(Other manga by the same author)) + .pic_list .updatesli") + .mapNotNull { element -> + val link = element.selectFirst(".thm-effect") ?: return@mapNotNull null + val href = link.attrAsRelativeUrlOrNull("href") ?: return@mapNotNull null + Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + title = link.attr("title").ifEmpty { link.selectFirst("h2 a")?.text() ?: return@mapNotNull null }, + altTitles = emptySet(), + coverUrl = link.selectFirst("img")?.attr("abs:data-src")?.ifEmpty { + link.selectFirst("img")?.attr("abs:src") ?: "" + } ?: "", + largeCoverUrl = null, + description = null, + tags = emptySet(), + authors = emptySet(), + state = null, + rating = RATING_UNKNOWN, + contentRating = ContentRating.SAFE, + source = source, + ) + } + + val alsoLiked = doc.select(".also-like li").mapNotNull { element -> + val link = element.selectFirst("h4 a[href*=\"/read-manga/\"][title]") ?: return@mapNotNull null + val href = link.attrAsRelativeUrlOrNull("href") ?: return@mapNotNull null + Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + title = link.attr("title").ifEmpty { link.text() }, + altTitles = emptySet(), + coverUrl = element.selectFirst("img")?.attr("abs:data-src")?.ifEmpty { + element.selectFirst("img")?.attr("abs:src") ?: "" + } ?: "", + largeCoverUrl = null, + description = null, + tags = emptySet(), + authors = emptySet(), + state = null, + rating = RATING_UNKNOWN, + contentRating = ContentRating.SAFE, + source = source, + ) + } + + return (sameAuthor + alsoLiked).distinctBy { it.url } + } + private val jsCache = mutableMapOf>() private val maxCacheTime = 1000 * 60 * 5 // 5 minutes @@ -421,17 +453,12 @@ internal class MangagoParser(context: MangaLoaderContext) : val batchUrl = buildBatchUrl(batchStart) val batchDoc = if (batchStart == 1) doc else webClient.httpGet(batchUrl).parseHtml() val batchImages = decryptImageList(batchDoc) - - if (batchImages.isEmpty()) { - break - } + if (batchImages.isEmpty()) break allImages.addAll(batchImages) batchStart += batchSize - if (batchStart > pagesCount + batchSize) { - break - } + if (batchStart > pagesCount + batchSize) break } return allImages.take(pagesCount).mapIndexed { index, imageUrl -> @@ -441,7 +468,6 @@ internal class MangagoParser(context: MangaLoaderContext) : } else { imageUrl } - MangaPage( id = generateUid("$fullUrl-$index"), url = url, @@ -452,7 +478,6 @@ internal class MangagoParser(context: MangaLoaderContext) : } val imgsrcsScript = doc.selectFirst("script:containsData(imgsrcs)")?.html() - if (imgsrcsScript != null) { val images = decryptImageList(doc) val cols = getColsFromDoc(doc) ?: "" @@ -465,11 +490,7 @@ internal class MangagoParser(context: MangaLoaderContext) : } else { imageUrl } - - if (url.isBlank()) { - throw Exception("Final image URL at index $index is blank after processing") - } - + if (url.isBlank()) throw Exception("Final image URL at index $index is blank after processing") MangaPage( id = generateUid("$fullUrl-$index"), url = url, @@ -483,13 +504,54 @@ internal class MangagoParser(context: MangaLoaderContext) : } override suspend fun getPageUrl(page: MangaPage): String { - if (page.url.isBlank()) { - throw Exception("Page URL is blank in getPageUrl") - } + if (page.url.isBlank()) throw Exception("Page URL is blank in getPageUrl") return page.url } - override suspend fun getRelatedManga(seed: Manga): List = emptyList() + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + val fragment = response.request.url.fragment + if (fragment == null || !fragment.contains("desckey=")) return response + + return context.redrawImageResponse(response) { bitmap -> + val key = fragment.substringAfter("desckey=").substringBefore("&") + val cols = fragment.substringAfter("cols=").toIntOrNull() + ?: return@redrawImageResponse bitmap + unscrambleImage(bitmap, key, cols) + } + } + + private fun unscrambleImage(bitmap: Bitmap, key: String, cols: Int): Bitmap { + val width = bitmap.width + val height = bitmap.height + val result = context.createBitmap(width, height) + + val unitWidth = width / cols + val unitHeight = height / cols + val keyArray = key.split("a") + + for (idx in 0 until cols * cols) { + val keyval = keyArray.getOrNull(idx)?.takeIf { it.isNotEmpty() }?.toIntOrNull() ?: 0 + + val heightY = keyval / cols + val dy = heightY * unitHeight + val dx = (keyval - heightY * cols) * unitWidth + + val widthY = idx / cols + val sy = widthY * unitHeight + val sx = (idx - widthY * cols) * unitWidth + + val w = min(unitWidth, width - dx) + val h = min(unitHeight, height - dy) + + val srcRect = Rect(sx, sy, sx + w, sy + h) + val dstRect = Rect(dx, dy, dx + w, dy + h) + + result.drawBitmap(bitmap, srcRect, dstRect) + } + + return result + } private suspend fun decryptImageList(doc: Document): List { val imgsrcsScript = doc.selectFirst("script:containsData(imgsrcs)")?.html() @@ -499,15 +561,13 @@ internal class MangagoParser(context: MangaLoaderContext) : ?: throw Exception("Could not extract imgsrcs") val imgsrcs = context.decodeBase64(imgsrcRaw) - val deobfChapterJs = getDeobfuscatedJS(doc) ?: throw Exception("Could not deobfuscate JS") val key = findHexEncodedVariable(deobfChapterJs, "key").decodeHex() val iv = findHexEncodedVariable(deobfChapterJs, "iv").decodeHex() val cipher = Cipher.getInstance("AES/CBC/NoPadding") - val keySpec = SecretKeySpec(key, "AES") - cipher.init(Cipher.DECRYPT_MODE, keySpec, IvParameterSpec(iv)) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) val decryptedBytes = cipher.doFinal(imgsrcs) var imageList = String(decryptedBytes, Charsets.UTF_8).trimEnd('\u0000') @@ -527,16 +587,12 @@ internal class MangagoParser(context: MangaLoaderContext) : private suspend fun getDeobfuscatedJS(doc: Document): String? { val chapterJsUrl = doc.select("script[src*=chapter.js]").firstOrNull()?.absUrl("src") ?: return null - val now = System.currentTimeMillis() val cached = jsCache[chapterJsUrl] - if (cached != null && now - cached.second < maxCacheTime) { - return cached.first - } + if (cached != null && now - cached.second < maxCacheTime) return cached.first val obfuscatedChapterJs = webClient.httpGet(chapterJsUrl).parseRaw() val deobf = deobfuscateSoJsonV4(obfuscatedChapterJs) - jsCache[chapterJsUrl] = Pair(deobf, now) return deobf } @@ -548,63 +604,12 @@ internal class MangagoParser(context: MangaLoaderContext) : return COLS_REGEX.find(js)?.groupValues?.get(1) } - override fun intercept(chain: Interceptor.Chain): Response { - val response = chain.proceed(chain.request()) - val fragment = response.request.url.fragment - - if (fragment == null || !fragment.contains("desckey=")) { - return response - } - - return context.redrawImageResponse(response) { bitmap -> - val key = fragment.substringAfter("desckey=").substringBefore("&") - val cols = fragment.substringAfter("cols=").toIntOrNull() ?: return@redrawImageResponse bitmap - unscrambleImage(bitmap, key, cols) - } - } - - private fun unscrambleImage(bitmap: Bitmap, key: String, cols: Int): Bitmap { - val width = bitmap.width - val height = bitmap.height - - val result = context.createBitmap(width, height) - - val unitWidth = width / cols - val unitHeight = height / cols - - val keyArray = key.split("a") - - for (idx in 0 until cols * cols) { - val keyval = keyArray.getOrNull(idx)?.takeIf { it.isNotEmpty() }?.toIntOrNull() ?: 0 - - val heightY = keyval / cols - val dy = heightY * unitHeight - val dx = (keyval - heightY * cols) * unitWidth - - val widthY = idx / cols - val sy = widthY * unitHeight - val sx = (idx - widthY * cols) * unitWidth - - val w = min(unitWidth, width - dx) - val h = min(unitHeight, height - dy) - - val srcRect = Rect(sx, sy, sx + w, sy + h) - val dstRect = Rect(dx, dy, dx + w, dy + h) - - result.drawBitmap(bitmap, srcRect, dstRect) - } - - return result - } - private fun deobfuscateSoJsonV4(jsf: String): String { if (!jsf.startsWith("['sojson.v4']")) { throw Exception("Obfuscated code header mismatch. Expected sojson.v4") } - val splitRegex = Regex("[a-zA-Z]+") val args = jsf.substring(240, jsf.length - 59).split(splitRegex) - return args.map { it.toInt().toChar() }.joinToString("") } @@ -621,22 +626,16 @@ internal class MangagoParser(context: MangaLoaderContext) : private fun unscrambleImageList(imageList: String, js: String): String { var imgList = imageList - val keyLocations = KEY_LOCATION_REGEX.findAll(js) .map { it.groupValues[1].toInt() } .distinct() .sorted() .toList() - - if (keyLocations.isEmpty()) { - return imgList - } + if (keyLocations.isEmpty()) return imgList val unscrambleKey = try { keyLocations.map { loc -> - if (loc >= imgList.length) { - throw NumberFormatException("Position $loc is beyond string length") - } + if (loc >= imgList.length) throw NumberFormatException("Position $loc is beyond string length") imgList[loc].toString().toInt() } } catch (_: NumberFormatException) { @@ -646,9 +645,7 @@ internal class MangagoParser(context: MangaLoaderContext) : keyLocations.forEachIndexed { idx, loc -> imgList = imgList.removeRange((loc - idx)..(loc - idx)) } - imgList = imgList.unscramble(unscrambleKey) - return imgList } @@ -678,9 +675,7 @@ internal class MangagoParser(context: MangaLoaderContext) : .joinToString("\n") .replace("img.src", "url") - if (imgkeys.isEmpty()) { - throw Exception("Failed to extract image key extraction code from chapter.js") - } + if (imgkeys.isEmpty()) throw Exception("Failed to extract image key extraction code from chapter.js") val js = """ function replacePos(strObj, pos, replacetext) { @@ -692,11 +687,7 @@ internal class MangagoParser(context: MangaLoaderContext) : """.trimIndent() val result = context.evaluateJs("https://$domain/", js) - - if (result.isNullOrEmpty()) { - throw Exception("Failed to evaluate JavaScript to get descrambling key. The image format or site structure may have changed.") - } - + if (result.isNullOrEmpty()) throw Exception("Failed to evaluate JavaScript to get descrambling key.") return result } @@ -704,6 +695,13 @@ internal class MangagoParser(context: MangaLoaderContext) : private val IMG_SRCS_REGEX = Regex("""var imgsrcs\s*=\s*['"]([a-zA-Z0-9+=/]+)['"]""") private val COLS_REGEX = Regex("""var\s*widthnum\s*=\s*heightnum\s*=\s*(\d+);""") private val KEY_LOCATION_REGEX = Regex("""str\.charAt\(\s*(\d+)\s*\)""") - private val JS_FILTERS = listOf("jQuery", "document", "getContext", "toDataURL", "getImageData", "width", "height") + private val JS_FILTERS = listOf( + "jQuery", "document", "getContext", "toDataURL", "getImageData", "width", "height", + ) + + private val TITLE_REGEX = Regex( + """^(?:\s*(?:\([^()]*\)|\{[^{}]*\}|\[(?:(?!]).)*]|«[^»]*»|〘[^〙]*〙|「[^」]*」|『[^』]*』|≪[^≫]*≫|﹛[^﹜]*﹜|〖[^〖〗]*〗|𖤍.+?𖤍|《[^》]*》|⌜.+?⌝|⟨[^⟩]*⟩)\s*)+|(?:\s*(?:\([^()]*\)|\{[^{}]*\}|\[(?:(?!]).)*]|«[^»]*»|〘[^〙]*〙|「[^」]*」|『[^』]*』|≪[^≫]*≫|﹛[^﹜]*﹜|〖[^〖〗]*〗|𖤍.+?𖤍|《[^》]*》|⌜.+?⌝|⟨[^⟩]*⟩|/\s*Official)\s*)+$""", + RegexOption.IGNORE_CASE, + ) } } From a13da8b76c72f281c3155c9cee065abe95a6a840 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:52:50 +0200 Subject: [PATCH 06/13] fixxed missing chapters --- src/main/kotlin/tsuki/site/en/Chikari.kt | 51 +++++++++++++++++++----- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/tsuki/site/en/Chikari.kt b/src/main/kotlin/tsuki/site/en/Chikari.kt index a8f0e9f8..56a9bb48 100644 --- a/src/main/kotlin/tsuki/site/en/Chikari.kt +++ b/src/main/kotlin/tsuki/site/en/Chikari.kt @@ -25,6 +25,7 @@ import tsuki.util.parseJson import tsuki.util.oneOrThrowIfMany import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import okhttp3.HttpUrl.Companion.toHttpUrl import org.json.JSONArray @@ -258,16 +259,44 @@ class Chikari(context: MangaLoaderContext) : private suspend fun fetchChapters(slug: String, detailsJson: JSONObject? = null): List { try { - val url = "https://$domain/api/series/$slug/chapters".toHttpUrl().newBuilder() - .addQueryParameter("order", "desc") - .addQueryParameter("limit", "9999") - .addQueryParameter("offset", "0") - .build() - .toString() - - val response = webClient.httpGet(url).parseJson() - response.optJSONArray("items")?.let { items -> - return parseChapterArray(items, slug) + val limit = 200 // server-enforced max page size + + fun buildUrl(offset: Int) = + "https://$domain/api/series/$slug/chapters".toHttpUrl().newBuilder() + .addQueryParameter("order", "desc") + .addQueryParameter("limit", limit.toString()) + .addQueryParameter("offset", offset.toString()) + .build() + .toString() + + val firstResponse = webClient.httpGet(buildUrl(0)).parseJson() + val total = firstResponse.optInt("total", 0) + val allChapters = mutableListOf() + + firstResponse.optJSONArray("items")?.let { items -> + allChapters.addAll(parseChapterArray(items, slug)) + } + + val remainingOffsets = (allChapters.size until total step limit).toList() + + if (remainingOffsets.isNotEmpty()) { + val pages = coroutineScope { + remainingOffsets.map { offset -> + async { + runCatching { + val response = webClient.httpGet(buildUrl(offset)).parseJson() + response.optJSONArray("items") + ?.let { parseChapterArray(it, slug) } + ?: emptyList() + }.getOrDefault(emptyList()) + } + }.awaitAll() + } + pages.forEach { allChapters.addAll(it) } + } + + if (allChapters.isNotEmpty()) { + return allChapters.distinctBy { it.number }.sortedBy { it.number } } } catch (_: Exception) { } @@ -300,7 +329,7 @@ class Chikari(context: MangaLoaderContext) : branch = null, source = source, ) - }.sortedBy { it.number } + } } private fun parseDate(dateStr: String): Long = From 4274ad3700cfbadc8bdafaef2ae78bcdecb75fa9 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:20:42 +0200 Subject: [PATCH 07/13] Create WebtoonScan.kt --- src/main/kotlin/tsuki/site/en/nsfw/WebtoonScan.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/kotlin/tsuki/site/en/nsfw/WebtoonScan.kt diff --git a/src/main/kotlin/tsuki/site/en/nsfw/WebtoonScan.kt b/src/main/kotlin/tsuki/site/en/nsfw/WebtoonScan.kt new file mode 100644 index 00000000..a027c226 --- /dev/null +++ b/src/main/kotlin/tsuki/site/en/nsfw/WebtoonScan.kt @@ -0,0 +1,12 @@ +package tsuki.site.en + +import tsuki.MangaLoaderContext +import tsuki.MangaSourceParser +import tsuki.parsers.MadaraParser + +import tsuki.model.ContentType +import tsuki.model.MangaParserSource + +@MangaSourceParser("WEBTOONSCAN", "WebtoonScan", "en", ContentType.HENTAI) +internal class WebtoonScan(context: MangaLoaderContext) : + MadaraParser(context, MangaParserSource.WEBTOONSCAN, "webtoonscan.com") From bbf04834685f28872d57215f94eafe69afb1e054 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:29:11 +0200 Subject: [PATCH 08/13] readd softkomi --- src/main/kotlin/tsuki/site/id/SoftKomik.kt | 493 +++++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 src/main/kotlin/tsuki/site/id/SoftKomik.kt diff --git a/src/main/kotlin/tsuki/site/id/SoftKomik.kt b/src/main/kotlin/tsuki/site/id/SoftKomik.kt new file mode 100644 index 00000000..5c0713b4 --- /dev/null +++ b/src/main/kotlin/tsuki/site/id/SoftKomik.kt @@ -0,0 +1,493 @@ +package tsuki.site.id + +import tsuki.MangaLoaderContext +import tsuki.MangaParserAuthProvider +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.getCookies +import tsuki.util.parseHtml +import tsuki.util.parseJson +import tsuki.util.toAbsoluteUrl +import tsuki.util.urlEncoded + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import org.json.JSONArray +import org.json.JSONObject +import java.net.URLDecoder +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +@MangaSourceParser("SOFTKOMIK", "Softkomik", "id") +internal class Softkomik(context: MangaLoaderContext) : + PagedMangaParser(context, MangaParserSource.SOFTKOMIK, pageSize = 24), MangaParserAuthProvider { + + override val authUrl: String get() = "https://$domain/akun/login" + override suspend fun isAuthorized(): Boolean { + val cookies = context.cookieJar.getCookies(domain) + return cookies.any { it.name == "tokkey" } + } + override suspend fun getUsername(): String = "" + + override val configKeyDomain = ConfigKey.Domain("softkomik.co") + + private val apiUrl = "https://v2.softdevices.my.id" + private val coverUrl = "https://cover.softdevices.my.id/softkomik-cover" + + private val cdnUrls = listOf( + "https://psy1.komik.im", + "https://image.komik.im/softkomik", + "https://cdn1.softkomik.online/softkomik", + "https://cd1.softkomik.online/softkomik", + "https://f1.softkomik.com/file/softkomik-image", + "https://img.softdevices.my.id/softkomik-image", + "https://image.softkomik.com/softkomik", + ) + + private val loginRequiredGenres = setOf("ecchi", "mature") + private val requiredLoginSuffix = "login-required" + private val requiredLoginFragment = "#$requiredLoginSuffix" + + override val availableSortOrders = setOf(SortOrder.NEWEST, SortOrder.POPULARITY) + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isMultipleTagsSupported = false, + ) + + private val customClient: OkHttpClient by lazy { + context.httpClient.newBuilder() + .addInterceptor(::apiAuthInterceptor) + .addInterceptor(::imageInterceptor) + .build() + } + + override val webClient by lazy { + tsuki.network.OkHttpWebClient(customClient, source) + } + + private fun pageHeaders() = getRequestHeaders().newBuilder() + .add("rsc", "1") + .build() + + private fun apiHeaders(session: SessionDto): Headers { + val builder = Headers.Builder() + .add("Accept", "application/json") + .add("Referer", "https://$domain/") + .add("Origin", "https://$domain") + if (session.sign.isNotEmpty()) { + builder.add("X-Token", session.token.cleanB64()) + builder.add("X-Sign", session.sign.take(64)) + } else { + builder.add("Authorization", "Bearer ${session.token}") + } + return builder.build() + } + + private data class SessionDto(val ex: Long, val token: String, val sign: String) + private val sessionsByKey = ConcurrentHashMap() + private val sessionKeyChapterList = "chapter-list" + private val sessionKeyChapterImage = "chapter-image" + + private suspend fun getSessionAsync(route: SessionRoute): SessionDto { + sessionsByKey[route.key]?.takeIf { it.ex > System.currentTimeMillis() + 30_000L }?.let { return it } + + try { + val apiHeaders = Headers.Builder() + .add("Accept", "application/json") + .add("Referer", "https://$domain/") + .add("Origin", "https://$domain") + .add("X-Requested-With", "XMLHttpRequest") + .build() + val res = webClient.httpGet(route.sessionApiUrl, apiHeaders).parseJson() + val rawSign = res.optString("sign", "") + val session = SessionDto( + ex = res.optLong("ex", System.currentTimeMillis() + 60_000L), + token = res.optString("token", ""), + sign = rawSign.substringBefore('|'), + ) + sessionsByKey[route.key] = session + return session + } catch (_: Exception) { + val token = getBearerTokenFromCookie() + if (token != null) { + val session = SessionDto( + ex = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2), + token = token, + sign = "", + ) + sessionsByKey[route.key] = session + return session + } + throw Exception("Gagal mendapatkan session. Coba lagi.") + } + } + + private fun getSessionBlocking(route: SessionRoute): SessionDto = runBlocking { + getSessionAsync(route) + } + + private fun getBearerTokenFromCookie(): String? { + val cookies = context.cookieJar.getCookies(domain) + val tokkey = cookies.firstOrNull { it.name == "tokkey" }?.value ?: return null + val decoded = runCatching { URLDecoder.decode(tokkey, Charsets.UTF_8.name()) }.getOrDefault(tokkey) + return decoded.cleanB64() + } + + private fun String.cleanB64(): String = + substringBefore('=').let { it + "=".repeat((4 - (it.length % 4)) % 4) } + + private fun apiAuthInterceptor(chain: Interceptor.Chain): Response { + val request = chain.request() + if (!request.url.toString().startsWith(apiUrl)) { + return chain.proceed(request) + } + val route = resolveSessionRoute(request.url) + val session = getSessionBlocking(route) + val newRequest = request.newBuilder() + .apply { + if (session.sign.isNotEmpty()) { + header("X-Token", session.token.cleanB64()) + header("X-Sign", session.sign.take(64)) + } else { + header("Authorization", "Bearer ${session.token}") + } + } + .build() + return chain.proceed(newRequest) + } + + private fun imageInterceptor(chain: Interceptor.Chain): Response { + val request = chain.request() + val userAgent = request.header("User-Agent") + val normalizedUserAgent = userAgent?.replace( + Regex("""\s*Mobile Safari/\d+(?:\.\d+)*""", RegexOption.IGNORE_CASE), + "", + )?.trim()?.ifEmpty { null } + + val modRequest = if (normalizedUserAgent != userAgent) { + request.newBuilder().header("User-Agent", normalizedUserAgent ?: "").build() + } else request + + val response: Response? = try { + chain.proceed(modRequest) + } catch (_: java.net.UnknownHostException) { + null + } + + if (response?.isSuccessful == true) return response + + val currentHost = cdnUrls.firstOrNull { request.url.toString().startsWith(it) } + if (currentHost == null) return response ?: throw java.net.UnknownHostException(request.url.host) + + response?.close() + val imagePath = request.url.toString().removePrefix(currentHost).removePrefix("/") + for (newHost in cdnUrls) { + if (newHost == currentHost) continue + val newUrl = "$newHost/$imagePath".toHttpUrl() + try { + val newResp = chain.proceed(request.newBuilder().url(newUrl).build()) + if (newResp.isSuccessful) return newResp + newResp.close() + } catch (_: Exception) {} + } + throw java.net.UnknownHostException("All CDN hosts failed for $imagePath") + } + + private data class SessionRoute( + val key: String, + val sessionApiUrl: String, + val slug: String?, + val isChapterListRequest: Boolean, + val isChapterImageRequest: Boolean, + ) + + private fun resolveSessionRoute(url: okhttp3.HttpUrl): SessionRoute { + val segments = url.pathSegments + val komikIndex = segments.indexOf("komik") + val slug = if (komikIndex != -1) segments.getOrNull(komikIndex + 1) else null + val isChapterListRequest = komikIndex != -1 && segments.getOrNull(komikIndex + 2) == "chapter" + val isChapterImageRequest = isChapterListRequest && (segments.contains("imgs") || segments.contains("img")) + + val key = if (isChapterImageRequest) sessionKeyChapterImage else sessionKeyChapterList + val sessionApiUrl = if (isChapterImageRequest) { + "https://$domain/api/session/chapter/oioa" + } else { + "https://$domain/api/session/iuiuiwqw" + } + return SessionRoute( + key = key, + sessionApiUrl = sessionApiUrl, + slug = slug, + isChapterListRequest = isChapterListRequest, + isChapterImageRequest = isChapterImageRequest, + ) + } + + @Volatile + private var genresCache: Set? = null + private val genresMutex = Mutex() + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = getOrFetchGenres(), + ) + + private suspend fun getOrFetchGenres(): Set { + genresCache?.let { return it } + return genresMutex.withLock { + genresCache ?: fetchGenres().also { genresCache = it } + } + } + + private suspend fun fetchGenres(): Set { + val doc = webClient.httpGet("https://$domain/komik/library", pageHeaders()).parseHtml() + val pageProps = extractNextJs(doc) ?: return emptySet() + val arr = pageProps.optJSONArray("genre") ?: return emptySet() + return LinkedHashSet(arr.length()).apply { + for (i in 0 until arr.length()) { + val jo = arr.optJSONObject(i) ?: continue + val value = jo.optString("value", "").ifEmpty { jo.optString("label", "") } + val label = jo.optString("label", "").ifEmpty { value } + if (value.isNotEmpty() && label.isNotEmpty()) { + add(MangaTag(title = label, key = value, source = source)) + } + } + } + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query?.trim().orEmpty() + if (query.isNotEmpty()) { + return searchByQuery(query, page) + } + val url = buildString { + append("https://$domain/komik/library") + append("?page=").append(page) + append("&sortBy=").append(if (order == SortOrder.POPULARITY) "popular" else "newKomik") + filter.tags.firstOrNull()?.key?.let { + append("&genre=").append(it.urlEncoded()) + } + } + val doc = webClient.httpGet(url, pageHeaders()).parseHtml() + val pageProps = extractNextJs(doc) ?: return emptyList() + val libData = pageProps.optJSONObject("libData") ?: pageProps + val dataArray = libData.optJSONArray("data") ?: return emptyList() + return parseListData(dataArray) + } + + private suspend fun searchByQuery(query: String, page: Int): List { + val urlPath = "$apiUrl/komik?name=${query.urlEncoded()}&search=true&limit=24&page=$page" + val session = getSessionAsync(resolveSessionRoute(urlPath.toHttpUrl())) + val headers = apiHeaders(session) + val json = webClient.httpGet(urlPath, headers).parseJson() + val dataArray = json.optJSONArray("data") ?: return emptyList() + return parseListData(dataArray) + } + + private fun parseListData(dataArray: JSONArray): List { + val result = ArrayList(dataArray.length()) + for (i in 0 until dataArray.length()) { + val jo = dataArray.optJSONObject(i) ?: continue + val slug = jo.optString("title_slug", "").ifEmpty { jo.optString("id", "") } + if (slug.isEmpty()) continue + val gambar = jo.optString("gambar", "").removePrefix("/") + result.add( + Manga( + id = generateUid(slug), + title = jo.optString("title", "Untitled"), + altTitles = emptySet(), + url = "/$slug", + publicUrl = "https://$domain/$slug", + rating = RATING_UNKNOWN, + contentRating = ContentRating.SAFE, + coverUrl = if (gambar.isNotEmpty()) "$coverUrl/$gambar" else "", + tags = emptySet(), + state = parseStatus(jo.optString("status")), + authors = emptySet(), + source = source, + ), + ) + } + return result + } + + override suspend fun getDetails(manga: Manga): Manga { + val doc = webClient.httpGet(manga.publicUrl, pageHeaders()).parseHtml() + val pageProps = extractNextJs(doc) + val detail = pageProps?.optJSONObject("data") + + val title = detail?.optString("title")?.takeIf { it.isNotBlank() } ?: manga.title + val gambar = detail?.optString("gambar", "")?.removePrefix("/") ?: "" + val coverImg = if (gambar.isNotEmpty()) "$coverUrl/$gambar" else manga.coverUrl + val description = detail?.optString("sinopsis")?.takeIf { it.isNotBlank() } + val author = detail?.optString("author")?.takeIf { it.isNotBlank() } + val state = parseStatus(detail?.optString("status")) + + val tags = LinkedHashSet() + detail?.optJSONArray("Genre")?.let { arr -> + for (i in 0 until arr.length()) { + val name = arr.optString(i, "").trim() + if (name.isNotEmpty()) tags.add(MangaTag(name, name.lowercase(), source)) + } + } + + val isLoginRequired = tags.any { it.title.lowercase() in loginRequiredGenres } + val slug = manga.url.trim('/').substringBefore('/') + val chapters = fetchChapterList(slug, isLoginRequired) + + return manga.copy( + title = title, + coverUrl = coverImg, + description = description, + tags = tags, + authors = setOfNotNull(author), + state = state, + chapters = chapters, + ) + } + + private suspend fun fetchChapterList(slug: String, isLoginRequired: Boolean): List { + val apiPath = "$apiUrl/komik/$slug/chapter?limit=9999999" + val headers = if (isLoginRequired) { + val token = getBearerTokenFromCookie() ?: throw Exception("Login diperlukan untuk mengakses chapter") + Headers.Builder() + .add("Authorization", "Bearer $token") + .add("Referer", "https://$domain/$slug") + .add("Origin", "https://$domain") + .build() + } else { + val session = getSessionAsync(resolveSessionRoute(apiPath.toHttpUrl())) + apiHeaders(session) + } + + val json = webClient.httpGet(apiPath, headers).parseJson() + val chapterArray = json.optJSONArray("chapter") ?: return emptyList() + val chapters = ArrayList(chapterArray.length()) + for (i in 0 until chapterArray.length()) { + val ch = chapterArray.optJSONObject(i) ?: continue + val chStr = ch.optString("chapter", "") + if (chStr.isEmpty()) continue + val number = chStr.substringBefore(".").toFloatOrNull() ?: continue + var chapterUrl = "/$slug/chapter/$chStr" + if (isLoginRequired) chapterUrl += requiredLoginFragment + chapters.add( + MangaChapter( + id = generateUid(chapterUrl), + title = "Chapter ${formatChapterDisplay(chStr)}", + url = chapterUrl, + number = number, + volume = 0, + scanlator = null, + uploadDate = 0L, + branch = null, + source = source, + ), + ) + } + chapters.sortBy { it.number } + return chapters + } + + override suspend fun getPages(chapter: MangaChapter): List { + val isLoginRequired = chapter.url.contains(requiredLoginFragment) + val cleanUrl = chapter.url.substringBefore('#') + + val doc = webClient.httpGet(cleanUrl.toAbsoluteUrl(domain), pageHeaders()).parseHtml() + val pageProps = extractNextJs(doc) + val data = pageProps?.optJSONObject("data") + val komik = data?.optJSONObject("komik") + val chapterData = data?.optJSONObject("data") + + val slug = cleanUrl.trim('/').substringBefore("/chapter/") + val chNum = cleanUrl.substringAfterLast("/chapter/").trim('/') + + var imageSrc = chapterData?.optJSONArray("imageSrc") ?: JSONArray() + val storageInter2 = chapterData?.optBoolean("storageInter2", false) ?: false + + if (imageSrc.length() == 0) { + val id = chapterData?.optString("_id") ?: komik?.optString("_id") ?: return emptyList() + imageSrc = fetchChapterImages(slug, chNum, id, isLoginRequired) + } + + if (imageSrc.length() == 0) return emptyList() + + val imageBaseUrl = if (storageInter2) cdnUrls[2] else cdnUrls[0] + return (0 until imageSrc.length()).mapNotNull { i -> + val path = imageSrc.optString(i, "").removePrefix("/") + if (path.isEmpty()) return@mapNotNull null + MangaPage( + id = generateUid(path), + url = "$imageBaseUrl/$path", + preview = null, + source = source, + ) + } + } + + private suspend fun fetchChapterImages( + slug: String, + chapter: String, + id: String, + isLoginRequired: Boolean, + ): JSONArray { + val url = "$apiUrl/komik/$slug/chapter/$chapter/imgs/$id" + val headers = if (isLoginRequired) { + val token = getBearerTokenFromCookie() ?: throw Exception("Login diperlukan") + Headers.Builder() + .add("Authorization", "Bearer $token") + .add("Referer", "https://$domain/$slug/chapter/$chapter") + .add("Origin", "https://$domain") + .build() + } else { + val session = getSessionAsync(resolveSessionRoute(url.toHttpUrl())) + apiHeaders(session) + } + val json = webClient.httpGet(url, headers).parseJson() + return json.optJSONArray("imageSrc") ?: JSONArray() + } + + private fun extractNextJs(doc: org.jsoup.nodes.Document): JSONObject? { + val raw = doc.selectFirst("script#__NEXT_DATA__")?.data() ?: return null + return runCatching { + JSONObject(raw).optJSONObject("props")?.optJSONObject("pageProps") + }.getOrNull() + } + + private fun parseStatus(text: String?): MangaState? = when { + text == null -> null + text.contains("ongoing", ignoreCase = true) -> MangaState.ONGOING + text.contains("tamat", ignoreCase = true) || text.contains("completed", ignoreCase = true) -> MangaState.FINISHED + else -> null + } + + private fun formatChapterDisplay(chStr: String): String { + val parts = chStr.split(".") + val numPart = parts[0].toFloatOrNull() ?: return chStr + val formatted = if (numPart == numPart.toLong().toFloat()) numPart.toLong().toString() + else numPart.toString().trimEnd('0').trimEnd('.') + val suffix = parts.drop(1).joinToString(".") + return if (suffix.isNotEmpty()) "$formatted.$suffix" else formatted + } +} From 937084e25bc916f1c500b8c6ee335d98878124f5 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:31:41 +0200 Subject: [PATCH 09/13] fix softkomik --- src/main/kotlin/tsuki/site/id/SoftKomik.kt | 122 ++++++++++++++++----- 1 file changed, 93 insertions(+), 29 deletions(-) diff --git a/src/main/kotlin/tsuki/site/id/SoftKomik.kt b/src/main/kotlin/tsuki/site/id/SoftKomik.kt index 5c0713b4..6bf6c0f0 100644 --- a/src/main/kotlin/tsuki/site/id/SoftKomik.kt +++ b/src/main/kotlin/tsuki/site/id/SoftKomik.kt @@ -45,17 +45,23 @@ internal class Softkomik(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.SOFTKOMIK, pageSize = 24), MangaParserAuthProvider { override val authUrl: String get() = "https://$domain/akun/login" + override suspend fun isAuthorized(): Boolean { val cookies = context.cookieJar.getCookies(domain) return cookies.any { it.name == "tokkey" } } + override suspend fun getUsername(): String = "" override val configKeyDomain = ConfigKey.Domain("softkomik.co") - private val apiUrl = "https://v2.softdevices.my.id" + private val apiUrl = "https://api.softkomik.org" private val coverUrl = "https://cover.softdevices.my.id/softkomik-cover" + // Inlined in the site's chapter chunk as `String("...").trim()` and appended to every image URL. + private val imageIdParam = "T4Kmwztku" + private val trapImagePath = "/baca-image.jpeg" + private val cdnUrls = listOf( "https://psy1.komik.im", "https://image.komik.im/softkomik", @@ -69,8 +75,11 @@ internal class Softkomik(context: MangaLoaderContext) : private val loginRequiredGenres = setOf("ecchi", "mature") private val requiredLoginSuffix = "login-required" private val requiredLoginFragment = "#$requiredLoginSuffix" + private val sessionKeyChapterList = "chapter-list" + private val sessionKeyChapterImage = "chapter-image" override val availableSortOrders = setOf(SortOrder.NEWEST, SortOrder.POPULARITY) + override val filterCapabilities = MangaListFilterCapabilities( isSearchSupported = true, isSearchWithFiltersSupported = true, @@ -103,30 +112,58 @@ internal class Softkomik(context: MangaLoaderContext) : } else { builder.add("Authorization", "Bearer ${session.token}") } + session.contentAccess?.let { + builder.add("X-Content-Token", it.token) + builder.add("X-Content-Sign", it.sign) + } return builder.build() } - private data class SessionDto(val ex: Long, val token: String, val sign: String) + private data class SessionDto( + val ex: Long, + val token: String, + val sign: String, + val contentAccess: ContentAccessDto? = null, + ) + + private data class ContentAccessDto( + val token: String, + val sign: String, + ) + private val sessionsByKey = ConcurrentHashMap() - private val sessionKeyChapterList = "chapter-list" - private val sessionKeyChapterImage = "chapter-image" private suspend fun getSessionAsync(route: SessionRoute): SessionDto { sessionsByKey[route.key]?.takeIf { it.ex > System.currentTimeMillis() + 30_000L }?.let { return it } try { - val apiHeaders = Headers.Builder() + val sessionHeaders = Headers.Builder() .add("Accept", "application/json") .add("Referer", "https://$domain/") .add("Origin", "https://$domain") .add("X-Requested-With", "XMLHttpRequest") .build() - val res = webClient.httpGet(route.sessionApiUrl, apiHeaders).parseJson() - val rawSign = res.optString("sign", "") + + // Initialize cookies if missing (some endpoints require zEm9be / AhyyL cookies) + val hasCookies = context.cookieJar.getCookies(domain) + .any { it.name == "zEm9be" || it.name == "AhyyL" } + if (!hasCookies) { + runCatching { webClient.httpGet("https://$domain/").close() } + runCatching { webClient.httpGet("https://$domain/api/me", sessionHeaders).close() } + } + + val res = webClient.httpGet(route.sessionApiUrl, sessionHeaders).parseJson() + val contentAccess = res.optJSONObject("contentAccess")?.let { + ContentAccessDto( + token = it.optString("token", ""), + sign = it.optString("sign", ""), + ) + } val session = SessionDto( ex = res.optLong("ex", System.currentTimeMillis() + 60_000L), token = res.optString("token", ""), - sign = rawSign.substringBefore('|'), + sign = res.optString("sign", ""), + contentAccess = contentAccess, ) sessionsByKey[route.key] = session return session @@ -174,6 +211,10 @@ internal class Softkomik(context: MangaLoaderContext) : } else { header("Authorization", "Bearer ${session.token}") } + session.contentAccess?.let { + header("X-Content-Token", it.token) + header("X-Content-Sign", it.sign) + } } .build() return chain.proceed(newRequest) @@ -197,25 +238,38 @@ internal class Softkomik(context: MangaLoaderContext) : null } - if (response?.isSuccessful == true) return response + // Trap image detection: site returns a placeholder when the chapter is blocked + if (response?.isSuccessful == true && !response.isTrapImage()) return response val currentHost = cdnUrls.firstOrNull { request.url.toString().startsWith(it) } if (currentHost == null) return response ?: throw java.net.UnknownHostException(request.url.host) response?.close() val imagePath = request.url.toString().removePrefix(currentHost).removePrefix("/") + var latestResponse: Response? = null for (newHost in cdnUrls) { if (newHost == currentHost) continue + latestResponse?.close() val newUrl = "$newHost/$imagePath".toHttpUrl() - try { - val newResp = chain.proceed(request.newBuilder().url(newUrl).build()) - if (newResp.isSuccessful) return newResp - newResp.close() - } catch (_: Exception) {} + latestResponse = try { + chain.proceed(request.newBuilder().url(newUrl).build()) + } catch (_: Exception) { + null + } + if (latestResponse?.isSuccessful == true && !latestResponse.isTrapImage()) return latestResponse + } + + if (latestResponse?.isTrapImage() == true) { + latestResponse.close() + throw Exception("Gambar diblokir oleh situs, extension perlu diperbarui") } - throw java.net.UnknownHostException("All CDN hosts failed for $imagePath") + + return latestResponse ?: throw java.net.UnknownHostException("All CDN hosts failed for $imagePath") } + private fun Response.isTrapImage(): Boolean = + request.url.encodedPath.endsWith(trapImagePath) + private data class SessionRoute( val key: String, val sessionApiUrl: String, @@ -233,9 +287,9 @@ internal class Softkomik(context: MangaLoaderContext) : val key = if (isChapterImageRequest) sessionKeyChapterImage else sessionKeyChapterList val sessionApiUrl = if (isChapterImageRequest) { - "https://$domain/api/session/chapter/oioa" + "https://$domain/api/session/chapter/oaisos" } else { - "https://$domain/api/session/iuiuiwqw" + "https://$domain/api/session/aksjkas" } return SessionRoute( key = key, @@ -413,33 +467,43 @@ internal class Softkomik(context: MangaLoaderContext) : override suspend fun getPages(chapter: MangaChapter): List { val isLoginRequired = chapter.url.contains(requiredLoginFragment) val cleanUrl = chapter.url.substringBefore('#') + val segments = cleanUrl.trim('/').split("/") + val slug = segments.getOrNull(0) ?: return emptyList() + val chapterNum = segments.getOrNull(2) ?: return emptyList() val doc = webClient.httpGet(cleanUrl.toAbsoluteUrl(domain), pageHeaders()).parseHtml() - val pageProps = extractNextJs(doc) - val data = pageProps?.optJSONObject("data") - val komik = data?.optJSONObject("komik") - val chapterData = data?.optJSONObject("data") + val pageProps = extractNextJs(doc) ?: return emptyList() - val slug = cleanUrl.trim('/').substringBefore("/chapter/") - val chNum = cleanUrl.substringAfterLast("/chapter/").trim('/') + // Fields are at the top level of pageProps (not nested under "data") + val chapterData = pageProps.optJSONObject("data")?.optJSONObject("data") + ?: pageProps.optJSONObject("data") + ?: pageProps - var imageSrc = chapterData?.optJSONArray("imageSrc") ?: JSONArray() - val storageInter2 = chapterData?.optBoolean("storageInter2", false) ?: false + var imageSrc = chapterData.optJSONArray("imageSrc") ?: JSONArray() + val storageInter2 = chapterData.optBoolean("storageInter2", false) + val backBS3 = chapterData.optBoolean("backBS3", false) if (imageSrc.length() == 0) { - val id = chapterData?.optString("_id") ?: komik?.optString("_id") ?: return emptyList() - imageSrc = fetchChapterImages(slug, chNum, id, isLoginRequired) + val id = chapterData.optString("_id") + .ifEmpty { chapterData.optJSONObject("komik")?.optString("_id") ?: "" } + if (id.isEmpty()) return emptyList() + imageSrc = fetchChapterImages(slug, chapterNum, id, isLoginRequired) } if (imageSrc.length() == 0) return emptyList() - val imageBaseUrl = if (storageInter2) cdnUrls[2] else cdnUrls[0] + val imageBaseUrl = when { + backBS3 -> cdnUrls[0] + storageInter2 -> cdnUrls[1] + else -> cdnUrls[0] + } + return (0 until imageSrc.length()).mapNotNull { i -> val path = imageSrc.optString(i, "").removePrefix("/") if (path.isEmpty()) return@mapNotNull null MangaPage( id = generateUid(path), - url = "$imageBaseUrl/$path", + url = "$imageBaseUrl/$path?id=$imageIdParam", preview = null, source = source, ) From c901a9664c0f823f8ffbd68b27fbdacc7cd827c4 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:18:05 +0200 Subject: [PATCH 10/13] Create KeyoApp.kt --- src/main/kotlin/tsuki/parsers/KeyoApp.kt | 465 +++++++++++++++++++++++ 1 file changed, 465 insertions(+) create mode 100644 src/main/kotlin/tsuki/parsers/KeyoApp.kt diff --git a/src/main/kotlin/tsuki/parsers/KeyoApp.kt b/src/main/kotlin/tsuki/parsers/KeyoApp.kt new file mode 100644 index 00000000..d9f25a1d --- /dev/null +++ b/src/main/kotlin/tsuki/parsers/KeyoApp.kt @@ -0,0 +1,465 @@ +package tsuki.parsers + +import tsuki.MangaLoaderContext +import tsuki.config.ConfigKey +import tsuki.core.PagedMangaParser +import tsuki.network.OkHttpWebClient + +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 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.util.Calendar +import java.util.EnumSet +import java.util.Locale + +abstract class KeyoApp( + context: MangaLoaderContext, + source: MangaParserSource, + domain: String, + pageSize: Int = 18, +) : PagedMangaParser(context, source, pageSize) { + + override val configKeyDomain: ConfigKey.Domain = ConfigKey.Domain(domain) + + 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 showPaidChapters: Boolean = false + + open val popularMangaTitleSelector = listOf( + "Popular", + "Popularie", + "Trending", + ) + + open fun popularMangaSelector(): String = + popularMangaTitleSelector.joinToString { + "div:contains($it) + div .group.overflow-hidden.grid" + } + + open fun popularMangaFromElement(element: Element): Manga { + val link = element.selectFirst("a[href]")!! + val url = link.attr("abs:href") + return Manga( + id = generateUid(url), + title = link.attr("title"), + altTitles = emptySet(), + url = url, + publicUrl = url, + rating = RATING_UNKNOWN, + contentRating = null, + coverUrl = element.getImageUrl("*[style*=background-image]") ?: "", + tags = emptySet(), + state = null, + authors = emptySet(), + largeCoverUrl = null, + description = null, + source = source, + ) + } + + open fun latestUpdatesSelector(): String = "div.grid > div.group" + + open fun latestUpdatesFromElement(element: Element): Manga = popularMangaFromElement(element) + + open fun searchMangaSelector(): String = "#searched_series_page > button" + + open fun searchMangaFromElement(element: Element): Manga = popularMangaFromElement(element) + + open fun Element.isNovel(): Boolean = + select("""[data-type="novel" i], span:matchesOwn((?i)^novel$)""").isNotEmpty() + + open fun Element.matchesQuery(query: String): Boolean = + attr("title").contains(query, ignoreCase = true) + + open fun Element.matchesGenres(genres: List): Boolean { + val tagsAttr = attr("tags").replace("___", "\"") + val entryGenres = Regex(""""([^"]+)"""").findAll(tagsAttr) + .map { it.groupValues[1] } + .toList() + return genres.all { g -> entryGenres.any { it.equals(g, ignoreCase = true) } } + } + + open fun Element.matchesTypes(types: List): Boolean = + types.any { it.equals(attr("data-type"), ignoreCase = true) } + + open fun Element.matchesStatuses(statuses: List): Boolean = + statuses.any { it.equals(attr("data-status"), ignoreCase = true) } + + override val availableSortOrders: Set = + EnumSet.of(SortOrder.POPULARITY, SortOrder.NEWEST, SortOrder.RELEVANCE) + + override val filterCapabilities: MangaListFilterCapabilities = + MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = true, + isSearchWithFiltersSupported = true, + ) + + override suspend fun getFilterOptions(): MangaListFilterOptions = MangaListFilterOptions( + availableTags = getAvailableTags(), + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, + MangaState.PAUSED, + MangaState.ABANDONED, + ), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ContentType.OTHER, + ), + ) + + @Volatile + private var cachedGenres: Set? = null + + protected open suspend fun getAvailableTags(): Set { + cachedGenres?.let { return it } + return fetchGenres().also { cachedGenres = it } + } + + private suspend fun fetchGenres(): Set { + return try { + val doc = webClient.httpGet("$baseUrl/series/").parseHtml() + parseGenres(doc) + } catch (_: Exception) { + emptySet() + } + } + + protected open fun parseGenres(document: Document): Set { + val script = document + .select("script:containsData(initializeDropdownMenu)") + .firstOrNull()?.data() + ?: return emptySet() + + val genreStart = script.indexOf("""type: "genre"""") + if (genreStart == -1) return emptySet() + + val itemsStart = script.indexOf("items:", genreStart) + if (itemsStart == -1) return emptySet() + + val itemsEnd = script.indexOf(']', itemsStart) + if (itemsEnd == -1) return emptySet() + + val genreBlock = script.substring(itemsStart, itemsEnd) + if (genreBlock.isBlank()) return emptySet() + + val itemRegex = Regex( + """value\s*:\s*["']([^"']+)["']\s*,\s*displayName\s*:\s*["']([^"']+)["']""", + RegexOption.IGNORE_CASE, + ) + + return itemRegex.findAll(genreBlock) + .mapNotNull { match -> + val value = match.groupValues[1].trim() + val name = match.groupValues[2].trim() + MangaTag(title = name, key = value, source = source) + .takeIf { name.isNotEmpty() && value.isNotEmpty() } + } + .toSet() + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + // Search / filtered browse → /series with params + if (!filter.query.isNullOrBlank() || filter.tags.isNotEmpty() || + filter.states.isNotEmpty() || filter.types.isNotEmpty() + ) { + val url = "$baseUrl/series".toHttpUrl().newBuilder().apply { + if (page > 1) addQueryParameter("page", page.toString()) + filter.query?.takeIf { it.isNotBlank() }?.let { addQueryParameter("q", it) } + filter.tags.forEach { addQueryParameter("genre", it.key) } + filter.states.firstOrNull()?.let { + addQueryParameter("status", it.toSiteStatus()) + } + filter.types.firstOrNull()?.let { + addQueryParameter("type", it.toSiteType()) + } + }.build() + + val doc = webClient.httpGet(url.toString()).parseHtml() + return doc.select(searchMangaSelector()) + .filter { entry -> + !entry.isNovel() && + (filter.query.isNullOrBlank() || entry.matchesQuery(filter.query!!)) && + (filter.tags.isEmpty() || entry.matchesGenres(filter.tags.map { it.key })) && + (filter.types.isEmpty() || + entry.matchesTypes(filter.types.map { it.toSiteType() })) && + (filter.states.isEmpty() || + entry.matchesStatuses(filter.states.map { it.toSiteStatus() })) + } + .map { searchMangaFromElement(it) } + } + + return when (order) { + SortOrder.POPULARITY -> { + val doc = webClient.httpGet(baseUrl).parseHtml() + doc.select(popularMangaSelector()) + .filter { !it.isNovel() } + .map { popularMangaFromElement(it) } + } + + SortOrder.RELEVANCE -> { + val url = if (page > 1) "$baseUrl/series/?page=$page" else "$baseUrl/series/" + val doc = webClient.httpGet(url).parseHtml() + doc.select(searchMangaSelector()) + .filter { !it.isNovel() } + .map { searchMangaFromElement(it) } + } + + else -> { + val url = if (page > 1) "$baseUrl/latest/?page=$page" else "$baseUrl/latest/" + val doc = webClient.httpGet(url).parseHtml() + doc.select(latestUpdatesSelector()) + .filter { !it.isNovel() } + .map { latestUpdatesFromElement(it) } + } + } + } + + private fun MangaState.toSiteStatus(): String = when (this) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + MangaState.PAUSED -> "hiatus" + MangaState.ABANDONED -> "dropped" + else -> name.lowercase() + } + + private fun ContentType.toSiteType(): String = when (this) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.MANHUA -> "manhua" + else -> name.lowercase() + } + + 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" + protected open val genreSelector: String = "div:has(>h1) a[href*='genre=']" + protected open val typeSelector: String = "div:has(span:containsOwn(Type)) ~ div" + protected open val dateSelector: String = ".text-xs" + protected open val paidChapterSelector: String = "img[alt~=Coin]" + + override suspend fun getDetails(manga: Manga): Manga { + val doc = webClient.httpGet(manga.publicUrl).parseHtml() + + val title = doc.selectFirst("div.grid > h1")?.text() ?: manga.title + val thumbnail = doc.getImageUrl("div[class*=photoURL], div[style*=photoURL]") + ?: manga.coverUrl + val state = doc.selectFirst(statusSelector).parseStatus() + + val author = doc.selectFirst(authorSelector)?.text() + val artist = doc.selectFirst(artistSelector)?.text() + + val genres = buildList { + doc.selectFirst(typeSelector)?.text()?.replaceFirstChar { + it.titlecase(Locale.ENGLISH) + }?.let(::add) + doc.select(genreSelector).forEach { add(it.text().trim(',', ' ')) } + }.filter { it.isNotBlank() } + + val synopsis = doc.selectFirst(descriptionSelector)?.text().orEmpty() + val altNames = doc.select(altNameSelector) + .map { it.text() } + .filter { it.isNotEmpty() && it != "No alternative titles." } + + val description = buildString { + append(synopsis) + if (altNames.isNotEmpty()) { + if (isNotEmpty()) append("\n\n") + append(altNamePrefix.trim()) + append("\n") + altNames.joinTo(this, "\n") { "- $it" } + } + }.takeIf { it.isNotEmpty() } + + val chapters = parseChapterList(doc) + + return manga.copy( + title = title, + coverUrl = thumbnail, + description = description, + authors = setOfNotNull(author, artist).ifEmpty { null } ?: emptySet(), + tags = genres.map { MangaTag(it, it.lowercase(), source) }.toSet(), + state = state, + chapters = chapters.reversed(), + ) + } + + open fun chapterListSelector(): String { + val base = "#chapters > :is(a, div):not(:has(.text-sm span:matches(Upcoming)))" + return if (!showPaidChapters) { + "$base:not(:has($paidChapterSelector))" + } else { + base + } + } + + open fun parseChapterList(document: Document): List = + document.select(chapterListSelector()).map { chapterFromElement(it) } + + open fun chapterFromElement(element: Element): MangaChapter { + val link = element.selectFirst("a[href]")!! + val url = link.attr("abs:href") + var name = element.selectFirst(".text-sm")!!.text() + + if (element.select(paidChapterSelector).isNotEmpty()) { + name = "🔒 $name" + } + + val uploadDate = element.selectFirst(dateSelector) + ?.text()?.trim()?.parseDate() + ?: 0L + + return MangaChapter( + id = generateUid(url), + title = name, + number = Regex("""(\d+(?:\.\d+)?)""").find(name) + ?.groupValues?.get(1)?.toFloatOrNull() ?: 0f, + volume = 0, + url = url, + scanlator = null, + uploadDate = uploadDate, + branch = null, + source = source, + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = webClient.httpGet(chapter.url).parseHtml() + val cdnUrl = getCdnUrl(doc) + + val uids = doc.select("#pages > img") + .map { it.attr("uid") } + .filter { it.isNotEmpty() } + + if (uids.isNotEmpty() && cdnUrl != null) { + return uids.mapIndexed { index, uid -> + MangaPage( + id = generateUid("${chapter.id}-$index"), + url = "$cdnUrl/$uid", + preview = null, + source = source, + ) + } + } + + return doc.select("#pages > img") + .map { it.imgAttr() } + .filter { OLD_IMG_CDN_REGEX.containsMatchIn(it) } + .mapIndexed { index, img -> + MangaPage( + id = generateUid("${chapter.id}-$index"), + url = img, + preview = null, + source = source, + ) + } + } + + override suspend fun getPageUrl(page: MangaPage): String = page.url + + protected open fun getCdnUrl(document: Document): String? { + val script = document.select("script") + .firstOrNull { CDN_HOST_REGEX.containsMatchIn(it.html()) } + ?: return null + val cdnHost = CDN_HOST_REGEX.find(script.html()) + ?.groups?.get(1)?.value + ?.replace(CDN_CLEAN_REGEX, "") + ?: return null + return "https://$cdnHost/uploads" + } + + protected fun Element?.parseStatus(): MangaState? = when (this?.text()?.lowercase()) { + "ongoing" -> MangaState.ONGOING + "dropped" -> MangaState.ABANDONED + "paused" -> MangaState.PAUSED + "completed" -> MangaState.FINISHED + else -> null + } + + private fun Element.imgAttr(): String = when { + hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") + hasAttr("data-src") -> attr("abs:data-src") + else -> attr("abs:src") + } + + protected open fun Element.getImageUrl(selector: String): String? = + selectFirst(selector)?.let { element -> + IMG_REGEX.find(element.attr("style"))?.groups?.get(1)?.value + ?.toHttpUrlOrNull()?.newBuilder() + ?.setQueryParameter("w", "480") + ?.build()?.toString() + } + + protected open fun String.parseDate(): Long { + val text = trim() + if (text.isEmpty()) return 0L + if (text.contains("ago", ignoreCase = true)) return parseRelativeDate() + return try { + LocalDate.parse(text, dateFormat) + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + .toEpochMilli() + } catch (_: Exception) { + 0L + } + } + + private fun String.parseRelativeDate(): Long { + val now = Calendar.getInstance().apply { + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + val relativeDate = split(" ").firstOrNull() + ?.replace("one", "1") + ?.replace("a", "1") + ?.toIntOrNull() + ?: return 0L + + when { + "second" in this -> now.add(Calendar.SECOND, -relativeDate) + "minute" in this -> now.add(Calendar.MINUTE, -relativeDate) + "hour" in this -> now.add(Calendar.HOUR, -relativeDate) + "day" in this -> now.add(Calendar.DAY_OF_YEAR, -relativeDate) + "week" in this -> now.add(Calendar.WEEK_OF_YEAR, -relativeDate) + "month" in this -> now.add(Calendar.MONTH, -relativeDate) + "year" in this -> now.add(Calendar.YEAR, -relativeDate) + } + return now.timeInMillis + } + + companion object { + val CDN_HOST_REGEX = """realUrl\s*=\s*`[^`]+//([^/]+)""".toRegex() + val CDN_CLEAN_REGEX = """\$\{[^}]*\}""".toRegex() + val IMG_REGEX = """url\(['"]?([^(['")])]+)""".toRegex() + val OLD_IMG_CDN_REGEX = Regex("""^(https?:)?//cdn\d*\.keyoapp\.com""") + } +} From aa0e9f4bd13d927144b8e87032cacab97b7599c0 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:18:21 +0200 Subject: [PATCH 11/13] Update MistScans.kt --- src/main/kotlin/tsuki/site/en/MistScans.kt | 331 +-------------------- 1 file changed, 3 insertions(+), 328 deletions(-) diff --git a/src/main/kotlin/tsuki/site/en/MistScans.kt b/src/main/kotlin/tsuki/site/en/MistScans.kt index 4a497058..8e9422e4 100644 --- a/src/main/kotlin/tsuki/site/en/MistScans.kt +++ b/src/main/kotlin/tsuki/site/en/MistScans.kt @@ -1,339 +1,14 @@ package tsuki.site.en -import tsuki.network.OkHttpWebClient import tsuki.MangaLoaderContext import tsuki.MangaSourceParser -import tsuki.config.ConfigKey -import tsuki.core.PagedMangaParser +import tsuki.parsers.KeyoApp -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.mapToSet -import tsuki.util.extractChapterNumber - -import java.text.SimpleDateFormat -import java.util.Calendar -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.jsoup.Jsoup -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.util.EnumSet -import java.util.Locale @MangaSourceParser("MISTSCANS", "Mist Scans", "en") internal class MistScans(context: MangaLoaderContext) : - PagedMangaParser(context, MangaParserSource.MISTSCANS, pageSize = 20) { - - override val configKeyDomain = ConfigKey.Domain("mistscans.com") - private val baseUrl = "https://$domain" - - private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH) - - override val webClient by lazy { - OkHttpWebClient(context.httpClient.newBuilder().build(), source) - } - - override fun getRequestHeaders() = super.getRequestHeaders().newBuilder() - .set("Referer", "$baseUrl/") - .build() - - @Volatile - private var cachedGenres: List? = null - - private data class Genre( - val name: String, - val id: String - ) - - private suspend fun fetchGenres(): List { - if (cachedGenres != null) return cachedGenres!! - val html = webClient.httpGet("$baseUrl/series/", getRequestHeaders()).body.string() - val doc = Jsoup.parse(html, baseUrl) - val genres = doc.select("#series_tags_page > button").map { btn -> - Genre(btn.text(), btn.attr("tag")) - } - cachedGenres = genres - return genres - } - - override val availableSortOrders: Set = EnumSet.of( - SortOrder.POPULARITY, - SortOrder.UPDATED, - SortOrder.ADDED, - ) - - override val filterCapabilities = MangaListFilterCapabilities( - isSearchSupported = true, - isMultipleTagsSupported = true, - ) - - override suspend fun getFilterOptions(): MangaListFilterOptions { - val genres = fetchGenres() - return MangaListFilterOptions( - availableTags = genres.map { MangaTag(it.name, it.id, source) }.toSet(), - availableStates = emptySet(), - ) - } - - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val query = filter.query?.trim().orEmpty() - val genreIds = filter.tags.map { it.key } - - if (query.isNotEmpty() || genreIds.isNotEmpty()) { - return searchManga(query, genreIds) - } - - return when (order) { - SortOrder.UPDATED -> fetchLatest() - SortOrder.ADDED -> fetchAdded() - SortOrder.POPULARITY -> fetchPopular() - else -> fetchPopular() - } - } - - private suspend fun fetchPopular(): List { - val html = webClient.httpGet(baseUrl, getRequestHeaders()).body.string() - val doc = Jsoup.parse(html, baseUrl) - return doc.select(".series-splide .splide__slide").map { element -> - popularMangaFromElement(element) - } - } - - private suspend fun fetchLatest(): List { - val html = webClient.httpGet("$baseUrl/latest/", getRequestHeaders()).body.string() - val doc = Jsoup.parse(html, baseUrl) - return doc.select("div.grid > div.group").map { element -> - popularMangaFromElement(element) - } - } - - private suspend fun fetchAdded(): List { - val html = webClient.httpGet("$baseUrl/series/", getRequestHeaders()).body.string() - val doc = Jsoup.parse(html, baseUrl) - return doc.select("#searched_series_page > button").map { element -> - popularMangaFromElement(element) - } - } - - private suspend fun searchManga(query: String, genreIds: List): List { - val urlBuilder = "$baseUrl/series/".toHttpUrlOrNull()?.newBuilder()?.apply { - if (query.isNotEmpty()) addQueryParameter("q", query) - genreIds.forEach { addQueryParameter("genre", it) } - }?.build()?.toString() ?: return emptyList() - - val html = webClient.httpGet(urlBuilder, getRequestHeaders()).body.string() - val doc = Jsoup.parse(html, baseUrl) - - return doc.select("#searched_series_page > button") - .filter { element -> - val title = element.attr("title") - val matchesQuery = query.isEmpty() || title.contains(query, true) - - val entryGenres = try { - element.attr("tags").parseAsList() - } catch (_: Exception) { emptyList() } - val matchesGenres = genreIds.isEmpty() || - genreIds.all { genre -> entryGenres.any { it.equals(genre, true) } } - - matchesQuery && matchesGenres - } - .map { element -> popularMangaFromElement(element) } - } - - private fun popularMangaFromElement(element: Element): Manga { - val a = element.selectFirst("a[href]") ?: throw Exception("Link not found") - val title = a.attr("title") - val url = a.attr("abs:href") - val cover = element.getImageUrl("*[style*=background-image]") ?: "" - return Manga( - id = generateUid(url), - url = url, - publicUrl = url, - title = title, - coverUrl = cover, - altTitles = emptySet(), - rating = RATING_UNKNOWN, - contentRating = null, - tags = emptySet(), - state = null, - authors = emptySet(), - source = source, - ) - } - - override suspend fun getDetails(manga: Manga): Manga { - val doc = fetchDocument(manga.url) - - val title = doc.selectFirst("div.grid > h1")?.text() ?: manga.title - val cover = doc.getImageUrl("div[class*=photoURL]") ?: manga.coverUrl ?: "" - val status = doc.selectFirst("div:has(span:containsOwn(Status)) ~ div").parseStatus() - val author = doc.selectFirst("div:has(span:containsOwn(Author)) ~ div")?.text() - val artist = doc.selectFirst("div:has(span:containsOwn(Artist)) ~ div")?.text() - val type = doc.selectFirst("div:has(span:containsOwn(Type)) ~ div")?.text() - val genres = doc.select("div.grid:has(>h1) > div > a:not([title='Status'])").map { it.text() } - val altNames = doc.select("div.font-medium:containsOwn(Alternative titles) ~ div span").mapToSet { it.text() } - val description = buildString { - val synopsis = doc.selectFirst("#expand_content p")?.text().orEmpty() - append(synopsis) - }.takeIf { it.isNotEmpty() } - - val chapters = fetchChapters(doc) - - return manga.copy( - title = title, - altTitles = altNames, - coverUrl = cover, - description = description, - authors = listOfNotNull(author, artist).toSet(), - tags = (genres + listOfNotNull(type)).map { MangaTag(it.lowercase(), it, source) }.toSet(), - state = status, - chapters = chapters.sortedBy { it.number }, - ) - } - - private suspend fun fetchDocument(url: String): Document { - val fullUrl = if (url.startsWith("http")) url else "$baseUrl$url" - val html = webClient.httpGet(fullUrl, getRequestHeaders()).body.string() - return Jsoup.parse(html, fullUrl) - } - - private fun Element?.parseStatus(): MangaState? = when (this?.text()?.lowercase()) { - "ongoing" -> MangaState.ONGOING - "dropped" -> MangaState.ABANDONED - "paused" -> MangaState.PAUSED - "completed" -> MangaState.FINISHED - else -> null - } - - private fun fetchChapters(document: Document): List { - val selector = "#chapters > a:not(:has(.text-sm span:matches(Upcoming))):not(:has(img[alt~=Coin]))" - return document.select(selector).map { element -> - val link = element.selectFirst("a[href]") ?: return@map null - val url = link.attr("abs:href") - val name = element.selectFirst(".text-sm")?.text() ?: "" - val dateStr = element.selectFirst(".text-xs")?.text()?.trim() ?: "" - val uploadDate = dateStr.parseDate() - - MangaChapter( - id = generateUid(url), - title = name, - number = name.extractChapterNumber(), - volume = 0, - url = url, - uploadDate = uploadDate, - scanlator = null, - branch = null, - source = source, - ) - }.filterNotNull() - } - - override suspend fun getPages(chapter: MangaChapter): List { - val doc = fetchDocument(chapter.url) - - val cdnUrl = getCdnUrl(doc) - val uidPages = doc.select("#pages > img") - .map { it.attr("uid") } - .filter { it.isNotEmpty() } - - if (uidPages.isNotEmpty() && cdnUrl != null) { - return uidPages.mapIndexed { _, uid -> - MangaPage( - id = generateUid(uid), - url = "$cdnUrl/$uid", - preview = null, - source = source, - ) - } - } - - return doc.select("#pages > img").map { it.imgAttr() } - .filter { it.contains(Regex("""^(https?:)?//cdn\d*\.keyoapp\.com""")) } - .mapIndexed { _, imgUrl -> - MangaPage( - id = generateUid(imgUrl), - url = imgUrl, - preview = null, - source = source, - ) - } - } - - private fun getCdnUrl(document: Document): String? { - val script = document.select("script").firstOrNull { - CDN_HOST_REGEX.containsMatchIn(it.html()) - } ?: return null - val cdnHost = CDN_HOST_REGEX.find(script.html())?.groups?.get(1)?.value - ?.replace(CDN_CLEAN_REGEX, "") ?: return null - return "https://$cdnHost/uploads" - } - - override suspend fun getRelatedManga(seed: Manga): List = emptyList() - - private fun Element.getImageUrl(selector: String): String? { - val element = selectFirst(selector) ?: return null - val style = element.attr("style") - val match = IMG_REGEX.find(style) ?: return null - val rawUrl = match.groups[1]?.value ?: return null - val url = rawUrl.toHttpUrlOrNull() ?: return null - return url.newBuilder().setQueryParameter("w", "480").build().toString() - } - - private fun Element.imgAttr(): String = when { - hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") - hasAttr("data-src") -> attr("abs:data-src") - else -> attr("abs:src") - } - - private fun String.parseDate(): Long { - val trimmed = trim() - if (trimmed.contains("ago")) return trimmed.parseRelativeDate() - return try { dateFormat.parse(trimmed)?.time ?: 0L } catch (_: Exception) { 0L } - } - - private fun String.parseRelativeDate(): Long { - val now = Calendar.getInstance().apply { - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - val number = split(" ").firstOrNull() - ?.replace("one", "1") - ?.replace("a", "1") - ?.toIntOrNull() ?: return 0L - val text = this.lowercase() - when { - "second" in text -> now.add(Calendar.SECOND, -number) - "minute" in text -> now.add(Calendar.MINUTE, -number) - "hour" in text -> now.add(Calendar.HOUR, -number) - "day" in text -> now.add(Calendar.DAY_OF_YEAR, -number) - "week" in text -> now.add(Calendar.WEEK_OF_YEAR, -number) - "month" in text -> now.add(Calendar.MONTH, -number) - "year" in text -> now.add(Calendar.YEAR, -number) - } - return now.timeInMillis - } - - private fun String.parseAsList(): List { - return removeSurrounding("[", "]") - .split(",") - .map { it.trim().removeSurrounding("\"") } - .filter { it.isNotEmpty() } - } + KeyoApp(context, MangaParserSource.MISTSCANS, "mistscans.com", pageSize = 20) { - companion object { - val CDN_HOST_REGEX = """realUrl\s*=\s*`[^`]+//([^/]+)""".toRegex() - val CDN_CLEAN_REGEX = """\$\{[^}]*\}""".toRegex() - val IMG_REGEX = """url\(['"]?([^(['")])]+)""".toRegex() - } + override fun popularMangaSelector(): String = ".series-splide .splide__slide" } From c8d467718541aef51c1ca8a69db15d8596146e51 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:18:39 +0200 Subject: [PATCH 12/13] Add TimelessToons manga source parser --- src/main/kotlin/tsuki/site/en/TimelessToons.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/main/kotlin/tsuki/site/en/TimelessToons.kt diff --git a/src/main/kotlin/tsuki/site/en/TimelessToons.kt b/src/main/kotlin/tsuki/site/en/TimelessToons.kt new file mode 100644 index 00000000..d398c8e9 --- /dev/null +++ b/src/main/kotlin/tsuki/site/en/TimelessToons.kt @@ -0,0 +1,18 @@ +package tsuki.site.en + +import tsuki.MangaLoaderContext +import tsuki.MangaSourceParser +import tsuki.parsers.KeyoApp + +import tsuki.model.MangaParserSource + +@MangaSourceParser("TIMELESSTOONS", "TimelessToons", "en") +internal class TimelessToons(context: MangaLoaderContext) : + KeyoApp(context, MangaParserSource.TIMELESSTOONS, "timelesstoons.org") { + + override fun popularMangaSelector() = + "div:has(> h2:contains(Trending)) + div .group" + + override fun latestUpdatesSelector() = + "div.grid > div.group.latest-poster" +} From 0f665f0d103fc7caf86903e69ab8b6c80e7d518a Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:40:06 +0200 Subject: [PATCH 13/13] Create PhenixScans.kt --- src/main/kotlin/tsuki/site/fr/PhenixScans.kt | 266 +++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 src/main/kotlin/tsuki/site/fr/PhenixScans.kt diff --git a/src/main/kotlin/tsuki/site/fr/PhenixScans.kt b/src/main/kotlin/tsuki/site/fr/PhenixScans.kt new file mode 100644 index 00000000..7d052c2d --- /dev/null +++ b/src/main/kotlin/tsuki/site/fr/PhenixScans.kt @@ -0,0 +1,266 @@ +package tsuki.site.fr + +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.json.mapJSON +import tsuki.util.json.mapJSONNotNull +import tsuki.util.json.mapJSONToSet +import tsuki.util.oneOrThrowIfMany +import tsuki.util.parseJson +import tsuki.util.parseSafe + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.EnumSet +import java.util.Locale + +private const val API_BASE = "https://api.phenix-scans.co/api" +private const val IMAGE_BASE = "https://api.phenix-scans.co" +private const val WEB_BASE = "https://phenix-scans.co" +private const val PAGE_SIZE = 18 + + +@MangaSourceParser("PHENIXSCANS", "Phenix Scans (unoriginal)", "fr") +internal class PhenixScans(context: MangaLoaderContext): + PagedMangaParser(context, MangaParserSource.PHENIXSCANS, PAGE_SIZE) { + + override val configKeyDomain = ConfigKey.Domain("phenix-scans.co") + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.ALPHABETICAL, + SortOrder.RATING, + SortOrder.UPDATED, + SortOrder.NEWEST, + SortOrder.POPULARITY, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = true, + isSearchWithFiltersSupported = true, + ) + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.FRENCH) + private val tagMutex = Mutex() + + @Volatile + private var cachedTags: Set? = null + + override suspend fun getFilterOptions(): MangaListFilterOptions { + return MangaListFilterOptions( + availableTags = getOrCreateTagMap(), + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, + MangaState.PAUSED, + MangaState.ABANDONED, + ), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ), + ) + } + + private suspend fun getOrCreateTagMap(): Set { + cachedTags?.let { return it } + return tagMutex.withLock { + cachedTags ?: fetchTags().also { cachedTags = it } + } + } + + private suspend fun fetchTags(): Set { + return try { + val json = webClient.httpGet("$API_BASE/front/manga?limit=0&page=0").parseJson() + json.getJSONArray("genres").mapJSONToSet { jo -> + MangaTag( + key = jo.getString("id"), + title = jo.getString("name"), + source = source, + ) + } + } catch (_: Exception) { + emptySet() + } + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val url = if (!filter.query.isNullOrBlank()) { + if (page > 1) return emptyList() + "$API_BASE/front/manga/search".toHttpUrl().newBuilder() + .addQueryParameter("query", filter.query) + .build() + .toString() + } else { + val builder = "$API_BASE/front/manga".toHttpUrl().newBuilder() + .addQueryParameter("limit", PAGE_SIZE.toString()) + .addQueryParameter("page", page.toString()) + + builder.addQueryParameter( + "sort", + when (order) { + SortOrder.ALPHABETICAL -> "title" + SortOrder.RATING -> "rating" + SortOrder.UPDATED -> "updatedAt" + SortOrder.NEWEST -> "updatedAt" + SortOrder.POPULARITY -> "rating" + else -> "title" + }, + ) + + if (filter.tags.isNotEmpty()) { + builder.addQueryParameter( + "genre", + filter.tags.joinToString(",") { it.key }, + ) + } + + if (filter.types.isNotEmpty()) { + val typeStr = filter.types.mapNotNull { + when (it) { + ContentType.MANGA -> "Manga" + ContentType.MANHWA -> "Manhwa" + ContentType.MANHUA -> "Manhua" + else -> null + } + }.joinToString(",") + if (typeStr.isNotEmpty()) { + builder.addQueryParameter("type", typeStr) + } + } + + filter.states.oneOrThrowIfMany()?.let { state -> + builder.addQueryParameter( + "status", + when (state) { + MangaState.ONGOING -> "Ongoing" + MangaState.FINISHED -> "Completed" + MangaState.PAUSED -> "Hiatus" + MangaState.ABANDONED -> "Hiatus" + else -> "" + }, + ) + } + builder.build().toString() + } + val json = webClient.httpGet(url).parseJson() + return json.getJSONArray("mangas").mapJSON(::parseManga) + } + + private fun parseManga(jo: JSONObject): Manga { + val slug = jo.getString("slug") + val cover = jo.getString("coverImage") + return Manga( + id = generateUid(slug), + title = jo.getString("title"), + altTitles = emptySet(), + url = slug, + publicUrl = "$WEB_BASE/manga/$slug", + rating = RATING_UNKNOWN, + contentRating = null, + coverUrl = "$IMAGE_BASE/$cover", + tags = emptySet(), + state = null, + authors = emptySet(), + largeCoverUrl = null, + description = null, + source = source, + ) + } + + override suspend fun getDetails(manga: Manga): Manga { + val json = webClient.httpGet("$API_BASE/front/manga/${manga.url}").parseJson() + val mangaObj = json.getJSONObject("manga") + val chaptersArr = json.getJSONArray("chapters") + + val chapters = chaptersArr.mapJSONNotNull { jo -> + val number = jo.get("number").toString().toFloatOrNull() ?: 0f + val chapterUrl = "${manga.url}/$number" + MangaChapter( + id = generateUid(chapterUrl), + title = "Chapter $number", + number = number, + volume = 0, + url = chapterUrl, + scanlator = null, + uploadDate = dateFormat.parseSafe(jo.optString("createdAt", "")), + branch = null, + source = source, + ) + }.sortedBy { it.number } + + val cover = mangaObj.optString("coverImage").takeIf { it.isNotEmpty() } + + val tags: Set = mangaObj.optJSONArray("genres")?.mapJSONToSet { jo -> + MangaTag( + key = jo.getString("id"), + title = jo.getString("name"), + source = source, + ) + } ?: emptySet() + + val rating: Float = mangaObj.optDouble("averageRating", 0.0) + .takeIf { it > 0.0 } + ?.let { (it / 10.0).toFloat() } + ?: RATING_UNKNOWN + + return manga.copy( + title = mangaObj.getString("title"), + coverUrl = cover?.let { "$IMAGE_BASE/$it" } ?: manga.coverUrl, + description = mangaObj.optString("synopsis").takeIf { it.isNotEmpty() }, + state = when (mangaObj.optString("status").lowercase()) { + "ongoing" -> MangaState.ONGOING + "hiatus" -> MangaState.PAUSED + "completed" -> MangaState.FINISHED + else -> null + }, + tags = tags, + rating = rating, + chapters = chapters, + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val slug = chapter.url.substringBeforeLast("/") + val number = chapter.url.substringAfterLast("/") + val json = webClient.httpGet("$API_BASE/front/manga/$slug/chapter/$number").parseJson() + val images = json.getJSONObject("chapter").getJSONArray("images") + return buildList(images.length()) { + for (i in 0 until images.length()) { + val path = images.getString(i) + add( + MangaPage( + id = generateUid(path), + url = "$IMAGE_BASE/$path", + preview = null, + source = source, + ), + ) + } + } + } + + override suspend fun getPageUrl(page: MangaPage): String = page.url +}