From 9a9c9b24c56d67243343441055238a12ddeec867 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:43:29 +0200 Subject: [PATCH 001/109] added groups mangafire --- .../parsers/site/kotatsu/all/MangaFire.kt | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt index 32b94f81..1f6e8c38 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.Demographic import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter @@ -18,12 +19,10 @@ import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN -import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.network.OkHttpWebClient import org.koitharu.kotatsu.parsers.util.generateUid import org.koitharu.kotatsu.parsers.util.parseJson -import org.koitharu.kotatsu.parsers.model.Demographic import java.util.EnumSet internal abstract class MangaFireParser( @@ -67,11 +66,8 @@ internal abstract class MangaFireParser( isTagsExclusionSupported = true, isSearchSupported = true, isSearchWithFiltersSupported = true, - isYearSupported = false, // idk how to do the year support - isYearRangeSupported = false, ) - // ---------- Tag ---------- companion object { val GENRE_MAP = mapOf( "Action" to "1", "Adventure" to "78", "Avant Garde" to "3", @@ -106,8 +102,7 @@ internal abstract class MangaFireParser( availableContentTypes = EnumSet.of( ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER, ), - availableDemographics = EnumSet.of(Demographic.SHOUNEN, Demographic.SHOUJO, Demographic.SEINEN, Demographic.JOSEI, - ), + availableDemographics = EnumSet.of(Demographic.SHOUNEN, Demographic.SHOUJO, Demographic.SEINEN, Demographic.JOSEI), ) // ---------- Listing ---------- @@ -147,7 +142,6 @@ internal abstract class MangaFireParser( filter.tags.forEach { url.append("&genres_in[]=").append(it.key) } filter.tagsExclude.forEach { url.append("&genres_ex[]=").append(it.key) } - // --- Statuses --- filter.states.forEach { url.append("&statuses[]=").append( when (it) { @@ -161,7 +155,6 @@ internal abstract class MangaFireParser( ) } - // --- Sort order --- val sortParam = when (order) { SortOrder.UPDATED -> "order[chapter_updated_at]=desc" SortOrder.POPULARITY -> "order[views_total]=desc" @@ -173,7 +166,6 @@ internal abstract class MangaFireParser( } if (sortParam.isNotEmpty()) url.append("&").append(sortParam) - // --- Execute request --- val response = apiClient.httpGet(url.toString()).parseJson() val items = response.getJSONArray("items") val mangas = mutableListOf() @@ -210,7 +202,7 @@ internal abstract class MangaFireParser( // ---------- Details ---------- override suspend fun getDetails(manga: Manga): Manga { - val hid = manga.url.substringAfter("/title/").substringBefore("-").substringBefore("/") + val hid = extractHid(manga.url) // improved extraction val detailsJson = apiClient.httpGet("https://$domain/api/titles/$hid").parseJson() val data = detailsJson.getJSONObject("data") @@ -247,12 +239,9 @@ internal abstract class MangaFireParser( tags.find { it.title == name } }.toSet() -// val ratingValue = if (data.has("rating")) { -// data.getDouble("rating").toFloat() -// } else { -// RATING_UNKNOWN -// } - // they use rating till 10 stars so idk + val ratingValue = data.optDouble("rating", RATING_UNKNOWN.toDouble()).toFloat().let { + if (it <= 0f) RATING_UNKNOWN else it / 2f + } val chapters = fetchChapters(hid) @@ -261,7 +250,7 @@ internal abstract class MangaFireParser( coverUrl = cover ?: manga.coverUrl, authors = setOfNotNull(authors), description = description, - state = when (status) { + state = when (status?.lowercase()) { "releasing" -> MangaState.ONGOING "finished" -> MangaState.FINISHED "discontinued" -> MangaState.ABANDONED @@ -271,6 +260,7 @@ internal abstract class MangaFireParser( }, tags = genreTags, altTitles = altTitlesArray.toSet(), + rating = ratingValue, chapters = chapters, ) } @@ -290,13 +280,13 @@ internal abstract class MangaFireParser( for (i in 0 until items.length()) { val chObj = items.getJSONObject(i) - // Only chapters for the current language if (chObj.getString("language") != siteLang) continue val id = chObj.getInt("id") val number = chObj.getDouble("number").toFloat() val name = chObj.optString("name", null) val createdAt = chObj.optLong("createdAt", 0L) * 1000L + val type = chObj.getString("type") // "official" or "unofficial" val chapterUrl = "/title/$hid/$id" val displayName = buildString { append("Ch. ") @@ -314,7 +304,7 @@ internal abstract class MangaFireParser( url = chapterUrl, scanlator = null, uploadDate = createdAt, - branch = null, + branch = type, // store raw type temporarily source = source, ) ) @@ -322,12 +312,25 @@ internal abstract class MangaFireParser( page++ } while (page <= lastPage) - return chapters.sortedBy { it.number } + val distinctTypes = chapters.map { it.branch }.distinct() + val useGroups = distinctTypes.size > 1 + + return chapters + .map { chapter -> + chapter.copy( + branch = if (useGroups) { + (chapter.branch ?: "").replaceFirstChar { it.uppercase() } + } else { + null + } + ) + } + .sortedBy { it.number } } // ---------- Pages ---------- override suspend fun getPages(chapter: MangaChapter): List { - val chapterId = chapter.url.substringAfterLast("/") + val chapterId = chapter.url.substringAfterLast("/") // numeric ID val response = apiClient.httpGet("https://$domain/api/chapters/$chapterId").parseJson() val pagesArray = response.getJSONObject("data").getJSONArray("pages") val pages = ArrayList(pagesArray.length()) @@ -352,6 +355,18 @@ internal abstract class MangaFireParser( override suspend fun isAuthorized(): Boolean = true override suspend fun getUsername(): String = "" + // ---------- Utility ---------- + + private fun extractHid(url: String): String { + val lastPart = url.removeSuffix("/").substringAfterLast("/") + return when { + lastPart.contains(".") -> lastPart.substringAfterLast(".") + lastPart.contains("-") -> lastPart.substringBefore("-") + else -> lastPart + } + } + + // ---------- Sources ---------- @MangaSourceParser("MANGAFIRE_EN", "MangaFire (English)", "en") class English(context: MangaLoaderContext) : MangaFireParser(context, MangaParserSource.MANGAFIRE_EN, "en") From 3e080c64bf790a10694b9baaa5f8b0844107f529 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:13:54 +0200 Subject: [PATCH 002/109] Update MangaFire.kt --- .../parsers/site/kotatsu/all/MangaFire.kt | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt index 1f6e8c38..0b574224 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt @@ -34,12 +34,14 @@ internal abstract class MangaFireParser( override val configKeyDomain = ConfigKey.Domain("mangafire.to") override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.RATING, - SortOrder.NEWEST, - SortOrder.ALPHABETICAL, - SortOrder.RELEVANCE, + SortOrder.UPDATED, // chapter update + SortOrder.POPULARITY, // most views + SortOrder.RATING, // rating score + SortOrder.NEWEST, // created manga + SortOrder.ALPHABETICAL, // title asc + SortOrder.RELEVANCE, // relevance sc + SortOrder.POPULARITY_WEEK, + SortOrder.POPULARITY_MONTH, ) private val apiClient by lazy { @@ -162,6 +164,8 @@ internal abstract class MangaFireParser( SortOrder.NEWEST -> "order[created_at]=desc" SortOrder.ALPHABETICAL -> "order[title]=asc" SortOrder.RELEVANCE -> "order[relevance]=desc" + SortOrder.POPULARITY_WEEK -> "order[views_7d]=desc" + SortOrder.POPULARITY_MONTH -> "order[views_30d]=desc" else -> "" } if (sortParam.isNotEmpty()) url.append("&").append(sortParam) @@ -239,9 +243,8 @@ internal abstract class MangaFireParser( tags.find { it.title == name } }.toSet() - val ratingValue = data.optDouble("rating", RATING_UNKNOWN.toDouble()).toFloat().let { - if (it <= 0f) RATING_UNKNOWN else it / 2f - } +// val raw = data.optDouble("rating", -1.0) +// val ratingValue = if (raw >= 0.0) (raw / 2.0).toFloat() else throw Exception("Invalid rating value: $raw") val chapters = fetchChapters(hid) @@ -260,7 +263,7 @@ internal abstract class MangaFireParser( }, tags = genreTags, altTitles = altTitlesArray.toSet(), - rating = ratingValue, +// rating = ratingValue, chapters = chapters, ) } From 4a5422512e953df4c34628ad5d3034cd83b025f6 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:34:29 +0200 Subject: [PATCH 003/109] Update NHentaiParser.kt --- .../site/kotatsu/all/hentais/NHentaiParser.kt | 443 +++++++++++------- 1 file changed, 280 insertions(+), 163 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt index 10737e4d..63a5f821 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt @@ -1,219 +1,336 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.all.hentais import org.json.JSONObject +import org.jsoup.Jsoup import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaSourceParser -import org.koitharu.kotatsu.parsers.model.ContentRating -import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN -import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.parsers.GalleryAdultsParser -import org.koitharu.kotatsu.parsers.util.generateUid -import org.koitharu.kotatsu.parsers.util.json.mapJSON -import org.koitharu.kotatsu.parsers.util.parseJson -import org.koitharu.kotatsu.parsers.util.urlBuilder -import org.koitharu.kotatsu.parsers.util.urlEncoded -import java.util.EnumSet -import java.util.Locale -import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions -import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.util.* + +private const val ONE_IMG_SERVER = "1" +private const val TWO_IMG_SERVER = "2" +private const val THREE_IMG_SERVER = "3" +private const val FOUR_IMG_SERVER = "4" @MangaSourceParser("NHENTAI", "NHentai.net", type = ContentType.HENTAI) internal class NHentaiParser(context: MangaLoaderContext) : - GalleryAdultsParser(context, MangaParserSource.NHENTAI, "nhentai.net", 25) { + PagedMangaParser(context, MangaParserSource.NHENTAI, 25), + MangaParserAuthProvider { + + override val configKeyDomain = ConfigKey.Domain("nhentai.net") - private val apiSuffix = "api/v2" + private var imageServer: String = "" + private var thumbServer: String = "" - // Required overrides for base class - override val selectGallery = "" - override val selectGalleryLink = "" - override val selectGalleryTitle = "" - override val selectGalleryImg = "" - override val idImg = "none" + private var displayFullTitle: Boolean = true override val availableSortOrders: Set = EnumSet.of( SortOrder.UPDATED, + SortOrder.POPULARITY, ) - private fun JSONObject.extractTags(): Set { - return optJSONArray("tags") - ?.mapJSON { tag -> + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isMultipleTagsSupported = true, + isAuthorSearchSupported = true, + ) - if (tag.optString("type") != "tag") { - return@mapJSON null - } + private val preferredServerKey = ConfigKey.PreferredImageServer( + presetValues = mapOf( + ONE_IMG_SERVER to "First server", + TWO_IMG_SERVER to "Second server", + THREE_IMG_SERVER to "Third server", + FOUR_IMG_SERVER to "Fourth server", + ), + defaultValue = ONE_IMG_SERVER, + ) - MangaTag( - title = tag.optString("name") - .replace("-", " ") - .replaceFirstChar { c -> c.uppercase() }, - key = tag.optString("name"), - source = source - ) - } - ?.filterNotNull() - ?.toSet() - ?: emptySet() + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + keys.add(preferredServerKey) + } + + // Apply selected server – used later for image requests + private val selectedServer: String + get() = config[preferredServerKey].toString() + + companion object { + val LANG_MAP = mapOf( + "en" to "english", + "ja" to "japanese", + "zh" to "chinese", + ) } - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - - filter.query?.trim()?.let { query -> - if (query.all(Char::isDigit)) { - return try { - val obj = webClient - .httpGet("https://$domain/$apiSuffix/galleries/$query") - .parseJson() - - val title = obj.extractTitle() - - listOf( - Manga( - id = generateUid("/g/$query/"), - title = title.cleanupTitle().ifEmpty { title }, - altTitles = emptySet(), - url = "/g/$query/", - publicUrl = "https://$domain/g/$query/", - rating = RATING_UNKNOWN, - contentRating = ContentRating.ADULT, - coverUrl = "https://t.$domain/${obj.getThumbnailPath()}", - tags = obj.extractTags(), - state = null, - authors = emptySet(), - source = source - ) + // Cached popular tags + private var popularTags: Set? = null + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = getPopularTags(), + availableLocales = setOf( + Locale.ENGLISH, + Locale.JAPANESE, + Locale.CHINESE, + ), + ) + + private suspend fun getPopularTags(): Set { + popularTags?.let { return it } + return try { + val doc = Jsoup.parse( + webClient.httpGet("https://$domain/tags?sort=popular").parseHtml().toString() + ) + val tags = doc.select("section#tag-container a.tag") + .mapNotNull { element -> + val name = element.text() + if (name.isBlank()) null + else MangaTag( + title = name.replace("-", " ").replaceFirstChar { it.uppercase() }, + key = name, // raw tag name, will be used as tag:"name" + source = source, ) - } catch (_: Exception) { - emptyList() - } + }.toSet() + popularTags = tags + tags + } catch (_: Exception) { + emptySet() + } + } + + private suspend fun ensureNhConfig() { + if (imageServer.isBlank() || thumbServer.isBlank()) { + try { + val json = webClient.httpGet("https://$domain/api/v2/config").parseJson() + val imgServers = json.getJSONArray("image_servers") + .let { (0 until it.length()).map { i -> it.getString(i) } } + val thbServers = json.getJSONArray("thumb_servers") + .let { (0 until it.length()).map { i -> it.getString(i) } } + imageServer = imgServers[selectedServer.toInt() - 1] + thumbServer = thbServers[selectedServer.toInt() - 1] + } catch (_: Exception) { + imageServer = "https://i$selectedServer.nhentai.net" + thumbServer = "https://t$selectedServer.nhentai.net" } } + } + + + override suspend fun getListPage( + page: Int, + order: SortOrder, + filter: MangaListFilter, + ): List { + ensureNhConfig() - val url = urlBuilder().addPathSegments(apiSuffix) - val isDefaultHome = order == SortOrder.UPDATED - && filter.query.isNullOrEmpty() - - if (isDefaultHome) { - url.addPathSegment("galleries") - url.addQueryParameter("page", page.toString()) - } else { - val query = buildString { - filter.query?.trim()?.takeIf { it.isNotEmpty() }?.let { - append(it) + // Language filter + val langQuery = filter.locale?.let { loc -> + val langKey = loc.language.lowercase() + LANG_MAP[langKey]?.let { "language:$it" } + } + + // Direct ID search + val directId = extractGalleryId(filter.query) + if (directId != null) { + return try { + listOf(fetchMangaById(directId)) + } catch (_: Exception) { + emptyList() + } + } + + val isLatest = order == SortOrder.UPDATED && filter.query.isNullOrBlank() && langQuery == null && filter.tags.isEmpty() + + val url = buildString { + append("https://$domain/api/v2/") + if (isLatest) { + append("galleries?page=$page") + } else { + val queryParts = mutableListOf() + if (!filter.query.isNullOrBlank()) queryParts.add(filter.query!!) + if (langQuery != null) queryParts.add(langQuery) + + // Add selected tags as tag:"name" + filter.tags.forEach { tag -> + queryParts.add("tag:\"${tag.key}\"") } - if (isEmpty()) { - append("pages:>0") + val finalQuery = queryParts.joinToString(" ").ifBlank { "\"\"" } + val sort = when (order) { + SortOrder.UPDATED -> "date" + SortOrder.POPULARITY -> "popular" + else -> "date" } + append("search?query=${finalQuery.urlEncoded()}&sort=$sort&page=$page") } + } - val sort = when (order) { - else -> "date" - } + val json = webClient.httpGet(url).parseJson() + val items = json.getJSONArray("result") + return (0 until items.length()).map { i -> items.getJSONObject(i).toManga() } + } - url.addPathSegment("search") - url.addQueryParameter("query", query.urlEncoded()) - url.addQueryParameter("sort", sort) - url.addQueryParameter("page", page.toString()) + private fun extractGalleryId(query: String?): Int? { + if (query.isNullOrBlank()) return null + val trimmed = query.trim() + return when { + trimmed.startsWith("id:") -> trimmed.removePrefix("id:").toIntOrNull() + trimmed.all(Char::isDigit) -> trimmed.toIntOrNull() + else -> null } + } + + private suspend fun fetchMangaById(id: Int): Manga { + ensureNhConfig() + val json = webClient.httpGet("https://$domain/api/v2/galleries/$id").parseJson() + return json.toMangaDetails() + } + + override suspend fun getDetails(manga: Manga): Manga { + ensureNhConfig() + val id = manga.url.removeSurrounding("/g/", "/").toInt() + val json = webClient.httpGet("https://$domain/api/v2/galleries/$id").parseJson() + return json.toMangaDetails().copy( + coverUrl = manga.coverUrl?.ifBlank { + "${thumbServer}/${json.getJSONObject("cover").optString("path")}" + }, + ) + } - val json = webClient.httpGet(url.build()).parseJson() - return json.optJSONArray("result").mapJSON { - val id = it.getInt("id") - val title = it.extractTitle() - Manga( - id = generateUid("/g/$id/"), - title = title.cleanupTitle().ifEmpty { title }, - altTitles = emptySet(), - url = "/g/$id/", - publicUrl = "https://$domain/g/$id/", - rating = RATING_UNKNOWN, - contentRating = ContentRating.ADULT, - coverUrl = "https://t.$domain/${it.getThumbnailPath()}", - tags = it.extractTags(), - state = null, - authors = emptySet(), - source = source + override suspend fun getPages(chapter: MangaChapter): List { + ensureNhConfig() + val id = chapter.url.removeSurrounding("/g/", "/").toInt() + val json = webClient.httpGet("https://$domain/api/v2/galleries/$id").parseJson() + val pagesArray = json.getJSONArray("pages") + return (0 until pagesArray.length()).map { i -> + val pageObj = pagesArray.getJSONObject(i) + val path = pageObj.getString("path") + MangaPage( + id = generateUid(path), + url = "$imageServer/$path", + preview = null, + source = source, ) } } - override suspend fun getFilterOptions(): MangaListFilterOptions { - return MangaListFilterOptions( - availableTags = emptySet(), + override suspend fun getPageUrl(page: MangaPage): String = page.url + override suspend fun getRelatedManga(seed: Manga): List = emptyList() + + private fun JSONObject.toManga(): Manga { + val id = getInt("id") + val titleObj = optJSONObject("title") + val rawTitle = titleObj?.optString("english") + ?: titleObj?.optString("pretty") + ?: optString("english_title") + ?: optString("japanese_title") + ?: "Gallery $id" + + val title = if (displayFullTitle) rawTitle else rawTitle.shortenTitle() + val thumbPath = optJSONObject("thumbnail")?.optString("path") + ?: optString("thumbnail").takeIf { !it.isNullOrBlank() } + ?: "galleries/$id/thumb.webp" + + return Manga( + id = generateUid("/g/$id/"), + title = title, + altTitles = emptySet(), + url = "/g/$id/", + publicUrl = "https://$domain/g/$id/", + rating = RATING_UNKNOWN, + contentRating = ContentRating.ADULT, + coverUrl = "$thumbServer/$thumbPath", + tags = extractTags(), + state = null, + authors = emptySet(), + source = source, ) } - override suspend fun getDetails(manga: Manga): Manga { - val id = manga.url.removeSurrounding("/g/", "/") + private fun JSONObject.toMangaDetails(): Manga { + val id = getInt("id") + val titleObj = optJSONObject("title") + val rawTitle = titleObj?.optString("english") + ?: titleObj?.optString("japanese") + ?: titleObj?.optString("pretty") + ?: "Gallery $id" + val title = if (displayFullTitle) rawTitle else rawTitle.shortenTitle() - val obj = webClient - .httpGet("https://$domain/$apiSuffix/galleries/$id") - .parseJson() + val thumbPath = getJSONObject("thumbnail").getString("path") + val coverPath = getJSONObject("cover").optString("path", thumbPath) + val numPages = optInt("num_pages") + val uploadDate = optLong("upload_date") * 1000 - return manga.copy( - tags = obj.extractTags(), - authors = emptySet(), - description = "Pages: ${obj.optInt("num_pages")}", - coverUrl = "https://t.$domain/${obj.getCoverPath()}", + val tags = extractTags() + val artists = getTagsOfType("artist") + val groups = getTagsOfType("group") + val author = artists.joinToString(", ").ifBlank { groups.joinToString(", ") } + + val description = buildString { + append("Pages: $numPages\n") + } + + return Manga( + id = generateUid("/g/$id/"), + title = title, + altTitles = listOfNotNull(titleObj?.optString("japanese")).toSet(), + url = "/g/$id/", + publicUrl = "https://$domain/g/$id/", + rating = RATING_UNKNOWN, + contentRating = ContentRating.ADULT, + coverUrl = "$thumbServer/$thumbPath", + largeCoverUrl = "$imageServer/$coverPath", + tags = tags, + state = null, + authors = setOfNotNull(author.ifBlank { null }), + description = description, chapters = listOf( MangaChapter( - id = manga.id, - title = manga.title, + id = generateUid("/g/$id/"), + title = title, number = 1f, volume = 0, - url = manga.url, - scanlator = null, - uploadDate = obj.optLong("upload_date") * 1000, + url = "/g/$id/", + scanlator = groups.joinToString(", ").ifBlank { null }, + uploadDate = uploadDate, branch = null, - source = source + source = source, ) - ) + ), + source = source, ) } - override suspend fun getPages(chapter: MangaChapter): List { - val id = chapter.url.removeSurrounding("/g/", "/") - val response = webClient.httpGet("https://$domain/$apiSuffix/galleries/$id").parseJson() - return response.getJSONArray("pages").mapJSON { page -> - val path = page.getString("path") - MangaPage( - id = generateUid(path), - url = "https://i.$domain/$path", - preview = page.optString("thumbnail").takeIf { it.isNotBlank() } - ?.let { "https://t.$domain/$it" }, + + private fun JSONObject.extractTags(): Set { + val tagsArray = optJSONArray("tags") ?: return emptySet() + return (0 until tagsArray.length()).map { i -> + val tag = tagsArray.getJSONObject(i) + val name = tag.getString("name") + MangaTag( + title = name.replace("-", " ").replaceFirstChar { it.uppercase() }, + key = "${tag.getString("type")}:$name", source = source, ) - } + }.toSet() } - override suspend fun getPageUrl(page: MangaPage): String = page.url - - private fun JSONObject.extractTitle(): String { - val titleObj = optJSONObject("title") - return listOfNotNull( - titleObj?.optString("english"), - titleObj?.optString("pretty"), - optString("english_title"), - optString("japanese_title") - ).firstOrNull { it.isNotBlank() } ?: "Gallery ${optInt("id")}" + private fun JSONObject.getTagsOfType(type: String): List { + val tagsArray = optJSONArray("tags") ?: return emptyList() + return (0 until tagsArray.length()).mapNotNull { i -> + val tag = tagsArray.getJSONObject(i) + if (tag.getString("type") == type) tag.getString("name") else null + } } - private fun JSONObject.getThumbnailPath(): String = optJSONObject("thumbnail")?.optString("path") - ?: optString("thumbnail").takeIf { it.isNotBlank() } - ?: "galleries/${optString("media_id")}/thumb.webp" - - private fun JSONObject.getCoverPath(): String = optJSONObject("cover")?.optString("path") ?: getThumbnailPath() + private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""") + private fun String.shortenTitle() = this.replace(shortenTitleRegex, "").trim() - - override fun Locale.toLanguagePath(): String = when (this) { - Locale.ENGLISH -> "english" - Locale.JAPANESE -> "japanese" - Locale.CHINESE -> "chinese" - else -> language - } + override val authUrl: String get() = "https://$domain" + override suspend fun isAuthorized(): Boolean = true + override suspend fun getUsername(): String = "" } From fb3de2ffc68770ac963e3157c2baf679d0e38bf0 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:18:40 +0200 Subject: [PATCH 004/109] Update MangaFire.kt --- .../parsers/site/kotatsu/all/MangaFire.kt | 104 +++++++++++++----- 1 file changed, 75 insertions(+), 29 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt index 0b574224..ad38345e 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt @@ -72,20 +72,59 @@ internal abstract class MangaFireParser( companion object { val GENRE_MAP = mapOf( - "Action" to "1", "Adventure" to "78", "Avant Garde" to "3", - "Boys Love" to "4", "Comedy" to "5", "Demons" to "77", - "Drama" to "6", "Ecchi" to "7", "Fantasy" to "79", - "Girls Love" to "9", "Gourmet" to "10", "Harem" to "11", - "Horror" to "530", "Isekai" to "13", "Iyashikei" to "531", - "Josei" to "15", "Kids" to "532", "Magic" to "539", - "Mahou Shoujo" to "533", "Martial Arts" to "534", - "Mecha" to "19", "Military" to "535", "Music" to "21", - "Mystery" to "22", "Parody" to "23", "Psychological" to "536", - "Reverse Harem" to "25", "Romance" to "26", "School" to "73", - "Sci-Fi" to "28", "Seinen" to "537", "Shoujo" to "30", - "Shounen" to "31", "Slice of Life" to "538", "Space" to "33", - "Sports" to "34", "Super Power" to "75", "Supernatural" to "76", - "Suspense" to "37", "Thriller" to "38", "Vampire" to "39" + "Action" to "1", + "Adult" to "268929", + "Adventure" to "78", + "Avant Garde" to "3", + "Boys Love" to "4", + "Comedy" to "5", + "Crime" to "268921", + "Demons" to "77", + "Drama" to "6", + "Ecchi" to "7", + "Fantasy" to "79", + "Girls Love" to "9", + "Gourmet" to "10", + "Harem" to "11", + "Hentai" to "268930", + "Historical" to "268922", + "Horror" to "530", + "Isekai" to "13", + "Iyashikei" to "531", + "Josei" to "15", + "Kids" to "532", + "Magic" to "539", + "Magical Girls" to "268923", + "Mahou Shoujo" to "533", + "Martial Arts" to "534", + "Mature" to "268931", + "Mecha" to "19", + "Medical" to "268924", + "Military" to "535", + "Music" to "21", + "Mystery" to "22", + "Parody" to "23", + "Philosophical" to "268925", + "Psychological" to "536", + "Reverse Harem" to "25", + "Romance" to "26", + "School" to "73", + "Sci-Fi" to "28", + "Seinen" to "537", + "Shoujo" to "30", + "Shounen" to "31", + "Slice of Life" to "538", + "Smut" to "268932", + "Space" to "33", + "Sports" to "34", + "Super Power" to "75", + "Superhero" to "268926", + "Supernatural" to "76", + "Suspense" to "37", + "Thriller" to "38", + "Tragedy" to "268927", + "Vampire" to "39", + "Wuxia" to "268928" ) } @@ -107,7 +146,6 @@ internal abstract class MangaFireParser( availableDemographics = EnumSet.of(Demographic.SHOUNEN, Demographic.SHOUJO, Demographic.SEINEN, Demographic.JOSEI), ) - // ---------- Listing ---------- override suspend fun getListPage( page: Int, order: SortOrder, @@ -204,9 +242,8 @@ internal abstract class MangaFireParser( return mangas } - // ---------- Details ---------- override suspend fun getDetails(manga: Manga): Manga { - val hid = extractHid(manga.url) // improved extraction + val hid = extractHid(manga.url) val detailsJson = apiClient.httpGet("https://$domain/api/titles/$hid").parseJson() val data = detailsJson.getJSONObject("data") @@ -231,28 +268,42 @@ internal abstract class MangaFireParser( (0 until arr.length()).map { arr.getString(it) } } ?: emptyList() - val description = synopsisHtml?.let { Jsoup.parseBodyFragment(it).text() } + // putting rating into description, adding it via "rating" does not work + val raw = data.optDouble("rating", -1.0) + val ratingValue = if (raw >= 0.0) raw.toFloat() else null + + val synopsisText = synopsisHtml?.let { Jsoup.parseBodyFragment(it).text() } ?: "" + + val richDescription = buildString { + ratingValue?.let { rating -> + val stars = (rating / 2.0).toInt().coerceIn(0, 5) + append("${"★".repeat(stars)}${"☆".repeat(5 - stars)} $rating\n\n") + } + if (synopsisText.isNotBlank()) { + append(synopsisText.trim(), "\n\n") + } + if (altTitlesArray.isNotEmpty()) { + append("Alternative Names:\n") + altTitlesArray.forEach { append("- ", it, "\n") } + } + }.trim() val genreList = buildList { type?.let { add(it.replaceFirstChar { c -> c.uppercase() }) } genres?.let { addAll(it) } themes?.let { addAll(it) } } - val genreTags = genreList.mapNotNull { name -> tags.find { it.title == name } }.toSet() -// val raw = data.optDouble("rating", -1.0) -// val ratingValue = if (raw >= 0.0) (raw / 2.0).toFloat() else throw Exception("Invalid rating value: $raw") - val chapters = fetchChapters(hid) return manga.copy( title = title, coverUrl = cover ?: manga.coverUrl, authors = setOfNotNull(authors), - description = description, + description = richDescription, state = when (status?.lowercase()) { "releasing" -> MangaState.ONGOING "finished" -> MangaState.FINISHED @@ -263,12 +314,10 @@ internal abstract class MangaFireParser( }, tags = genreTags, altTitles = altTitlesArray.toSet(), -// rating = ratingValue, chapters = chapters, ) } - // ---------- Chapters ---------- private suspend fun fetchChapters(hid: String): List { val chapters = mutableListOf() var page = 1 @@ -289,7 +338,7 @@ internal abstract class MangaFireParser( val number = chObj.getDouble("number").toFloat() val name = chObj.optString("name", null) val createdAt = chObj.optLong("createdAt", 0L) * 1000L - val type = chObj.getString("type") // "official" or "unofficial" + val type = chObj.getString("type") // "official" or "unofficial" or others val chapterUrl = "/title/$hid/$id" val displayName = buildString { append("Ch. ") @@ -331,7 +380,6 @@ internal abstract class MangaFireParser( .sortedBy { it.number } } - // ---------- Pages ---------- override suspend fun getPages(chapter: MangaChapter): List { val chapterId = chapter.url.substringAfterLast("/") // numeric ID val response = apiClient.httpGet("https://$domain/api/chapters/$chapterId").parseJson() @@ -358,7 +406,6 @@ internal abstract class MangaFireParser( override suspend fun isAuthorized(): Boolean = true override suspend fun getUsername(): String = "" - // ---------- Utility ---------- private fun extractHid(url: String): String { val lastPart = url.removeSuffix("/").substringAfterLast("/") @@ -369,7 +416,6 @@ internal abstract class MangaFireParser( } } - // ---------- Sources ---------- @MangaSourceParser("MANGAFIRE_EN", "MangaFire (English)", "en") class English(context: MangaLoaderContext) : MangaFireParser(context, MangaParserSource.MANGAFIRE_EN, "en") From 8fead773ff41eabcedeab50024c4ad32e47049c8 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:26:39 +0200 Subject: [PATCH 005/109] Update NHentaiParser.kt --- .../site/kotatsu/all/hentais/NHentaiParser.kt | 31 +------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt index 63a5f821..bd66bb69 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.all.hentais import org.json.JSONObject -import org.jsoup.Jsoup import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaSourceParser @@ -35,9 +34,6 @@ internal class NHentaiParser(context: MangaLoaderContext) : override val filterCapabilities = MangaListFilterCapabilities( isSearchSupported = true, - isSearchWithFiltersSupported = true, - isMultipleTagsSupported = true, - isAuthorSearchSupported = true, ) private val preferredServerKey = ConfigKey.PreferredImageServer( @@ -68,11 +64,8 @@ internal class NHentaiParser(context: MangaLoaderContext) : ) } - // Cached popular tags - private var popularTags: Set? = null - override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = getPopularTags(), + availableTags = emptySet(), availableLocales = setOf( Locale.ENGLISH, Locale.JAPANESE, @@ -80,28 +73,6 @@ internal class NHentaiParser(context: MangaLoaderContext) : ), ) - private suspend fun getPopularTags(): Set { - popularTags?.let { return it } - return try { - val doc = Jsoup.parse( - webClient.httpGet("https://$domain/tags?sort=popular").parseHtml().toString() - ) - val tags = doc.select("section#tag-container a.tag") - .mapNotNull { element -> - val name = element.text() - if (name.isBlank()) null - else MangaTag( - title = name.replace("-", " ").replaceFirstChar { it.uppercase() }, - key = name, // raw tag name, will be used as tag:"name" - source = source, - ) - }.toSet() - popularTags = tags - tags - } catch (_: Exception) { - emptySet() - } - } private suspend fun ensureNhConfig() { if (imageServer.isBlank() || thumbServer.isBlank()) { From e184ddc6628e9ebad2f16b2ca5a7ba75daabb101 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:58:49 +0200 Subject: [PATCH 006/109] Refactor Mangadotnet parser for better genre and tag management Refactor Mangadotnet parser to improve genre and tag handling. Update filter capabilities and sorting options. --- .../parsers/site/kotatsu/en/Mangadotnet.kt | 452 +++++++++--------- 1 file changed, 225 insertions(+), 227 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt index 592dd4ce..3f611ab9 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt @@ -17,77 +17,72 @@ internal class Mangadotnet(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.MANGADOTNET, 20) { override val configKeyDomain = ConfigKey.Domain("mangadot.net") - private val baseUrl = "https://mangadot.net" - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isSearchSupported = true, - ) - - override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.NEWEST, - SortOrder.ALPHABETICAL, - ) - - override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = cachedGenres.mapTo(LinkedHashSet(cachedGenres.size)) { MangaTag(key = it, title = it, source = source) }, - ) - - private var cachedGenres: List = emptyList() + private var cachedGenres: List? = null + private var cachedTags: List? = null + private val demographicNames = setOf("Josei", "Seinen", "Shoujo", "Shounen") + private val maxGenreTags = 200 + private val maxTopicTags = 200 + + private fun updateGenres(newGenres: List) { + if (newGenres.isEmpty()) return + cachedGenres = newGenres.filter { it !in demographicNames } + .sortedBy { it.lowercase() } + .take(maxGenreTags) + } - private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT).apply { - timeZone = TimeZone.getTimeZone("UTC") + @Suppress("UNCHECKED_CAST") + private fun updateTags(categories: List>?) { + if (categories.isNullOrEmpty()) return + val newTags = categories.asSequence().flatMap { category -> + val tagsList = category["tags"] as? List> ?: emptyList() + tagsList.mapNotNull { it["name"] as? String } + } + .filter { it.isNotBlank() } + .distinct() + .sortedBy { it.lowercase() } + .take(maxTopicTags) + .toList() + + val current = cachedTags ?: emptyList() + val combined = (current + newTags).distinct() + .sortedBy { it.lowercase() } + .take(maxTopicTags) + cachedTags = combined } - // region RSC decoder + private fun getGenres(): List = cachedGenres ?: emptyList() + private fun getTags(): List = cachedTags ?: emptyList() + // RSC decoder from clarity private fun decodeRsc(flat: JSONArray): Any? { val cache = arrayOfNulls(flat.length()) - val nil = Object() - + val nil = Any() fun resolve(i: Int): Any? { if (i < 0 || i >= flat.length()) return null val cached = cache[i] if (cached != null) return if (cached === nil) null else cached - val result: Any? = when (val el = flat.opt(i)) { JSONObject.NULL -> null is String, is Number, is Boolean -> el - is JSONArray -> { - (0 until el.length()).mapTo(mutableListOf()) { j -> - resolve(el.optInt(j, -1)) - } - } + is JSONArray -> (0 until el.length()).mapTo(mutableListOf()) { j -> resolve(el.optInt(j, -1)) } is JSONObject -> { val map = mutableMapOf() for (key in el.keys()) { - val actualKey = if (key.startsWith("_")) { - flat.optString(key.substring(1).toInt(), key) - } else { - key - } + val actualKey = if (key.startsWith("_")) flat.optString(key.removePrefix("_").toInt(), key) else key map[actualKey] = resolve(el.optInt(key, -1)) } map } else -> null } - cache[i] = result ?: nil return result } - return resolve(0) } - /** - * Fetch an RSC-encoded URL and extract the given route. - * Route values are wrapped in `{"data": ...}` (Data). - * Returns the inner `data` value of the route. - */ @Suppress("UNCHECKED_CAST") private suspend fun fetchRscData(url: String, route: String): Map? { val flat = webClient.httpGet(url).parseJsonArray() @@ -96,101 +91,147 @@ internal class Mangadotnet(context: MangaLoaderContext) : return routeValue.asMap("data") } - // endregion + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = true, + isSearchWithFiltersSupported = true, + ) - // region Listing + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.RATING, + SortOrder.ALPHABETICAL, + ) - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val query = filter.query - if (!query.isNullOrEmpty()) { - return getSearchPage(page, query, order) - } - return when (order) { - SortOrder.POPULARITY -> getPopularPage(page) - else -> getLatestPage(page) + override suspend fun getFilterOptions(): MangaListFilterOptions { + // On first open, load the genre list from a browse page so filters are populated + if (cachedGenres == null && cachedTags == null) { + try { + val initUrl = "$baseUrl/view-all/latest-updates.data".toHttpUrl().newBuilder() + .addQueryParameter("adult", "0") + .addQueryParameter("page", "1") + .addQueryParameter("_routes", "pages/ViewAllPage") + .build().toString() + parseViewAllPage(initUrl) // this updates cachedGenres + } catch (_: Exception) { + // ignore + } } + val genres = getGenres() + val tags = getTags() + val availableTags = mutableSetOf() + genres.forEach { g -> availableTags.add(MangaTag(g, g, source)) } + tags.forEach { t -> availableTags.add(MangaTag(t, "tag:$t", source)) } + return MangaListFilterOptions( + availableTags = availableTags, + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED), + availableContentTypes = setOf(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER), + availableDemographics = setOf(Demographic.JOSEI, Demographic.SEINEN, Demographic.SHOUJO, Demographic.SHOUNEN), + ) } - private suspend fun getPopularPage(page: Int): List { - val url = "$baseUrl/view-all/trending.data".toHttpUrl().newBuilder().apply { - if (page > 1) addQueryParameter("page", page.toString()) - addQueryParameter("_routes", "pages/ViewAllPage") - }.build().toString() - return parseViewAllPage(url) + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + if (!filter.query.isNullOrBlank() || filter.tags.isNotEmpty() || filter.tagsExclude.isNotEmpty() || + filter.states.isNotEmpty() || filter.types.isNotEmpty() || filter.demographics.isNotEmpty()) { + return getSearchPage(page, order, filter) + } + return getBrowsePage(page, order) } - private suspend fun getLatestPage(page: Int): List { + private suspend fun getBrowsePage(page: Int, order: SortOrder): List { + val sortParam = when (order) { + SortOrder.POPULARITY -> "views" + SortOrder.RATING -> "rating" + SortOrder.ALPHABETICAL -> "alphabetical" + else -> null + } val url = "$baseUrl/view-all/latest-updates.data".toHttpUrl().newBuilder().apply { + addQueryParameter("adult", "0") if (page > 1) addQueryParameter("page", page.toString()) + sortParam?.let { addQueryParameter("sortBy", it) } addQueryParameter("_routes", "pages/ViewAllPage") }.build().toString() return parseViewAllPage(url) } - /** - * ViewAllPage route structure: - * route_value = { "data": ViewAllData } - * ViewAllData = { "data": MangaList, "allGenres": [...] } - * MangaList = { "manga_list"|"results": [...], "pagination": {...} } - * - * After fetchRscData: we get ViewAllData. - */ @Suppress("UNCHECKED_CAST") - private suspend fun parseViewAllPage(url: String): List { - val viewAllData = fetchRscData(url, "pages/ViewAllPage") ?: return emptyList() + private suspend fun getSearchPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val url = "$baseUrl/search.data".toHttpUrl().newBuilder().apply { + addQueryParameter("adult", "0") + if (!filter.query.isNullOrBlank()) addQueryParameter("search", filter.query!!) + addQueryParameter("page", page.toString()) + addQueryParameter("perPage", "56") + + val (sortBy, sortOrder) = when (order) { + SortOrder.UPDATED -> "latest" to "desc" + SortOrder.ALPHABETICAL -> "alphabetical" to "asc" + SortOrder.POPULARITY -> "views" to "desc" + SortOrder.RATING -> "rating" to "desc" + else -> "relevance" to "desc" + } + addQueryParameter("sortBy", sortBy) + addQueryParameter("sortOrder", sortOrder) - // Update genres - (viewAllData["allGenres"] as? List<*>)?.filterIsInstance()?.let { - if (it.isNotEmpty()) cachedGenres = it - } + if (filter.states.size == 1) { + addQueryParameter("status", filter.states.first().toApiStatus()) + } - // Navigate to MangaList: viewAllData.data - val mangaListMap = viewAllData.asMap("data") ?: return emptyList() - return parseMangaList(mangaListMap) - } + filter.types.forEach { ct -> + when (ct) { + ContentType.MANGA -> addQueryParameter("origin", "JP") + ContentType.MANHWA -> addQueryParameter("origin", "KR") + ContentType.MANHUA -> addQueryParameter("origin", "CN") + ContentType.OTHER -> addQueryParameter("origin", "ONESHOT") + else -> {} + } + } + + filter.demographics.forEach { demographic -> + addQueryParameter("genre", demographic.name.lowercase().replaceFirstChar { it.uppercase() }) + } + + filter.tags.forEach { tag -> + if (tag.key.startsWith("tag:")) { + addQueryParameter("tag", tag.key.removePrefix("tag:")) + } else { + addQueryParameter("genre", tag.key) + } + } + filter.tagsExclude.forEach { tag -> + if (tag.key.startsWith("tag:")) { + addQueryParameter("tag", "-${tag.key.removePrefix("tag:")}") + } else { + addQueryParameter("genre", "-${tag.key}") + } + } - private suspend fun getSearchPage(page: Int, query: String, order: SortOrder): List { - val url = "$baseUrl/search.data".toHttpUrl().newBuilder().apply { - addQueryParameter("search", query) - addQueryParameter("page", page.toString()) - addQueryParameter("sortBy", when (order) { - SortOrder.NEWEST -> "latest" - SortOrder.ALPHABETICAL -> "Alphabetical" - else -> "relevance" - }) addQueryParameter("_routes", "pages/SearchPage") }.build().toString() - /** - * SearchPage route structure: - * route_value = { "data": MangaList } - * MangaList = { "manga_list"|"results": [...], "pagination": {...}, "allGenres": [...] } - * - * After fetchRscData: we get MangaList directly. - */ - @Suppress("UNCHECKED_CAST") - val mangaListMap = fetchRscData(url, "pages/SearchPage") ?: return emptyList() - - (mangaListMap["allGenres"] as? List<*>)?.filterIsInstance()?.let { - if (it.isNotEmpty()) cachedGenres = it - } + val dataMap = fetchRscData(url, "pages/SearchPage") ?: return emptyList() + (dataMap["allGenres"] as? List<*>)?.filterIsInstance()?.let { updateGenres(it) } + (dataMap["allTags"] as? List>?)?.let { updateTags(it) } + + return parseMangaList(dataMap) + } + private suspend fun parseViewAllPage(url: String): List { + val viewAllData = fetchRscData(url, "pages/ViewAllPage") ?: return emptyList() + (viewAllData["allGenres"] as? List<*>)?.filterIsInstance()?.let { updateGenres(it) } + val mangaListMap = viewAllData.asMap("data") ?: return emptyList() return parseMangaList(mangaListMap) } - @Suppress("UNCHECKED_CAST") - private fun parseMangaList(mangaListMap: Map): List { - val mangaList = mangaListMap["manga_list"] as? List<*> - ?: mangaListMap["results"] as? List<*> - ?: return emptyList() - return mangaList.filterIsInstance>().map { parseMangaFromList(it) } + private fun parseMangaList(data: Map): List { + val list = data["manga_list"] as? List<*> ?: data["results"] as? List<*> ?: return emptyList() + return list.filterIsInstance>().map { parseMangaFromList(it) } } private fun parseMangaFromList(data: Map): Manga { val id = (data["id"] as? Number)?.toInt()?.toString() ?: "" val title = data["title"] as? String ?: "" val photo = data["photo"] as? String - val coverUrl = photo?.let { when { it.startsWith("/") -> "$baseUrl$it" @@ -198,7 +239,6 @@ internal class Mangadotnet(context: MangaLoaderContext) : else -> null } } - return Manga( id = generateUid(id), url = id, @@ -215,26 +255,11 @@ internal class Mangadotnet(context: MangaLoaderContext) : ) } - // endregion - - // region Details - - /** - * MangaDetailPage route structure: - * route_value = { "data": MangaData } - * MangaData = { "mangaData": { "manga": Manga } } - * - * After fetchRscData: we get MangaData. - */ - @Suppress("UNCHECKED_CAST") override suspend fun getDetails(manga: Manga): Manga { val url = "$baseUrl/manga/${manga.url}.data?_routes=pages/MangaDetailPage" val mangaData = fetchRscData(url, "pages/MangaDetailPage") ?: return manga - val mangaInfo = mangaData - .asMap("mangaData")?.asMap("manga") - ?: return manga - + val mangaInfo = mangaData.asMap("mangaData")?.asMap("manga") ?: return manga val title = mangaInfo["title"] as? String ?: manga.title val description = mangaInfo["description"] as? String val photo = mangaInfo["photo"] as? String @@ -247,15 +272,16 @@ internal class Mangadotnet(context: MangaLoaderContext) : } ?: manga.coverUrl val hiatus = mangaInfo["hiatus"] as? String val status = mangaInfo["status"] as? String - val genres = (mangaInfo["genres"] as? List<*>)?.filterIsInstance() - ?.mapNotNull { it.trim().ifBlank { null } }.orEmpty() - val altTitles = (mangaInfo["alt_titles"] as? List<*>)?.filterIsInstance() - ?.mapNotNull { it.trim().ifBlank { null } }?.toSet().orEmpty() + val genres = (mangaInfo["genres"] as? List<*>)?.filterIsInstance()?.mapNotNull { it.trim().ifBlank { null } }.orEmpty() + val altTitles = (mangaInfo["alt_titles"] as? List<*>)?.filterIsInstance()?.mapNotNull { it.trim().ifBlank { null } }?.toSet().orEmpty() val origin = mangaInfo["country_of_origin"] as? String val ratingValue = (mangaInfo["avg_rating"] as? Number)?.toFloat() - val anilistId = (mangaInfo["anilist_id"] as? Number)?.toLong() - val sourceUrl = mangaInfo["source_url"] as? String val author = mangaInfo["author"] as? String + val year = (mangaInfo["year"] as? Number)?.toInt() + val chapterCount = (mangaInfo["chapter_count"] as? Number)?.toInt() + val trackedCount = (mangaInfo["tracked_count"] as? Number)?.toInt() + val contentRating = mangaInfo["content_rating"] as? String + val ratingCount = (mangaInfo["rating_count"] as? Number)?.toInt() val state = when { "One Shot" in genres -> MangaState.FINISHED @@ -267,39 +293,33 @@ internal class Mangadotnet(context: MangaLoaderContext) : } } - val tags = genres.mapTo(LinkedHashSet(genres.size)) { MangaTag(key = it, title = it, source = source) } - val contentType = when (origin) { - "JP" -> "Manga" - "KR" -> "Manhwa" - "CN" -> "Manhua" - else -> null + val tagSet = genres.mapTo(LinkedHashSet(genres.size)) { MangaTag(key = it, title = it, source = source) } + when (origin) { + "JP" -> tagSet.add(MangaTag("Manga", "Manga", source)) + "KR" -> tagSet.add(MangaTag("Manhwa", "Manhwa", source)) + "CN" -> tagSet.add(MangaTag("Manhua", "Manhua", source)) } - val contentTag = contentType?.let { MangaTag(key = it, title = it, source = source) } - val allTags = if (contentTag != null && contentTag !in tags) tags + contentTag else tags val richDescription = buildString { ratingValue?.let { rating -> val stars = (rating / 2).toInt().coerceIn(0, 5) append("${"★".repeat(stars)}${"☆".repeat(5 - stars)} $rating\n\n") } - description?.let { - append( - it.replace("\r\n", "\n") - .replace(Regex("\n{3,}"), "\n\n") - .trim(), - "\n\n", - ) + val metaInfo = buildList { + year?.let { add("**Year:** $it") } + chapterCount?.let { add("**Chapters:** $it") } + trackedCount?.let { add("**Tracked:** $it") } + contentRating?.let { add("**Content Rating:** ${it.replaceFirstChar { c -> c.uppercase() }}") } + ratingCount?.takeIf { it > 0 }?.let { add("$it ratings") } } - val links = buildList { - anilistId?.let { add("[AniList](https://anilist.co/manga/$it)") } - sourceUrl?.let { add("[Source]($it)") } + if (metaInfo.isNotEmpty()) { + append(metaInfo.joinToString(" · "), "\n\n") } - if (links.isNotEmpty()) { - append("\nLinks:\n") - links.forEach { append("- ", it, "\n") } + description?.let { + append(it.replace("\r\n", "\n").replace(Regex("\n{3,}"), "\n\n").trim(), "\n\n") } if (altTitles.isNotEmpty()) { - append("\nAlternative Names:\n") + append("\n**Alternative Names:**\n") altTitles.forEach { append("- ", it, "\n") } } }.trim() @@ -311,56 +331,40 @@ internal class Mangadotnet(context: MangaLoaderContext) : coverUrl = coverUrl, description = richDescription, altTitles = altTitles, - tags = allTags, + tags = tagSet, state = state, authors = setOfNotNull(author).filterTo(mutableSetOf()) { it.isNotBlank() }, chapters = chapters, ) } - // endregion - - // region Chapters - - @Suppress("UNCHECKED_CAST") private suspend fun fetchChapters(mangaId: String): List { val response = webClient.httpGet("$baseUrl/api/manga/$mangaId/chapters/list?lang=en").parseJsonArray() - - val allChapters = (0 until response.length()).map { i -> - response.getJSONObject(i) - } + val allChapters = (0 until response.length()).map { response.getJSONObject(it) } val chaptersByTeam = mutableMapOf>() - for (chapter in allChapters) { - val group = chapter.optString("group_name", "").nullIfEmpty() - val scanlator = chapter.optString("scanlator_name", "").nullIfEmpty() - val teamName = group ?: scanlator ?: "Unknown" - chaptersByTeam.getOrPut(teamName) { mutableListOf() }.add(chapter) + for (ch in allChapters) { + val team = listOf( + ch.optString("group_name", ""), + ch.optString("scanlator_name", "") + ).firstOrNull { it.isNotBlank() && !it.equals("null", ignoreCase = true) } + ?: "Unknown" + chaptersByTeam.getOrPut(team) { mutableListOf() }.add(ch) } - val allChapterNumbers = allChapters.map { it.optDouble("chapter_number", 0.0).toFloat() }.toSet().sorted() - - val chaptersBuilder = ChaptersListBuilder(allChapters.size * chaptersByTeam.size) - - for ((teamName, teamChapters) in chaptersByTeam) { - val teamChapterMap = teamChapters.associateBy { it.optDouble("chapter_number", 0.0).toFloat() } - - for (chapterNumber in allChapterNumbers) { - val chapterData = teamChapterMap[chapterNumber] - ?: allChapters.find { it.optDouble("chapter_number", 0.0).toFloat() == chapterNumber } - ?: continue - - val chapterId = chapterData.getString("id") - val chapterSource = chapterData.optString("source", "scraper") - val number = chapterData.optDouble("chapter_number", 0.0).toFloat() - val name = chapterData.optString("chapter_title", "").nullIfEmpty() - val group = chapterData.optString("group_name", "").nullIfEmpty() - val scanlator = chapterData.optString("scanlator_name", "").nullIfEmpty() - val actualTeamName = group ?: scanlator ?: "Unknown" - val date = chapterData.optString("date_added", "").nullIfEmpty() - + val allNumbers = allChapters.map { it.optDouble("chapter_number", 0.0).toFloat() }.distinct().sorted() + val builder = ChaptersListBuilder(allChapters.size * chaptersByTeam.size) + + for ((team, teamChapters) in chaptersByTeam) { + val mapByNum = teamChapters.associateBy { it.optDouble("chapter_number", 0.0).toFloat() } + for (num in allNumbers) { + val chapter = mapByNum[num] ?: continue + val chId = chapter.getString("id") + val src = chapter.optString("source", "user") + val name = chapter.optString("chapter_title", "").nullIfEmpty() + val date = chapter.optString("date_added", "").nullIfEmpty() val title = buildString { - val numStr = number.toString().substringBefore(".0") + val numStr = num.toString().removeSuffix(".0") if (name != null && !name.contains(numStr)) { append("Chapter $numStr: ") } else if (name == null) { @@ -368,51 +372,38 @@ internal class Mangadotnet(context: MangaLoaderContext) : } name?.let { append(it.trim()) } } - - val chapter = MangaChapter( - id = generateUid("$teamName-$chapterId"), - title = title, - number = number, - volume = 0, - url = JSONObject().apply { - put("id", chapterId) - put("source", chapterSource) - }.toString(), - uploadDate = date?.let { dateFormat.parseSafe(it) } ?: 0L, - source = source, - scanlator = actualTeamName, - branch = teamName, + val chapterUrl = JSONObject().apply { + put("id", chId) + put("source", src) + }.toString() + + builder.add( + MangaChapter( + id = generateUid("$team-$chId"), + title = title, + number = num, + volume = 0, + url = chapterUrl, + uploadDate = date?.let { dateFormat.parseSafe(it) } ?: 0L, + source = source, + scanlator = team, + branch = team, + ) ) - - chaptersBuilder.add(chapter) } } - return chaptersBuilder.toList() + return builder.toList() } - // endregion - - // region Pages - - override suspend fun getRelatedManga(seed: Manga): List = emptyList() - override suspend fun getPages(chapter: MangaChapter): List { val chapterData = JSONObject(chapter.url) val chapterId = chapterData.getString("id") - val chapterSource = chapterData.optString("source", "scraper") - - val segment = if (chapterSource == "user") "uploads" else "chapters" + val source = chapterData.optString("source", "user") + val segment = if (source == "user") "uploads" else "chapters" val response = webClient.httpGet("$baseUrl/api/$segment/$chapterId/images").parseJson() - - // Response can be wrapped in {"data": {"manga": {"id": ...}, "images": [...]}} or flat {"images": [...]} val data = response.optJSONObject("data") - val imagesArray = if (data != null && data.has("images")) { - data.getJSONArray("images") - } else { - response.getJSONArray("images") - } - + val imagesArray = if (data != null && data.has("images")) data.getJSONArray("images") else response.getJSONArray("images") return (0 until imagesArray.length()).mapNotNull { i -> val img = imagesArray.getJSONObject(i) val imgUrl = img.optString("url", "").nullIfEmpty() ?: return@mapNotNull null @@ -425,17 +416,24 @@ internal class Mangadotnet(context: MangaLoaderContext) : id = generateUid(fullUrl), url = fullUrl, preview = null, - source = source, + source = this.source, ) } } - // endregion + override suspend fun getRelatedManga(seed: Manga): List = emptyList() - // region Helpers + private fun MangaState.toApiStatus() = when (this) { + MangaState.ONGOING -> "Ongoing" + MangaState.FINISHED -> "Completed" + MangaState.PAUSED -> "Hiatus" + else -> "" + } + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT).apply { + timeZone = TimeZone.getTimeZone("UTC") + } @Suppress("UNCHECKED_CAST") private fun Map.asMap(key: String): Map? = this[key] as? Map - - // endregion -} \ No newline at end of file +} From 61ea3f16a64973ae9bc9dff314e81d1e0d176859 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:14:02 +0200 Subject: [PATCH 007/109] Enhance NHentaiParser with filter capabilities Added support for search with filters in NHentaiParser. --- .../parsers/site/kotatsu/all/hentais/NHentaiParser.kt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt index bd66bb69..c0841369 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt @@ -34,6 +34,7 @@ internal class NHentaiParser(context: MangaLoaderContext) : override val filterCapabilities = MangaListFilterCapabilities( isSearchSupported = true, + isSearchWithFiltersSupported = true, ) private val preferredServerKey = ConfigKey.PreferredImageServer( @@ -52,7 +53,6 @@ internal class NHentaiParser(context: MangaLoaderContext) : keys.add(preferredServerKey) } - // Apply selected server – used later for image requests private val selectedServer: String get() = config[preferredServerKey].toString() @@ -64,6 +64,7 @@ internal class NHentaiParser(context: MangaLoaderContext) : ) } + override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = emptySet(), availableLocales = setOf( @@ -91,7 +92,6 @@ internal class NHentaiParser(context: MangaLoaderContext) : } } - override suspend fun getListPage( page: Int, order: SortOrder, @@ -99,7 +99,6 @@ internal class NHentaiParser(context: MangaLoaderContext) : ): List { ensureNhConfig() - // Language filter val langQuery = filter.locale?.let { loc -> val langKey = loc.language.lowercase() LANG_MAP[langKey]?.let { "language:$it" } @@ -126,11 +125,6 @@ internal class NHentaiParser(context: MangaLoaderContext) : if (!filter.query.isNullOrBlank()) queryParts.add(filter.query!!) if (langQuery != null) queryParts.add(langQuery) - // Add selected tags as tag:"name" - filter.tags.forEach { tag -> - queryParts.add("tag:\"${tag.key}\"") - } - val finalQuery = queryParts.joinToString(" ").ifBlank { "\"\"" } val sort = when (order) { SortOrder.UPDATED -> "date" From 8ac01a2fbc859bdec5d3f048647b1ba2800e8f8f Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:45:16 +0200 Subject: [PATCH 008/109] Update Atsumaru.kt --- .../parsers/site/kotatsu/en/Atsumaru.kt | 691 +++++++++--------- 1 file changed, 349 insertions(+), 342 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt index 1c526f8d..1a240f7d 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt @@ -1,6 +1,8 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.en +import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl +import org.json.JSONArray import org.json.JSONObject import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser @@ -8,166 +10,399 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.* -import java.text.SimpleDateFormat import java.util.* -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit @MangaSourceParser("ATSUMARU", "Atsumaru", "en") internal class Atsumaru(context: MangaLoaderContext) : - PagedMangaParser(context, MangaParserSource.ATSUMARU, pageSize = 24) { + PagedMangaParser(context, MangaParserSource.ATSUMARU, pageSize = 40) { override val configKeyDomain = ConfigKey.Domain("atsu.moe") - private val apiUrl = "https://$domain/api/" + private val baseUrl = "https://atsu.moe" - private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH).apply { - timeZone = TimeZone.getTimeZone("UTC") + private val apiHeaders: Headers by lazy { + Headers.Builder() + .add("Accept", "*/*") + .add("Referer", baseUrl) + .add("Content-Type", "application/json") + .build() } override val availableSortOrders: Set = EnumSet.of( - SortOrder.POPULARITY, - SortOrder.RATING, + SortOrder.POPULARITY, // trending (views) + SortOrder.UPDATED, // recently added (dateAdded) + SortOrder.ADDED, // recently new added mangas + SortOrder.RELEVANCE // chapters count ) - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isSearchSupported = true - ) + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + ) + + private val allGenres = listOf( + Genre("Action", "39"), Genre("Adult", "46"), Genre("Adventure", "37"), + Genre("Boys Love", "180"), Genre("Comedy", "6"), Genre("Drama", "31"), + Genre("Fantasy", "36"), Genre("Girls Love", "4"), Genre("Hentai", "10"), + Genre("Historical", "45"), Genre("Horror", "44"), Genre("Martial Arts", "29"), + Genre("Mystery", "32"), Genre("Psychological", "18"), Genre("Romance", "9"), + Genre("Sci-Fi", "1"), Genre("Slice of Life", "7"), Genre("Smut", "41"), + Genre("Supernatural", "22"), Genre("Thriller", "19"), Genre("Tragedy", "5") + ) + + private val allTags = listOf( + Tag("Blackmail", "285"), Tag("Cooking", "669"), Tag("Crimes", "288"), + Tag("Crossdressing", "167"), Tag("Murder", "250"), Tag("Prostitution", "366"), + Tag("Swordplay", "337"), Tag("Working", "248"), + Tag("Josei", "43"), Tag("Seinen", "8"), Tag("Shoujo", "40"), Tag("Shounen", "38"), + Tag("Otaku", "264"), Tag("Tsundere", "313"), Tag("Yandere", "315"), + Tag("Animal Characteristics", "274"), Tag("Beautiful Female Lead", "72"), + Tag("Big Breasts", "123"), Tag("Flat Chest", "320"), Tag("Glasses-Wearing Male Lead", "71"), + Tag("Handsome Male Lead", "68"), Tag("Kemonomimi", "279"), Tag("MILF", "339"), + Tag("Small Breasts", "124"), Tag("Young Male Lead", "787"), + Tag("Adult Cast", "159"), Tag("Bisexual", "382"), Tag("Ensemble Cast", "362"), + Tag("Female Lead", "59"), Tag("Male Lead", "58"), Tag("Non-Human Protagonist", "247"), + Tag("Primarily Adult Cast", "158"), Tag("Primarily Female Cast", "333"), + Tag("Primarily Male Cast", "335"), Tag("Primarily Teen Cast", "334"), + Tag("Strong Female Lead", "69"), Tag("Strong Male Lead", "67"), + Tag("Adapted to Anime", "166"), Tag("Based on a Light Novel", "76"), + Tag("Based on a Novel", "75"), Tag("Based on a Video Game", "77"), + Tag("Based on a Web Novel", "74"), Tag("College", "257"), Tag("Company", "1205"), + Tag("Countryside", "415"), Tag("Europe", "405"), Tag("Foreign", "336"), + Tag("High School", "162"), Tag("Hospital", "760"), Tag("Japan", "225"), + Tag("School", "107"), Tag("School Clubs", "356"), + Tag("Amnesia", "283"), Tag("Appearance Different from Personality", "651"), + Tag("Caught in the Act", "874"), Tag("Dead Family Member", "831"), + Tag("Family Drama", "848"), Tag("Flashbacks", "449"), Tag("Gender Bender", "12"), + Tag("Love Triangle", "125"), Tag("Male Lead Falls in Love First", "653"), + Tag("Misunderstandings", "647"), Tag("Past Plays a Big Role", "648"), + Tag("Reincarnation", "126"), Tag("Secret Identity", "260"), + Tag("Time Manipulation", "311"), Tag("Time Skip", "172"), Tag("Time Travel", "249"), + Tag("Tragic Past", "898"), Tag("Weak to Strong", "1064"), + Tag("Delinquents", "239"), Tag("Detectives", "240"), Tag("Idols", "281"), + Tag("Maids", "116"), Tag("Office Lady", "312"), Tag("Office Worker", "429"), + Tag("School Girl", "788"), Tag("Teachers", "175"), + Tag("Age Gap", "106"), Tag("Childhood Friends", "97"), Tag("Coworkers", "286"), + Tag("Female Harem", "163"), Tag("Friends to Lovers", "243"), Tag("Friendship", "242"), + Tag("Harem", "20"), Tag("Heterosexual", "108"), Tag("Incest", "174"), + Tag("Infidelity", "231"), Tag("Interspecies Relationship", "308"), + Tag("Love-Hate Relationship", "889"), Tag("Master-Servant Relationship", "406"), + Tag("Older Female Younger Male", "114"), Tag("Older Male Younger Female", "649"), + Tag("Older Uke Younger Seme", "880"), Tag("Siblings", "254"), + Tag("Student-Student Relationship", "573"), Tag("Student-Teacher Relationship", "177"), + Tag("Twins", "253"), + Tag("Chinese Ambience", "588"), Tag("European Ambience", "450"), + Tag("Fantasy World", "642"), Tag("Feudal Japan", "606"), Tag("Game Elements", "399"), + Tag("Game World", "641"), Tag("Isekai", "94"), Tag("Isekaied Into a Novel", "258"), + Tag("Mecha", "11"), Tag("Mythology", "259"), Tag("Urban", "338"), + Tag("Urban Fantasy", "261"), + Tag("Anal Intercourse", "100"), Tag("Bondage", "280"), Tag("Boobjob", "381"), + Tag("Borderline H", "448"), Tag("Cunnilingus", "171"), Tag("Defloration", "306"), + Tag("Dubious Consent", "985"), Tag("Ecchi", "21"), Tag("Erotica", "14"), + Tag("Exhibitionism", "287"), Tag("Group Intercourse", "373"), + Tag("Handjob", "303"), Tag("Lolicon", "28"), Tag("Masturbation", "161"), + Tag("Mature", "15"), Tag("Nakadashi", "169"), Tag("Netorare", "232"), + Tag("Nudity", "109"), Tag("Oral Intercourse", "99"), Tag("Outdoor Intercourse", "307"), + Tag("Public Intercourse", "103"), Tag("Rape", "95"), Tag("Sex Addict", "650"), + Tag("Sex Toys", "289"), Tag("Shotacon", "35"), Tag("Teens Love", "374"), + Tag("Threesome", "173"), Tag("Virginity", "369"), + Tag("Animals", "278"), Tag("Cats", "284"), Tag("Demons", "160"), + Tag("Ghosts", "229"), Tag("Gods", "176"), Tag("Monsters", "395"), + Tag("Non-human", "547"), Tag("Vampires", "252"), + Tag("21st century", "132"), Tag("Betrayal", "403"), Tag("Bullying", "235"), + Tag("Cohabitation", "228"), Tag("Coming of Age", "117"), Tag("Danmei", "305"), + Tag("Depression", "1090"), Tag("Family Life", "282"), Tag("Female Empowerment", "1816"), + Tag("Forbidden Love", "699"), Tag("Gore", "262"), Tag("Gourmet", "2"), + Tag("Harlequin", "304"), Tag("Jealousy", "881"), Tag("LGBTQ+", "326"), + Tag("Love Confession", "882"), Tag("Marriage", "360"), Tag("Mature Romance", "241"), + Tag("Medical", "350"), Tag("Military", "230"), Tag("Music", "27"), + Tag("Nobility", "127"), Tag("Obsessive Love", "893"), Tag("Orphans", "237"), + Tag("Religion", "498"), Tag("Reunion", "984"), Tag("Revenge", "227"), + Tag("Royalty", "128"), Tag("School Life", "42"), Tag("Shoujo Ai", "47"), + Tag("Shounen Ai", "23"), Tag("Special Ability", "883"), Tag("Sports", "30"), + Tag("Suicide", "309"), Tag("Super Powers", "236"), Tag("Unrequited Love", "226"), + Tag("Violence", "830"), Tag("War", "238"), Tag("Yaoi", "16"), Tag("Yuri", "33"), + Tag("4-Koma", "105"), Tag("Anthology", "113"), Tag("Chinese Novels", "1112"), + Tag("Collection of Stories", "111"), Tag("Doujinshi", "24"), Tag("Episodic", "115"), + Tag("Full color", "57"), Tag("Korean Novels", "1111"), Tag("Light Novel", "466"), + Tag("Longstrip", "93"), Tag("One Shot", "110"), Tag("Web Comic", "428"), + Tag("Web Novel", "427"), Tag("Magic", "121") + ) override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = setOf( - MangaTag( - key = "Manga", - title = "Manga", - source = source - ), - MangaTag( - key = "Manwha", - title = "Manhwa", - source = source - ), - MangaTag( - key = "Manhua", - title = "Manhua", - source = source - ), - MangaTag( - key = "OEL", - title = "OEL", - source = source - ) - ), - availableStates = EnumSet.of( - MangaState.ONGOING, - MangaState.FINISHED, - MangaState.PAUSED, - MangaState.ABANDONED - ) + availableTags = (allGenres.map { (name, id) -> MangaTag(name, "genre:$id", source) } + + allTags.map { (name, id) -> MangaTag(name, "tag:$id", source) }).toSet(), + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED, MangaState.ABANDONED), + availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER), + availableContentRating = EnumSet.of(ContentRating.SAFE, ContentRating.ADULT), ) override suspend fun getListPage( page: Int, order: SortOrder, - filter: MangaListFilter - ): List { + filter: MangaListFilter, + ): List = getSearchPage(page, order, filter) + + private suspend fun getSearchPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query ?: "*" + val url = "$baseUrl/collections/manga/documents/search".toHttpUrl().newBuilder().apply { + addQueryParameter("q", query) + if (query != "*") { + addQueryParameter("query_by", "title,englishTitle,otherNames,authors") + addQueryParameter("query_by_weights", "4,3,2,1") + addQueryParameter("num_typos", "4,3,2,1") + } + addQueryParameter("page", page.toString()) + addQueryParameter("per_page", pageSize.toString()) - val sort = when (order) { - SortOrder.POPULARITY -> "views:desc" - SortOrder.RATING -> "mbRating:desc" - else -> "views:desc" - } + val filterParts = mutableListOf("hidden:!=true", "views:>0") - val url = "https://$domain/collections/manga/documents/search" - .toHttpUrl() - .newBuilder() - .addQueryParameter("q", filter.query ?: "*") - .addQueryParameter( - "query_by", - "title,englishTitle,otherNames,authors" - ) - .addQueryParameter( - "include_fields", - "id,title,poster,type,status" - ) - .addQueryParameter("page", page.toString()) - .addQueryParameter("per_page", pageSize.toString()) - .addQueryParameter("sort_by", sort) - .addQueryParameter( - "filter_by", - buildFilter(filter) - ) - .build() + val ratings = filter.contentRating + when { + ratings.contains(ContentRating.ADULT) && !ratings.contains(ContentRating.SAFE) -> { + filterParts.add("isAdult:=true") + } + ratings.contains(ContentRating.SAFE) && !ratings.contains(ContentRating.ADULT) -> { + filterParts.add("isAdult:=false") + } + // both or none: no filter shows all + else -> { /* no isAdult filter */ } + } + filterParts.add("(mbContentRating:=[`Safe`,`Suggestive`,`Erotica`] || mbContentRating:!=*)") + + val types = filter.types.mapNotNull { ct -> + when (ct) { + ContentType.MANGA -> "Manga" + ContentType.MANHWA -> "Manwha" + ContentType.MANHUA -> "Manhua" + ContentType.OTHER -> "OEL" + else -> null + } + } + if (types.isNotEmpty()) { + filterParts.add("type:=[${types.joinToString(",") { "`$it`" }}]") + } + val statusVals = filter.states.mapNotNull { st -> + when (st) { + MangaState.ONGOING -> "Ongoing" + MangaState.FINISHED -> "Completed" + MangaState.PAUSED -> "Hiatus" + MangaState.ABANDONED -> "Canceled" + else -> null + } + } + if (statusVals.isNotEmpty()) { + filterParts.add("status:=[${statusVals.joinToString(",") { "`$it`" }}]") + } + + val includedGenres = mutableListOf() + val excludedGenres = mutableListOf() + val includedTags = mutableListOf() + val excludedTags = mutableListOf() + + for (tag in filter.tags) { + val key = tag.key + when { + key.startsWith("genre:") -> includedGenres.add(key.removePrefix("genre:")) + key.startsWith("tag:") -> includedTags.add(key.removePrefix("tag:")) + } + } + for (tag in filter.tagsExclude) { + val key = tag.key + when { + key.startsWith("genre:") -> excludedGenres.add(key.removePrefix("genre:")) + key.startsWith("tag:") -> excludedTags.add(key.removePrefix("tag:")) + } + } - val hits = webClient - .httpGet(url.toString()) - .parseJson() - .optJSONArray("hits") - ?: return emptyList() + if (includedGenres.isNotEmpty()) { + filterParts.add(includedGenres.joinToString(" && ") { "genreIds:=`$it`" }) + } + if (excludedGenres.isNotEmpty()) { + filterParts.add("genreIds:!=[${excludedGenres.joinToString(",") { "`$it`" }}]") + } + if (includedTags.isNotEmpty()) { + filterParts.add(includedTags.joinToString(" && ") { "tagIds:=`$it`" }) + } + if (excludedTags.isNotEmpty()) { + filterParts.add("tagIds:!=[${excludedTags.joinToString(",") { "`$it`" }}]") + } + addQueryParameter("filter_by", filterParts.joinToString(" && ")) - return List(hits.length()) { index -> - hits.getJSONObject(index) - .getJSONObject("document") - .let(::parseManga) + val sort = when (order) { + SortOrder.POPULARITY -> "trending:desc" + SortOrder.UPDATED -> "released:desc" + SortOrder.ADDED -> "dateAdded:desc" + SortOrder.RELEVANCE -> "chapterCount:desc" + else -> "released:desc" + } + addQueryParameter("sort_by", sort) + addQueryParameter("sort_by", sort) + }.build() + + val json = webClient.httpGet(url, apiHeaders).parseJson() + val hits = json.optJSONArray("hits") ?: return emptyList() + return (0 until hits.length()).map { i -> + hits.getJSONObject(i).getJSONObject("document").toManga() } } - private fun buildFilter(filter: MangaListFilter): String { + // limit tags to 20 + override suspend fun getDetails(manga: Manga): Manga { + val mangaId = manga.url.substringAfterLast("/") + val pageJson = webClient.httpGet("$baseUrl/api/manga/page?id=$mangaId", apiHeaders).parseJson() + val mangaPage = pageJson.optJSONObject("mangaPage") ?: return manga - val filters = mutableListOf() + val title = mangaPage.optString("title").ifEmpty { mangaPage.optString("englishTitle", manga.title) } + val description = mangaPage.optString("synopsis") ?: manga.description - filters += "views:>0" + val posterObj = mangaPage.optJSONObject("poster") + val posterImage = posterObj?.optString("mediumImage") + val coverUrl = if (!posterImage.isNullOrEmpty()) { + "https://$domain/static/$posterImage" + } else manga.coverUrl - // Manga type filter - val types = filter.tags.map { - "`${it.key}`" - } + val authors = mangaPage.optJSONArray("authors")?.let { arr -> + (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("name") }.toSet() + } ?: emptySet() - if (types.isNotEmpty()) { - filters += "type:=[${types.joinToString(",")}]" + val statusText = mangaPage.optString("status").orEmpty() + val state = when (statusText.lowercase()) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + "canceled" -> MangaState.ABANDONED + else -> manga.state } + val tagSet = mutableSetOf() + mangaPage.optJSONArray("genres")?.let { arr -> + for (i in 0 until minOf(arr.length(), 10)) { + val obj = arr.optJSONObject(i) ?: continue + val name = obj.optString("name").takeIf { it.isNotEmpty() } ?: continue + tagSet.add(MangaTag(name, "genre:${obj.optString("id")}", source)) + } + } + mangaPage.optJSONArray("tags")?.let { arr -> + for (i in 0 until minOf(arr.length(), 10)) { + val obj = arr.optJSONObject(i) ?: continue + val name = obj.optString("name").takeIf { it.isNotEmpty() } ?: continue + tagSet.add(MangaTag(name, "tag:${obj.optString("id")}", source)) + } + } - // Status filter - val statuses = filter.states.mapNotNull { - when (it) { - MangaState.ONGOING -> "`Ongoing`" - MangaState.FINISHED -> "`Completed`" - MangaState.PAUSED -> "`Hiatus`" - MangaState.ABANDONED -> "`Canceled`" - else -> null + val chaptersJson = webClient.httpGet("$baseUrl/api/manga/allChapters?mangaId=$mangaId", apiHeaders).parseJson() + val chaptersArray = chaptersJson.optJSONArray("chapters") ?: JSONArray() + val scanlators = mutableMapOf() + mangaPage.optJSONArray("scanlators")?.let { scanArr -> + for (i in 0 until scanArr.length()) { + val sc = scanArr.optJSONObject(i) ?: continue + val id = sc.optString("id").takeIf { it.isNotEmpty() } ?: continue + val name = sc.optString("name").takeIf { it.isNotEmpty() } ?: continue + scanlators[id] = name } } - if (statuses.isNotEmpty()) { - filters += "status:=[${statuses.joinToString(",")}]" + val chapters = (0 until chaptersArray.length()).map { i -> + val ch = chaptersArray.getJSONObject(i) + val chId = ch.getString("id") + val number = ch.optDouble("number", 0.0).toFloat() + val chTitle = ch.optString("title") + val scanId = ch.optString("scanlationMangaId").nullIfEmpty() + val scanName = scanId?.let { scanlators[it] } + val createdAt = ch.optLong("createdAt", 0L) + + MangaChapter( + id = generateUid("$mangaId/$chId"), + title = chTitle.ifBlank { "Chapter $number" }, + number = number, + volume = 0, + url = "$mangaId/$chId", + uploadDate = createdAt, + source = source, + scanlator = scanName, + branch = scanName, + ) + }.reversed() + + val groupedChapters = if (chapters.map { it.branch }.distinct().size > 1) { + chapters.map { it.copy(branch = it.branch ?: "Unknown") } + } else { + chapters.map { it.copy(branch = null, scanlator = null) } } + val altTitles = mangaPage.optJSONArray("otherNames")?.let { arr -> + (0 until arr.length()).mapNotNull { arr.optString(it) }.toSet() + } ?: emptySet() + + return manga.copy( + title = title, + description = description, + coverUrl = coverUrl, + authors = authors, + state = state, + tags = tagSet, + chapters = groupedChapters, + altTitles = altTitles, + ) + } + + override suspend fun getRelatedManga(seed: Manga): List { + val mangaId = seed.url.substringAfterLast("/") + val pageJson = webClient.httpGet("$baseUrl/api/manga/page?id=$mangaId", apiHeaders).parseJson() + val mangaPage = pageJson.optJSONObject("mangaPage") ?: return emptyList() + val similar = mangaPage.optJSONArray("similarManga") ?: return emptyList() + return (0 until similar.length()).map { i -> + similar.getJSONObject(i).toManga() + } + } - return filters.joinToString(" && ") + override suspend fun getPages(chapter: MangaChapter): List { + val (mangaId, chapterId) = chapter.url.split("/") + val url = "$baseUrl/api/read/chapter".toHttpUrl().newBuilder() + .addQueryParameter("mangaId", mangaId) + .addQueryParameter("chapterId", chapterId) + .build() + val json = webClient.httpGet(url, apiHeaders).parseJson() + val pagesArray = json.getJSONObject("readChapter").getJSONArray("pages") + return (0 until pagesArray.length()).map { i -> + val page = pagesArray.getJSONObject(i) + val imagePath = page.getString("image") + val fullUrl = when { + imagePath.startsWith("http") -> imagePath + imagePath.startsWith("//") -> "https:$imagePath" + else -> "https://$domain/static/${imagePath.removePrefix("/").removePrefix("static/")}" + } + MangaPage(id = generateUid(fullUrl), url = fullUrl, preview = null, source = source) + } } + private fun JSONObject.toManga(): Manga { + val id = getString("id") + val title = optString("title").ifEmpty { optString("englishTitle", "Unknown") } - private fun parseManga(json: JSONObject): Manga { - val id = json.getString("id") - val title = json.optString("title").ifEmpty { - json.optString("englishTitle", "Unknown") + val imageRaw: Any? = when { + has("poster") -> get("poster") + has("image") -> getString("image") + else -> null } - // List results have "image", search results have "poster" - val image = json.optString("image") - val poster = json.optString("poster") - val imagePath = image.ifEmpty { poster } + val imagePath: String? = when (imageRaw) { + is String -> imageRaw.takeIf { it.isNotBlank() } + is JSONObject -> { + imageRaw.optString("image").nullIfEmpty() + ?: imageRaw.optString("mediumImage").nullIfEmpty() + ?: imageRaw.optString("smallImage").nullIfEmpty() + } + else -> null + }?.removePrefix("/")?.removePrefix("static/") - val coverUrl = if (imagePath.isNotEmpty()) { + val coverUrl = if (!imagePath.isNullOrBlank()) { when { imagePath.startsWith("http") -> imagePath - imagePath.startsWith("/static/") -> "https://$domain$imagePath" - imagePath.startsWith("/") -> "https://$domain$imagePath" + imagePath.startsWith("//") -> "https:$imagePath" else -> "https://$domain/static/$imagePath" } } else null @@ -177,245 +412,17 @@ internal class Atsumaru(context: MangaLoaderContext) : title = title, altTitles = emptySet(), url = "/manga/$id", - publicUrl = "https://$domain/manga/$id", + publicUrl = "$baseUrl/manga/$id", rating = RATING_UNKNOWN, contentRating = null, coverUrl = coverUrl, tags = emptySet(), state = null, authors = emptySet(), - source = source - ) - } - - override suspend fun getDetails(manga: Manga): Manga = coroutineScope { - val mangaId = manga.url.substringAfterLast("/") - val pageDeferred = async { - webClient - .httpGet("${apiUrl}manga/page?id=$mangaId") - .parseJson() - .optJSONObject("mangaPage") - } - val chaptersDeferred = async { - fetchAllChapters( - mangaId, - pageDeferred.await() - ) - } - val mangaPage = pageDeferred.await() - val title = mangaPage?.optString("title")?.ifEmpty { - mangaPage.optString("englishTitle", manga.title) - } ?: manga.title - val description = - mangaPage?.optString("synopsis") - ?: manga.description - val posterImage = - mangaPage - ?.optJSONObject("poster") - ?.optString("image") - val coverUrl = - if (!posterImage.isNullOrEmpty()) { - "https://$domain/static/$posterImage" - } else { - manga.coverUrl - } - val authors = - mangaPage - ?.optJSONArray("authors") - ?.let { array -> - (0 until array.length()) - .mapNotNull { - array.optJSONObject(it) - ?.optString("name") - } - .toSet() - } - ?: emptySet() - val status = mangaPage?.optString("status").orEmpty() - val state = when(status.lowercase()) { - "ongoing" -> MangaState.ONGOING - "completed" -> MangaState.FINISHED - "hiatus" -> MangaState.PAUSED - "cancelled" -> MangaState.ABANDONED - else -> manga.state - } - manga.copy( - title = title, - description = description, - coverUrl = coverUrl, - authors = authors, - state = state, - chapters = chaptersDeferred.await() - ) - } - - private suspend fun fetchAllChapters( - mangaId: String, - mangaPage: JSONObject? - ): List = coroutineScope { - - val scanlators = mangaPage.parseScanlators() - - // First request is needed to know total pages - val firstUrl = - "${apiUrl}manga/chapters?id=$mangaId&filter=all&sort=desc&page=0" - - val firstJson = webClient - .httpGet(firstUrl) - .parseJson() - - val totalPages = firstJson.optInt("pages", 1) - - val semaphore = Semaphore(8) - - val pages = buildList { - - // Add already fetched page 0 - add(firstJson) - - // Fetch remaining pages in parallel - addAll( - (1 until totalPages).map { page -> - - async { - semaphore.withPermit { - - val url = - "${apiUrl}manga/chapters?id=$mangaId&filter=all&sort=desc&page=$page" - - webClient - .httpGet(url) - .parseJson() - } - } - - }.awaitAll() - ) - } - - - pages.flatMap { json -> - - val array = json.optJSONArray("chapters") - ?: return@flatMap emptyList() - - (0 until array.length()) - .mapNotNull { i -> - array.optJSONObject(i) - } - .map { - parseChapter( - it, - mangaId, - scanlators - ) - } - } - .reversed() - .mapChapterBranches() - } - - private fun JSONObject?.parseScanlators(): Map { - val scanlatorsArray = this?.optJSONArray("scanlators") ?: return emptyMap() - return buildMap { - for (i in 0 until scanlatorsArray.length()) { - val scanlator = scanlatorsArray.optJSONObject(i) ?: continue - val id = scanlator.optString("id").takeIf { it.isNotEmpty() } ?: continue - val name = scanlator.optString("name").takeIf { it.isNotEmpty() } ?: continue - put(id, name) - } - } - } - - - private fun parseChapter( - json: JSONObject, - mangaId: String, - scanlators: Map, - ): MangaChapter { - val chapterId = json.getString("id") - val title = json.optString("title").takeIf { it.isNotEmpty() } - val number = json.optString("number").toFloatOrNull() - ?: json.optDouble("number", 0.0).toFloat() - val scanlator = scanlators[json.optString("scanlationMangaId")] - - val uploadDate = when (val createdAt = json.opt("createdAt")) { - is Number -> createdAt.toLong() - is String -> { - try { - dateFormat.parse(createdAt)?.time ?: 0L - } catch (_: Exception) { - 0L - } - } - else -> 0L - } - - return MangaChapter( - id = generateUid("$mangaId/$chapterId"), - title = title, - number = number, - volume = 0, - url = "$mangaId/$chapterId", - uploadDate = uploadDate, source = source, - scanlator = scanlator, - branch = scanlator ) } - private fun List.mapChapterBranches(): List { - val totalPerChapter = groupingBy { it.number } - .eachCount() - val counters = HashMap() - return map { chapter -> - val total = totalPerChapter[chapter.number] ?: 1 - - if (total == 1) { - return@map chapter.copy( - scanlator = null, - branch = null - ) - } - val count = counters.getOrDefault(chapter.number, 0) + 1 - counters[chapter.number] = count - val branch = if (count == 1) { - "Group 1" - } else { - "Group $count" - } - chapter.copy( - scanlator = branch, - branch = branch - ) - } - } - - override suspend fun getPages(chapter: MangaChapter): List { - val (mangaId, chapterId) = chapter.url.split("/") - val url = "${apiUrl}read/chapter".toHttpUrl().newBuilder() - .addQueryParameter("mangaId", mangaId) - .addQueryParameter("chapterId", chapterId) - .build() - - val json = webClient.httpGet(url.toString()).parseJson() - val readChapter = json.getJSONObject("readChapter") - val pages = readChapter.getJSONArray("pages") - - return (0 until pages.length()).map { i -> - val page = pages.getJSONObject(i) - val imagePath = page.getString("image") - val fullUrl = "https://$domain$imagePath" - - MangaPage( - id = generateUid(fullUrl), - url = fullUrl, - preview = null, - source = source - ) - } - } - override suspend fun getRelatedManga(seed: Manga): List { - return emptyList() - } + private data class Genre(val name: String, val id: String) + private data class Tag(val name: String, val id: String) } From bcd8f1c6f55a5fe31bbb9e962b9ba638fe642359 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:14:37 +0200 Subject: [PATCH 009/109] Update Mangadotnet.kt --- .../parsers/site/kotatsu/en/Mangadotnet.kt | 172 ++++++++++-------- 1 file changed, 101 insertions(+), 71 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt index 3f611ab9..72558613 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt @@ -19,43 +19,32 @@ internal class Mangadotnet(context: MangaLoaderContext) : override val configKeyDomain = ConfigKey.Domain("mangadot.net") private val baseUrl = "https://mangadot.net" - private var cachedGenres: List? = null - private var cachedTags: List? = null - private val demographicNames = setOf("Josei", "Seinen", "Shoujo", "Shounen") - private val maxGenreTags = 200 - private val maxTopicTags = 200 - - private fun updateGenres(newGenres: List) { - if (newGenres.isEmpty()) return - cachedGenres = newGenres.filter { it !in demographicNames } - .sortedBy { it.lowercase() } - .take(maxGenreTags) - } - - @Suppress("UNCHECKED_CAST") - private fun updateTags(categories: List>?) { - if (categories.isNullOrEmpty()) return - val newTags = categories.asSequence().flatMap { category -> - val tagsList = category["tags"] as? List> ?: emptyList() - tagsList.mapNotNull { it["name"] as? String } - } - .filter { it.isNotBlank() } - .distinct() - .sortedBy { it.lowercase() } - .take(maxTopicTags) - .toList() - - val current = cachedTags ?: emptyList() - val combined = (current + newTags).distinct() - .sortedBy { it.lowercase() } - .take(maxTopicTags) - cachedTags = combined - } - - private fun getGenres(): List = cachedGenres ?: emptyList() - private fun getTags(): List = cachedTags ?: emptyList() + private val genreNames = setOf( + "Josei", "Seinen", "Shoujo", "Shounen", + "Action", "Adventure", "Comedy", "Drama", "Fantasy", + "Historical", "Horror", "Mecha", "Mystery", "Psychological", + "Romance", "Sci-Fi", "Slice of Life", "Sports", "Supernatural", "Thriller", "Tragedy", + "Cooking", "Demons", "Ecchi", "Harem", "Isekai", "Magic", "Martial Arts", + "Medical", "Military", "Music", "School Life", "Webtoon", + "Academy", "Acting", "Adult", "Aliens", "Animals", "Anthology", "Apocalypse", + "Avant Garde", "Award Winning", "BDSM", "Boys Love", "Bully", "Business", + "Child Abuse", "Child Neglect", "Comic", "Crime", "crossdressing", "Crossdressing", + "Cultivation", "Delinquents", "Difficult Childhood", "dojinshi", "Doujinshi", + "Erotica", "Female Protagonist", "Femdom", "Fight", "futanari on male", "futunari", + "Gender Bender", "Genderswap", "Ghosts", "Girls Love", "Gore", "Gourmet", + "Gyaru", "Hentai", "Hunters", "Idol", "Incest", "Loli", "Lolicon", "Mafia", + "Magical Girls", "Mahou Shoujo", "Manga", "Manhua", "Manhwa", "Mature", + "Medieval Area", "Monster Girls", "Monsters", "Ninja", "Nobility", + "Office Romance", "Office Worker", "Office Workers", "One Shot", "Otome", + "Overpowered", "Philosophical", "playboy", "Police", "Post-Apocalyptic", + "Reincarnation", "Revenge", "Reverse Harem", "Royalty", "Samurai", "School", + "Seinin", "Shota", "Shotacon", "Shoujo Ai", "Shounen Ai", "Smut", "Superhero", + "Survival", "Suspense", "System", "Time Travel", "Traditional Games", "uncensored", + "Vampires", "Video Games", "Villainess", "Virtual Reality", "War", "Workplace", + "Wuxia", "Yaoi", "Yuri", "Zombies" + ) - // RSC decoder from clarity + // RSC decoder private fun decodeRsc(flat: JSONArray): Any? { val cache = arrayOfNulls(flat.length()) val nil = Any() @@ -94,6 +83,7 @@ internal class Mangadotnet(context: MangaLoaderContext) : override val filterCapabilities = MangaListFilterCapabilities( isSearchSupported = true, isMultipleTagsSupported = true, + isTagsExclusionSupported = true, isSearchWithFiltersSupported = true, ) @@ -105,26 +95,9 @@ internal class Mangadotnet(context: MangaLoaderContext) : ) override suspend fun getFilterOptions(): MangaListFilterOptions { - // On first open, load the genre list from a browse page so filters are populated - if (cachedGenres == null && cachedTags == null) { - try { - val initUrl = "$baseUrl/view-all/latest-updates.data".toHttpUrl().newBuilder() - .addQueryParameter("adult", "0") - .addQueryParameter("page", "1") - .addQueryParameter("_routes", "pages/ViewAllPage") - .build().toString() - parseViewAllPage(initUrl) // this updates cachedGenres - } catch (_: Exception) { - // ignore - } - } - val genres = getGenres() - val tags = getTags() - val availableTags = mutableSetOf() - genres.forEach { g -> availableTags.add(MangaTag(g, g, source)) } - tags.forEach { t -> availableTags.add(MangaTag(t, "tag:$t", source)) } + val tags = genreNames.map { name -> MangaTag(name, name, source) }.toSet() return MangaListFilterOptions( - availableTags = availableTags, + availableTags = tags, availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED), availableContentTypes = setOf(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER), availableDemographics = setOf(Demographic.JOSEI, Demographic.SEINEN, Demographic.SHOUJO, Demographic.SHOUNEN), @@ -192,33 +165,21 @@ internal class Mangadotnet(context: MangaLoaderContext) : } filter.tags.forEach { tag -> - if (tag.key.startsWith("tag:")) { - addQueryParameter("tag", tag.key.removePrefix("tag:")) - } else { - addQueryParameter("genre", tag.key) - } + addQueryParameter("genre", tag.key) } filter.tagsExclude.forEach { tag -> - if (tag.key.startsWith("tag:")) { - addQueryParameter("tag", "-${tag.key.removePrefix("tag:")}") - } else { - addQueryParameter("genre", "-${tag.key}") - } + addQueryParameter("genre", "-${tag.key}") } addQueryParameter("_routes", "pages/SearchPage") }.build().toString() val dataMap = fetchRscData(url, "pages/SearchPage") ?: return emptyList() - (dataMap["allGenres"] as? List<*>)?.filterIsInstance()?.let { updateGenres(it) } - (dataMap["allTags"] as? List>?)?.let { updateTags(it) } - return parseMangaList(dataMap) } private suspend fun parseViewAllPage(url: String): List { val viewAllData = fetchRscData(url, "pages/ViewAllPage") ?: return emptyList() - (viewAllData["allGenres"] as? List<*>)?.filterIsInstance()?.let { updateGenres(it) } val mangaListMap = viewAllData.asMap("data") ?: return emptyList() return parseMangaList(mangaListMap) } @@ -255,6 +216,7 @@ internal class Mangadotnet(context: MangaLoaderContext) : ) } + @Suppress("UNCHECKED_CAST") override suspend fun getDetails(manga: Manga): Manga { val url = "$baseUrl/manga/${manga.url}.data?_routes=pages/MangaDetailPage" val mangaData = fetchRscData(url, "pages/MangaDetailPage") ?: return manga @@ -270,13 +232,22 @@ internal class Mangadotnet(context: MangaLoaderContext) : else -> null } } ?: manga.coverUrl + val bannerImage = mangaInfo["banner_image"] as? String + val largeCoverUrl = bannerImage?.let { + when { + it.startsWith("/") -> "$baseUrl$it" + it.startsWith("http") -> it + else -> null + } + } val hiatus = mangaInfo["hiatus"] as? String val status = mangaInfo["status"] as? String val genres = (mangaInfo["genres"] as? List<*>)?.filterIsInstance()?.mapNotNull { it.trim().ifBlank { null } }.orEmpty() val altTitles = (mangaInfo["alt_titles"] as? List<*>)?.filterIsInstance()?.mapNotNull { it.trim().ifBlank { null } }?.toSet().orEmpty() val origin = mangaInfo["country_of_origin"] as? String val ratingValue = (mangaInfo["avg_rating"] as? Number)?.toFloat() - val author = mangaInfo["author"] as? String + val authorRaw = mangaInfo["authors"] as? String + val author = parseJsonArrayString(authorRaw)?.joinToString(", ") ?: authorRaw val year = (mangaInfo["year"] as? Number)?.toInt() val chapterCount = (mangaInfo["chapter_count"] as? Number)?.toInt() val trackedCount = (mangaInfo["tracked_count"] as? Number)?.toInt() @@ -300,6 +271,25 @@ internal class Mangadotnet(context: MangaLoaderContext) : "CN" -> tagSet.add(MangaTag("Manhua", "Manhua", source)) } + val tagsArray = mangaInfo["tags"] as? List> + if (tagsArray != null) { + tagsArray.flatMap { category -> + (category["tags"] as? List> ?: emptyList()) + .mapNotNull { it["name"] as? String } + } + .distinct() + .take(20) + .forEach { tagSet.add(MangaTag(it, it, source)) } + } + + val anilistId = (mangaInfo["anilist_id"] as? Number)?.toLong() + val malId = (mangaInfo["mal_id"] as? Number)?.toLong() + val mangadexId = mangaInfo["mangadex_id"] as? String + val mangaupdatesId = mangaInfo["mangaupdates_id"] as? String + val kitsuId = (mangaInfo["kitsu_id"] as? Number)?.toLong() + val mangabakaId = (mangaInfo["mangabaka_id"] as? Number)?.toLong() + val sourceUrl = mangaInfo["source_url"] as? String + val richDescription = buildString { ratingValue?.let { rating -> val stars = (rating / 2).toInt().coerceIn(0, 5) @@ -318,6 +308,19 @@ internal class Mangadotnet(context: MangaLoaderContext) : description?.let { append(it.replace("\r\n", "\n").replace(Regex("\n{3,}"), "\n\n").trim(), "\n\n") } + val links = buildList { + anilistId?.let { add("[AniList](https://anilist.co/manga/$it)") } + malId?.let { add("[MyAnimeList](https://myanimelist.net/manga/$it)") } + mangadexId?.let { add("[MangaDex](https://mangadex.org/title/$it)") } + mangaupdatesId?.let { add("[MangaUpdates](https://www.mangaupdates.com/series/$it)") } + kitsuId?.let { add("[Kitsu](https://kitsu.app/manga/$it)") } + mangabakaId?.let { add("[MangaBaka](https://mangabaka.org/$it)") } + sourceUrl?.let { add("[Source]($it)") } + } + if (links.isNotEmpty()) { + append("\n**Links:**\n") + links.forEach { append("- ", it, "\n") } + } if (altTitles.isNotEmpty()) { append("\n**Alternative Names:**\n") altTitles.forEach { append("- ", it, "\n") } @@ -329,6 +332,7 @@ internal class Mangadotnet(context: MangaLoaderContext) : return manga.copy( title = title, coverUrl = coverUrl, + largeCoverUrl = largeCoverUrl, description = richDescription, altTitles = altTitles, tags = tagSet, @@ -338,6 +342,16 @@ internal class Mangadotnet(context: MangaLoaderContext) : ) } + private fun parseJsonArrayString(raw: String?): List? { + if (raw.isNullOrBlank()) return null + return try { + val arr = JSONArray(raw) + (0 until arr.length()).mapNotNull { arr.optString(it) } + } catch (_: Exception) { + null + } + } + private suspend fun fetchChapters(mangaId: String): List { val response = webClient.httpGet("$baseUrl/api/manga/$mangaId/chapters/list?lang=en").parseJsonArray() val allChapters = (0 until response.length()).map { response.getJSONObject(it) } @@ -421,7 +435,23 @@ internal class Mangadotnet(context: MangaLoaderContext) : } } - override suspend fun getRelatedManga(seed: Manga): List = emptyList() + @Suppress("UNCHECKED_CAST") + override suspend fun getRelatedManga(seed: Manga): List { + val url = "$baseUrl/manga/${seed.url}.data?_routes=pages/MangaDetailPage" + val data = fetchRscData(url, "pages/MangaDetailPage") ?: return emptyList() + + val related = mutableListOf() + + // suggestions + (data["suggestions"] as? List<*>)?.filterIsInstance>()?.mapTo(related) { parseMangaFromList(it) } + + // relations + val relationsData = data.asMap("relationsData") + val relations = relationsData?.asMap("relations") as? Map>> + relations?.values?.forEach { list -> list.mapTo(related) { parseMangaFromList(it) } } + + return related + } private fun MangaState.toApiStatus() = when (this) { MangaState.ONGOING -> "Ongoing" From e5130a1a62a46ef253133ec407189623e02820a1 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:30:32 +0200 Subject: [PATCH 010/109] added volume support for mangafire --- .../parsers/site/kotatsu/all/MangaFire.kt | 74 +++++++++++++++---- 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt index ad38345e..0455e4a3 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt @@ -25,6 +25,8 @@ import org.koitharu.kotatsu.parsers.util.generateUid import org.koitharu.kotatsu.parsers.util.parseJson import java.util.EnumSet +// TODO: improve perfomance + internal abstract class MangaFireParser( context: MangaLoaderContext, source: MangaParserSource, @@ -246,6 +248,7 @@ internal abstract class MangaFireParser( val hid = extractHid(manga.url) val detailsJson = apiClient.httpGet("https://$domain/api/titles/$hid").parseJson() val data = detailsJson.getJSONObject("data") + val hasVolumes = data.optBoolean("hasVolumes", false) val title = data.getString("title") val poster = data.optJSONObject("poster") @@ -297,7 +300,7 @@ internal abstract class MangaFireParser( tags.find { it.title == name } }.toSet() - val chapters = fetchChapters(hid) + val chapters = fetchChapters(hid, hasVolumes) return manga.copy( title = title, @@ -318,7 +321,7 @@ internal abstract class MangaFireParser( ) } - private suspend fun fetchChapters(hid: String): List { + private suspend fun fetchChapters(hid: String, hasVolumes: Boolean): List { val chapters = mutableListOf() var page = 1 var lastPage: Int @@ -331,21 +334,18 @@ internal abstract class MangaFireParser( for (i in 0 until items.length()) { val chObj = items.getJSONObject(i) - if (chObj.getString("language") != siteLang) continue val id = chObj.getInt("id") val number = chObj.getDouble("number").toFloat() val name = chObj.optString("name", null) val createdAt = chObj.optLong("createdAt", 0L) * 1000L - val type = chObj.getString("type") // "official" or "unofficial" or others + val type = chObj.getString("type") val chapterUrl = "/title/$hid/$id" val displayName = buildString { append("Ch. ") append(number.toString().removeSuffix(".0")) - if (!name.isNullOrBlank()) { - append(" - $name") - } + if (!name.isNullOrBlank()) append(" - $name") } chapters.add( MangaChapter( @@ -356,7 +356,7 @@ internal abstract class MangaFireParser( url = chapterUrl, scanlator = null, uploadDate = createdAt, - branch = type, // store raw type temporarily + branch = type, source = source, ) ) @@ -364,17 +364,56 @@ internal abstract class MangaFireParser( page++ } while (page <= lastPage) - val distinctTypes = chapters.map { it.branch }.distinct() - val useGroups = distinctTypes.size > 1 + if (hasVolumes) { + try { + val volumesResponse = apiClient.httpGet( + "https://$domain/api/titles/$hid/volumes?language=$siteLang" + ).parseJson() + val volumesItems = volumesResponse.getJSONArray("items") + for (i in 0 until volumesItems.length()) { + val volObj = volumesItems.getJSONObject(i) + if (volObj.getString("language") != siteLang) continue + + val volId = volObj.getInt("id") + val volNumber = volObj.getDouble("number").toFloat() + val volName = volObj.optString("name", "").takeIf { it.isNotBlank() } + val chapterCount = volObj.optInt("chapterCount", 0) + + val title = buildString { + append("Vol. ") + append(volNumber.toString().removeSuffix(".0")) + if (!volName.isNullOrBlank()) append(" - $volName") + if (chapterCount > 0) append(" ($chapterCount chapters)") + } + + val volUrl = "/title/$hid/vol/$volId" + + chapters.add( + MangaChapter( + id = generateUid(volUrl), + title = title, + number = volNumber, + volume = 0, + url = volUrl, + scanlator = "Volume", + uploadDate = 0L, + branch = "Volume", + source = source, + ) + ) + } + } catch (index: Exception) { + throw Exception(index) + } + } + + val distinctBranches = chapters.map { it.branch }.distinct() + val useGroups = distinctBranches.size > 1 return chapters .map { chapter -> chapter.copy( - branch = if (useGroups) { - (chapter.branch ?: "").replaceFirstChar { it.uppercase() } - } else { - null - } + branch = if (useGroups) (chapter.branch ?: "").replaceFirstChar { it.uppercase() } else null ) } .sortedBy { it.number } @@ -382,7 +421,10 @@ internal abstract class MangaFireParser( override suspend fun getPages(chapter: MangaChapter): List { val chapterId = chapter.url.substringAfterLast("/") // numeric ID - val response = apiClient.httpGet("https://$domain/api/chapters/$chapterId").parseJson() + val isVolume = chapter.url.contains("/vol/") + val endpoint = if (isVolume) "volumes" else "chapters" + + val response = apiClient.httpGet("https://$domain/api/$endpoint/$chapterId").parseJson() val pagesArray = response.getJSONObject("data").getJSONArray("pages") val pages = ArrayList(pagesArray.length()) for (i in 0 until pagesArray.length()) { From f3a5f1600d37e6a7777a17d16d56a8d76e5f5268 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:50:24 +0200 Subject: [PATCH 011/109] Update MangaFire.kt --- .../parsers/site/kotatsu/all/MangaFire.kt | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt index 0455e4a3..5f9acda7 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt @@ -24,8 +24,8 @@ import org.koitharu.kotatsu.parsers.network.OkHttpWebClient import org.koitharu.kotatsu.parsers.util.generateUid import org.koitharu.kotatsu.parsers.util.parseJson import java.util.EnumSet - -// TODO: improve perfomance +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope internal abstract class MangaFireParser( context: MangaLoaderContext, @@ -243,13 +243,16 @@ internal abstract class MangaFireParser( } return mangas } - - override suspend fun getDetails(manga: Manga): Manga { + override suspend fun getDetails(manga: Manga): Manga = coroutineScope { val hid = extractHid(manga.url) + val detailsJson = apiClient.httpGet("https://$domain/api/titles/$hid").parseJson() val data = detailsJson.getJSONObject("data") + val hasVolumes = data.optBoolean("hasVolumes", false) + val chaptersDeferred = async { fetchChapters(hid, hasVolumes) } + val title = data.getString("title") val poster = data.optJSONObject("poster") val cover = poster?.optString("large") @@ -271,7 +274,6 @@ internal abstract class MangaFireParser( (0 until arr.length()).map { arr.getString(it) } } ?: emptyList() - // putting rating into description, adding it via "rating" does not work val raw = data.optDouble("rating", -1.0) val ratingValue = if (raw >= 0.0) raw.toFloat() else null @@ -300,9 +302,9 @@ internal abstract class MangaFireParser( tags.find { it.title == name } }.toSet() - val chapters = fetchChapters(hid, hasVolumes) + val chapters = chaptersDeferred.await() - return manga.copy( + manga.copy( title = title, coverUrl = cover ?: manga.coverUrl, authors = setOfNotNull(authors), @@ -323,24 +325,27 @@ internal abstract class MangaFireParser( private suspend fun fetchChapters(hid: String, hasVolumes: Boolean): List { val chapters = mutableListOf() + val base = "https://$domain/api/titles/$hid" + var page = 1 var lastPage: Int do { - val url = "https://$domain/api/titles/$hid/chapters?language=$siteLang&sort=number&order=desc&page=$page&limit=200" - val response = apiClient.httpGet(url).parseJson() - val items = response.getJSONArray("items") - val meta = response.optJSONObject("meta") + val json = apiClient.httpGet( + "$base/chapters?language=$siteLang&sort=number&order=desc&page=$page&limit=200" + ).parseJson() + val items = json.getJSONArray("items") + val meta = json.optJSONObject("meta") lastPage = meta?.optInt("lastPage", 1) ?: 1 for (i in 0 until items.length()) { - val chObj = items.getJSONObject(i) - if (chObj.getString("language") != siteLang) continue - - val id = chObj.getInt("id") - val number = chObj.getDouble("number").toFloat() - val name = chObj.optString("name", null) - val createdAt = chObj.optLong("createdAt", 0L) * 1000L - val type = chObj.getString("type") + val ch = items.getJSONObject(i) + if (ch.getString("language") != siteLang) continue + + val id = ch.getInt("id") + val number = ch.getDouble("number").toFloat() + val name = ch.optString("name", null) + val createdAt = ch.optLong("createdAt", 0L) * 1000L + val type = ch.getString("type") val chapterUrl = "/title/$hid/$id" val displayName = buildString { append("Ch. ") @@ -366,35 +371,31 @@ internal abstract class MangaFireParser( if (hasVolumes) { try { - val volumesResponse = apiClient.httpGet( - "https://$domain/api/titles/$hid/volumes?language=$siteLang" - ).parseJson() - val volumesItems = volumesResponse.getJSONArray("items") - for (i in 0 until volumesItems.length()) { - val volObj = volumesItems.getJSONObject(i) - if (volObj.getString("language") != siteLang) continue - - val volId = volObj.getInt("id") - val volNumber = volObj.getDouble("number").toFloat() - val volName = volObj.optString("name", "").takeIf { it.isNotBlank() } - val chapterCount = volObj.optInt("chapterCount", 0) + val volJson = apiClient.httpGet("$base/volumes?language=$siteLang").parseJson() + val volItems = volJson.getJSONArray("items") + for (i in 0 until volItems.length()) { + val vol = volItems.getJSONObject(i) + if (vol.getString("language") != siteLang) continue + + val volId = vol.getInt("id") + val volNumber = vol.getDouble("number").toFloat() + val volName = vol.optString("name", "").takeIf { it.isNotBlank() } + val chapterCount = vol.optInt("chapterCount", 0) val title = buildString { append("Vol. ") append(volNumber.toString().removeSuffix(".0")) - if (!volName.isNullOrBlank()) append(" - $volName") + if (volName != null) append(" - $volName") if (chapterCount > 0) append(" ($chapterCount chapters)") } - val volUrl = "/title/$hid/vol/$volId" - chapters.add( MangaChapter( - id = generateUid(volUrl), + id = generateUid("/title/$hid/vol/$volId"), title = title, number = volNumber, volume = 0, - url = volUrl, + url = "/title/$hid/vol/$volId", scanlator = "Volume", uploadDate = 0L, branch = "Volume", @@ -402,8 +403,7 @@ internal abstract class MangaFireParser( ) ) } - } catch (index: Exception) { - throw Exception(index) + } catch (_: Exception) { } } From 5e4cca7690c36e677e72ebbca00affc1c55f2215 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:58:24 +0200 Subject: [PATCH 012/109] Update AquaManga.kt --- .../parsers/site/kotatsu/en/AquaManga.kt | 171 ++++++++++++++---- 1 file changed, 139 insertions(+), 32 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt index 852e0959..751b65c0 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt @@ -1,12 +1,13 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.en -import okhttp3.Headers +import org.jsoup.nodes.Document import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser -import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.parsers.MadaraParser -import java.util.Base64 -import kotlin.random.Random +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.* @MangaSourceParser("AQUAMANGA", "AquaManga", "en") internal class AquaManga(context: MangaLoaderContext) : @@ -15,38 +16,144 @@ internal class AquaManga(context: MangaLoaderContext) : override val withoutAjax = true override val stylePage = "" - override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder() - .set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") - .set("Accept-Language", "en-US,en;q=0.5") - .set("Referer", "https://$domain/") - .set("Sec-Fetch-Dest", "document") - .set("Sec-Fetch-Mode", "navigate") - .set("Sec-Fetch-Site", "same-origin") - .set("Upgrade-Insecure-Requests", "1") - .set("X-Requested-With", randomValue) - .build() - - private fun getRandomSubstring(input: String, length: Int): String { - val startIndex = Random.nextInt(0, input.length - length + 1) - return input.substring(startIndex, startIndex + length) - } + override val selectChapter = ".aqua-ch-item" + + override val availableSortOrders: EnumSet = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.NEWEST, + SortOrder.ALPHABETICAL, + ) + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = setOf( + MangaTag("Academy", "academy", source), + MangaTag("Action", "action", source), + MangaTag("Adaptation", "adaptation", source), + MangaTag("Adventure", "adventure", source), + MangaTag("Comedy", "comedy", source), + MangaTag("Cooking", "cooking", source), + MangaTag("Crime", "crime", source), + MangaTag("Cultivation", "cultivation", source), + MangaTag("Delinquents", "delinquents", source), + MangaTag("Demons", "demons", source), + MangaTag("Drama", "drama", source), + MangaTag("Dungeons", "dungeons", source), + MangaTag("Ecchi", "ecchi", source), + MangaTag("Fantasy", "fantasy", source), + MangaTag("Game", "game", source), + MangaTag("Gore", "gore", source), + MangaTag("Harem", "harem", source), + MangaTag("Historical", "historical", source), + MangaTag("Horror", "horror", source), + MangaTag("Isekai", "isekai", source), + MangaTag("Josei", "josei", source), + MangaTag("Magic", "magic", source), + MangaTag("Manga", "manga", source), + MangaTag("Manhua", "manhua", source), + MangaTag("Manhwa", "manhwa", source), + MangaTag("Martial Arts", "martial-arts", source), + MangaTag("Mecha", "mecha", source), + MangaTag("Medical", "medical", source), + MangaTag("Military", "military", source), + MangaTag("Monsters", "monsters", source), + MangaTag("Murim", "murim", source), + MangaTag("Music", "music", source), + MangaTag("Mystery", "mystery", source), + MangaTag("Necromancer", "necromancer", source), + MangaTag("Ninja", "ninja", source), + MangaTag("Office Workers", "office-workers", source), + MangaTag("OP-MC", "op-mc", source), + MangaTag("Overpowered", "overpowered", source), + MangaTag("Philosophical", "philosophical", source), + MangaTag("Post-Apocalyptic", "post-apocalyptic", source), + MangaTag("Psychological", "psychological", source), + MangaTag("Rebirth", "rebirth", source), + MangaTag("Regression", "regression", source), + MangaTag("Reincarnation", "reincarnation", source), + MangaTag("Returner", "returner", source), + MangaTag("Revenge", "revenge", source), + MangaTag("Romance", "romance", source), + MangaTag("School Life", "school-life", source), + MangaTag("Sci-fi", "sci-fi", source), + MangaTag("Seinen", "seinen", source), + MangaTag("Shounen", "shounen", source), + MangaTag("Slice-of-Life", "slice-of-life", source), + MangaTag("Sports", "sports", source), + MangaTag("Super Power", "super-power", source), + MangaTag("Superhero", "superhero", source), + MangaTag("Supernatural", "supernatural", source), + MangaTag("Survival", "survival", source), + MangaTag("System", "system", source), + MangaTag("Thriller", "thriller", source), + MangaTag("Time Travel", "time-travel", source), + MangaTag("Tower", "tower", source), + MangaTag("Tragedy", "tragedy", source), + MangaTag("Vampire", "vampire", source), + MangaTag("Video Games", "video-games", source), + MangaTag("Villainess", "villainess", source), + MangaTag("Virtual Reality", "virtual-reality", source), + MangaTag("Voilence", "voilence", source), + MangaTag("Webcomic", "webcomic", source), + MangaTag("Wuxia", "wuxia", source), + MangaTag("Zombies", "zombies", source), + ), + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), + availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA), + ) - private val randomLength = Random.Default.nextInt(13, 21) + override suspend fun getDetails(manga: Manga): Manga { + val fullUrl = manga.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl).parseHtml() - private val decodedString = Base64.getDecoder() - .decode(LITTLE_BIT_CURSED_VALUE) - .toString(Charsets.UTF_8) - .trim() + val title = doc.selectFirstOrThrow(".aqua-series-info__title").text() + val thumbnail = doc.selectFirstOrThrow(".aqua-series-cover__img").requireSrc() + val description = doc.selectFirst(".aqua-series-synopsis")?.html().orEmpty() + val status = doc.selectFirst(".aqua-series-meta__status")?.text() + val genres = doc.select(".aqua-series-genre-pill").map { it.text() }.toSet() + val author = doc.selectFirst(".aqua-series-info__creator-value a")?.ownText() + val artist = doc.selectFirst(".aqua-series-info__creator-value a")?.ownText() - private val randomStringValue = getRandomSubstring(decodedString, randomLength) + val tags = genres.mapTo(mutableSetOf()) { MangaTag(title = it, key = it, source = source) } + val statusText = status?.lowercase().orEmpty() + val state = when (statusText) { + in ongoing -> MangaState.ONGOING + in finished -> MangaState.FINISHED + in abandoned -> MangaState.ABANDONED + in paused -> MangaState.PAUSED + else -> null + } - private val randomValue = when { - Random.nextInt(1, 11) == 1 -> "org.chromium.chrome" - else -> randomStringValue + val chapters = getChapters(manga, doc) + return manga.copy( + title = title, + coverUrl = thumbnail, + description = description, + tags = tags, + state = state, + authors = setOfNotNull(author, artist).filter { it.isNotBlank() }.toSet(), + chapters = chapters, + ) } - private companion object { - private const val LITTLE_BIT_CURSED_VALUE = - "ICAgSUFBZ0FFa0FRd0JCQUdjQVNRQkRBRUVBWndCUkFGVUFUZ0JDQUZFQVZRQnNBRUlBVVFCWEFHUUFRZ0JSQURBQVJnQkNBRk1BVlFCR0FFSUFXZ0F3QUVZQVJBQlJBRlVBUmdCVUFGVUFWUUJLQUVVQVVRQlZBRllBUmdCUkFGWUFjQUF6QUZFQWF3QndBRUlBVWdCVkFERUFRZ0JWQUZZQVJnQkRBR0lBYXdCR0FFWUFZUUF3QUVZQVVnQmtBREFBU2dCREFGRUFWUUJrQUdvQVVRQldBRTRBVWdCUkFHc0FVZ0JDQUZJQVZRQnNBRUlBVlFBeUFHUUFRd0JWQUdzQVJnQkhBRllBVlFCR0FGTUFXZ0F3QUVvQU1RQlJBRlVBV2dCR0FGRUFWZ0JhQUZJQVVRQnJBRGtBUWdCU0FGVUFiQUJDQUZZQVZnQkdBRU1BVmdBd0FFWUFSZ0JPQUVVQVJnQldBRm9BTUFCS0FGTUFVUUJWQUdRQWVnQlJBRllBVmdCdUFGRUFhd0JPQUVJQVVnQnJBR3dBUWdCV0FHd0FSZ0JEQUZZQU1BQkdBRVlBVXdCVkFFWUFWd0JrQURBQVNnQXhBRkVBVlFCa0FGSUFVUUJXQUVZQU13QlJBR3dBVWdCQ0FGSUFNd0JPQUVJQVZRQnRBR1FBUXdCU0FEQUFSZ0JIQUZVQVZRQkdBRmNBVlFCVkFFb0FTQUJSQUZVQVdnQktBRkVBVmdCYUFGSUFVUUJzQUZvQVFnQlNBRmNBT1FCQ0FGb0FSZ0JHQUVNQVZRQnJBRVlBUndCV0FGVUFSZ0JYQUZvQU1BQktBRFVBVVFCVkFGb0FSZ0JSQUZZQVdnQnVBRkVBYXdCa0FFSUFVZ0JGQURFQVFnQldBRllBUmdCREFHTUFhd0JHQUVZQVlnQXdBRVlBVWdCYUFEQUFTZ0JVQUZFQVZRQlNBRW9BVVFCV0FGSUFiZ0JSQUdzQVRnQkNBRklBYkFCc0FFSUFWQUJXQUVZQVF3QlNBREFBUmdCR0FGTUFWUUJHQUdFQVZRQlZBRW9BVndCUkFGVUFWZ0JhQUZFQVZnQktBRklBVVFCdEFHZ0FRZ0JTQUVVQVJnQkNBRlVBYlFCa0FFTUFZd0JyQUVZQVJ3QlNBRlVBUmdCWEFGVUFWUUJLQUV3QVVRQlZBRlVBTUFCUkFGWUFWZ0JTQUZFQWJBQmFBRUlBVWdBd0FERUFRZ0JhQUVnQVpBQkRBRlVBYXdCR0FFY0FWd0JWQUVZQVZBQmFBREFBU2dBeEFGRUFWUUJhQUVZQVVRQlhBRVlBYmdCUkFHc0FaQUJDQUZJQVZRQnNBRUlBVmdCWEFHUUFRd0JrQUVVQVJnQkZBR0VBTUFCR0FGSUFXZ0F3QUVvQVZ3QlJBRlVBVWdCQ0FGRUFWZ0JLQUc0QVVRQnJBRklBUWdCU0FHc0FNUUJDQUZRQVZRQkdBRU1BVWdBd0FFWUFSZ0JoQURBQVJnQlhBR1FBTUFCS0FGY0FVUUJWQUZZQVdnQlJBRllBWkFCdUFGRUFiQUJhQUVJQVVnQnNBRllBUWdCVkFESUFaQUJEQUZjQWF3QkdBRWNBVWdCVkFFWUFWd0JWQUZVQVNnQm9BRkVBVlFCV0FGb0FVUUJXQUZZQVVnQlJBR3dBYUFCQ0FGSUFhd0JzQUVJQVZnQlhBR1FBUXdCVkFHc0FSZ0JJQUdRQU1BQkdBR29BVVFCVkFFb0FSQUJSQUZVQVdnQktBRkVBVmdCS0FGSUFVUUJ1QUU0QVFnQlNBRlVBYkFCQ0FGWUFNUUJHQUVNQVZnQnJBRVlBUmdCWEFGVUFSZ0JTQUdRQU1BQktBRkFBVVFCVkFGb0FWZ0JSQUZZQVNnQnVBRkVBYXdCc0FFSUFVZ0JyQURFQVFnQldBR3dBUmdCREFGSUFNQUJHQUVZQVRnQkZBRVlBV0FCYUFEQUFSZ0F6QUZFQVZRQldBRm9BVVFCVkFEVUFRZ0JSQUd3QVNnQkNBRklBYkFCV0FFSUFWd0JyQUVZQVFnQmxBR3NBUmdCSEFGSUFWUUJHQUZjQVdnQXdBRW9BVUFCUkFGVUFXZ0JLQUZFQVZnQldBRklBVVFCdUFFb0FRZ0JTQUdzQWJBQkNBRlVBVndCa0FFTUFWUUF3QUVZQVJ3QldBRlVBUmdCWEFGb0FNQUJLQUVRQVVRQlZBRm9BYWdCUkFGY0FTZ0J1QUZFQWJRQjBBRUlBVWdCVkFERUFRZ0JXQUZnQVpBQkRBR01BYXdCR0FFWUFWd0JWQUVZQVV3QmFBREFBU2dCV0FGRUFWUUJhQUZZQVVRQldBRW9BYmdCUkFHd0FUZ0JDQUZJQWJBQldBRUlBVmdCc0FFWUFRd0JUQURBQVJnQkpBRllBVlFCR0FGWUFWUUJWQUVvQVZ3QlJBRlVBV2dCYUFGRUFWd0JPQUc0QVVRQnNBRW9BUWdCU0FHd0FiQUJDQUZVQWJRQmtBRUlBWlFCckFFWUFSd0JTQUZVQVJnQm9BR1FBTUFCS0FFd0FVUUJWQUZZQVNnQlJBRllBVmdCdUFGRUFXQUJzQUVJQVVnQlVBRklBUWdCVkFGY0FaQUJEQUZZQVJRQkdBRWNBVmdCVkFFWUFVd0JhQURBQVNnQkVBRkVBVlFCYUFIWUFVUUJWQURFQVFnQlJBR3NBWkFCQ0FGSUFWZ0JHQUVJQVZnQldBRVlBUXdCV0FHc0FSZ0JHQUZjQVZRQkdBRlFBV2dBd0FFb0FVd0JSQUZVQVdnQldBRkVBVmdCS0FHNEFVUUJ1QUZZQVFnQlNBR3NBVmdCQ0FGWUFiQUJHQUVNQVVnQnJBRVlBUlFCaEFEQUFSZ0JXQUZFQVZRQktBRlVBVVFCVkFGWUFSZ0JSQUZZQWNBQXpBRkVBYXdCd0FFSUFVZ0JWQURFQVFnQlZBRllBUmdCREFHSUFhd0JHQUVZQVlRQXdBRVlBVWdCa0FEQUFTZ0JEQUZFQVZRQmtBR29BVVFCV0FFNEFVZ0JSQUdzQVNnQkNBRklBUkFCQ0FFSUFWUUJHQUVZQVFnQmFBREFBUmdCRUFGRUFWUUJHQUVvQVVRQlZBRVlBYmdCUkFGVUFUZ0JDQUZFQVZRQnNBRUlBVVFCWEFHTUFad0JKQUVNQVFRQm5BRWtBUXdCQkFEMEFJQUFnQUNBQUlBQT0gICA=" + override suspend fun getChapters(manga: Manga, doc: Document): List { + val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.US) + return doc.select(selectChapter).mapChapters(reversed = true) { i, el -> + val a = el.selectFirstOrThrow("a") + val href = a.attrAsRelativeUrl("href") + val name = el.selectFirstOrThrow(".aqua-ch-item__name").text() + val dateText = el.selectFirst(".aqua-ch-item__time")?.text() + MangaChapter( + id = generateUid(href), + url = href + stylePage, + title = name, + number = i + 1f, + volume = 0, + uploadDate = parseChapterDate(dateFormat, dateText), + source = source, + scanlator = null, + branch = null, + ) + } } -} \ No newline at end of file +} From d977c00e4fbf3a8528fe9a3104452315621d579b Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:59:24 +0200 Subject: [PATCH 013/109] Update AquaManga.kt --- .../org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt index 751b65c0..0a229c50 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt @@ -99,7 +99,6 @@ internal class AquaManga(context: MangaLoaderContext) : MangaTag("Zombies", "zombies", source), ), availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), - availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA), ) override suspend fun getDetails(manga: Manga): Manga { From ab3abe1dcd59cfa765c7724b0228524ad8216319 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:31:54 +0200 Subject: [PATCH 014/109] Update AquaManga.kt --- .../org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt index 0a229c50..24cf61a4 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt @@ -141,7 +141,7 @@ internal class AquaManga(context: MangaLoaderContext) : val a = el.selectFirstOrThrow("a") val href = a.attrAsRelativeUrl("href") val name = el.selectFirstOrThrow(".aqua-ch-item__name").text() - val dateText = el.selectFirst(".aqua-ch-item__time")?.text() + val dateText = el.selectFirst(".aqua-ch-item__time")?.text() // doesn't really work idk MangaChapter( id = generateUid(href), url = href + stylePage, From 40d1870dbd3040ffa205c84e0fcedfb603b11e72 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:29:11 +0200 Subject: [PATCH 015/109] Update MangaFire.kt --- .../parsers/site/kotatsu/all/MangaFire.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt index 5f9acda7..85a089d6 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt @@ -39,7 +39,7 @@ internal abstract class MangaFireParser( SortOrder.UPDATED, // chapter update SortOrder.POPULARITY, // most views SortOrder.RATING, // rating score - SortOrder.NEWEST, // created manga + SortOrder.ADDED, // created manga SortOrder.ALPHABETICAL, // title asc SortOrder.RELEVANCE, // relevance sc SortOrder.POPULARITY_WEEK, @@ -70,6 +70,8 @@ internal abstract class MangaFireParser( isTagsExclusionSupported = true, isSearchSupported = true, isSearchWithFiltersSupported = true, + isYearSupported = true, + isYearRangeSupported = true, ) companion object { @@ -197,11 +199,14 @@ internal abstract class MangaFireParser( ) } + filter.yearFrom.let { url.append("&year_from=$it") } + filter.yearTo.let { url.append("&year_to=$it") } + val sortParam = when (order) { SortOrder.UPDATED -> "order[chapter_updated_at]=desc" SortOrder.POPULARITY -> "order[views_total]=desc" SortOrder.RATING -> "order[score]=desc" - SortOrder.NEWEST -> "order[created_at]=desc" + SortOrder.ADDED -> "order[created_at]=desc" SortOrder.ALPHABETICAL -> "order[title]=asc" SortOrder.RELEVANCE -> "order[relevance]=desc" SortOrder.POPULARITY_WEEK -> "order[views_7d]=desc" @@ -243,6 +248,7 @@ internal abstract class MangaFireParser( } return mangas } + override suspend fun getDetails(manga: Manga): Manga = coroutineScope { val hid = extractHid(manga.url) @@ -261,9 +267,16 @@ internal abstract class MangaFireParser( val synopsisHtml = data.optString("synopsisHtml", null) val status = data.optString("status", null) val type = data.optString("type", null) + + // Authors & Artists val authors = data.optJSONArray("authors")?.let { arr -> (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } }?.joinToString() + val artists = data.optJSONArray("artists")?.let { arr -> + (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } + }?.joinToString() + val authorSet = setOfNotNull(authors, artists).filter { it.isNotBlank() }.toSet() + val genres = data.optJSONArray("genres")?.let { arr -> (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } } @@ -307,7 +320,7 @@ internal abstract class MangaFireParser( manga.copy( title = title, coverUrl = cover ?: manga.coverUrl, - authors = setOfNotNull(authors), + authors = authorSet, description = richDescription, state = when (status?.lowercase()) { "releasing" -> MangaState.ONGOING From f264365723e2eab661847f606409735265f4c533 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:41:19 +0200 Subject: [PATCH 016/109] Update AquaManga.kt --- .../org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt index 24cf61a4..7cd932c0 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt @@ -136,7 +136,7 @@ internal class AquaManga(context: MangaLoaderContext) : } override suspend fun getChapters(manga: Manga, doc: Document): List { - val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.US) + val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") } return doc.select(selectChapter).mapChapters(reversed = true) { i, el -> val a = el.selectFirstOrThrow("a") val href = a.attrAsRelativeUrl("href") From 552674b15661565a48249c81cac815c9b4e3c423 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:06:50 +0200 Subject: [PATCH 017/109] simlify code --- .../site/kotatsu/en/hentais/MangaDistrict.kt | 203 +++--------------- 1 file changed, 33 insertions(+), 170 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/MangaDistrict.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/MangaDistrict.kt index f43a632e..20995789 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/MangaDistrict.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/MangaDistrict.kt @@ -6,62 +6,31 @@ import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.parsers.MadaraParser -import org.koitharu.kotatsu.parsers.site.tachiyomi.parsers.Madara.Genre -import org.koitharu.kotatsu.parsers.util.attrAsRelativeUrl -import org.koitharu.kotatsu.parsers.util.generateUid -import org.koitharu.kotatsu.parsers.util.mapNotNullToSet -import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.parsers.util.parseHtml -import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow -import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl -import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.koitharu.kotatsu.parsers.util.* import java.text.SimpleDateFormat +import java.util.EnumSet @MangaSourceParser("MANGADISTRICT", "MangaDistrict", "en", ContentType.HENTAI) -internal class MangaDistrict(context: MangaLoaderContext): - MadaraParser(context, MangaParserSource.MANGADISTRICT, "mangadistrict.com") { +internal class MangaDistrict(context: MangaLoaderContext) : + MadaraParser(context, MangaParserSource.MANGADISTRICT, "mangadistrict.com", pageSize = 30) { + override val tagPrefix = "publication-genre/" override val withoutAjax: Boolean = true override val datePattern: String = "MMMM d, yyyy" override val stylePage: String = "?style=list" - override fun parseMangaList(doc: Document): List { - return super.parseMangaList(doc).map { manga -> - manga.copy( - title = manga.title.cleanTitleIfNeeded() - ) - } - } - - private fun String.cleanTitleIfNeeded(): String { - return this - .replace(Regex("""\s*\(.*?\)\s*"""), "") // remove brackets - .replace(Regex("""\s*\[.*?]\s*"""), "") - .trim() - } - - override suspend fun fetchAvailableTags(): Set { - val doc = webClient.httpGet("https://$domain/series/").parseHtml() - val elements = doc.select("header ul.second-menu li a, div.genres_wrap ul li a") - - return elements.mapNotNullToSet { a -> - val href = a.attr("href") - .removeSuffix("/") - .substringAfterLast(tagPrefix, "") - - if (href.isBlank()) return@mapNotNullToSet null - - MangaTag( - key = href, - title = a.text().toTitleCase(), - source = source, - ) - } - } + override val availableSortOrders: Set = EnumSet.of( + SortOrder.RELEVANCE, + SortOrder.NEWEST, + SortOrder.ALPHABETICAL, + SortOrder.RATING, + SortOrder.POPULARITY, + SortOrder.UPDATED, + ) override suspend fun getChapters(manga: Manga, doc: Document): List { val dateFormat = SimpleDateFormat(datePattern, sourceLocale) @@ -112,132 +81,26 @@ internal class MangaDistrict(context: MangaLoaderContext): ?.toFloatOrNull() } - override suspend fun getDetails(manga: Manga): Manga { - val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() - - val base = super.getDetails(manga) + override suspend fun fetchAvailableTags(): Set { + val doc = webClient.httpGet("https://$domain/series/").parseHtml() + val elements = doc.select("header ul.second-menu li a, div.genres_wrap ul li a") - return base.copy( - title = doc.selectFirst("h1")?.text() ?: base.title, - tags = doc.select("div.genres-content a").mapToSet { a -> - MangaTag( - key = a.attr("href") - .removeSuffix("/") - .substringAfterLast('/'), - title = a.text().toTitleCase(), - source = source, - ) - } - ) - } + return elements.mapNotNullToSet { a -> + val href = a.attr("href") + .removeSuffix("/") + .substringAfterLast(tagPrefix, "") - private var genresList = listOf( - Genre("3D", "3d"), - Genre("Action", "action"), - Genre("Adapted to Anime", "adapted-to-anime"), - Genre("Adventure", "adventure"), - Genre("Aliens", "aliens"), - Genre("Animal Characteristics", "animal-characteristics"), - Genre("Based on Another Work", "based-on-another-work"), - Genre("BL", "bl"), - Genre("BL Uncensored", "bl-uncensored"), - Genre("Borderline H", "borderline-h"), - Genre("Cohabitation", "cohabitation"), - Genre("Collection of Stories", "collection-of-stories"), - Genre("Comedy", "comedy"), - Genre("Comics", "comics"), - Genre("Cooking", "cooking"), - Genre("Coworkers", "coworkers"), - Genre("Crime", "crime"), - Genre("Crossdressing", "crossdressing"), - Genre("Delinquents", "delinquents"), - Genre("Demons", "demons"), - Genre("Detectives", "detectives"), - Genre("Doujinshi", "doujinshi"), - Genre("Drama", "drama"), - Genre("Ecchi", "ecchi"), - Genre("Explicit Sex", "explicit-sex"), - Genre("Fantasy", "fantasy"), - Genre("Fetish", "fetish"), - Genre("Full Color", "full-color"), - Genre("Gender Bender", "gender-bender"), - Genre("Ghosts", "ghosts"), - Genre("GL", "gl"), - Genre("Gyaru", "gyaru"), - Genre("Harem", "harem"), - Genre("Historical", "historical"), - Genre("Horror", "horror"), - Genre("Incest", "incest"), - Genre("Isekai", "isekai"), - Genre("Japanese Webtoons", "japanese-webtoons"), - Genre("Josei", "josei"), - Genre("Light Novels", "light-novels"), - Genre("Mafia", "mafia"), - Genre("Magic", "magic"), - Genre("Magical Girl", "magical-girl"), - Genre("Manhua", "manhua"), - Genre("Manhwa", "manhwa"), - Genre("Martial Arts", "martial-arts"), - Genre("Mature Romance", "mature-romance"), - Genre("Mecha", "mecha"), - Genre("Medical", "medical"), - Genre("Military", "military"), - Genre("Monster Girls", "monster-girls"), - Genre("Monsters", "monsters"), - Genre("Music", "music"), - Genre("Mystery", "mystery"), - Genre("Ninja", "ninja"), - Genre("Nudity", "nudity"), - Genre("One Shot", "one-shot"), - Genre("Person in a Strange World", "person-in-a-strange-world"), - Genre("Police", "police"), - Genre("Psychological", "psychological"), - Genre("Reincarnation", "reincarnation"), - Genre("Reverse Harem", "reverse-harem"), - Genre("Romance", "romance"), - Genre("Salaryman", "salaryman"), - Genre("Samurai", "samurai"), - Genre("School Life", "school-life"), - Genre("Sci Fi", "sci-fi"), - Genre("Seinen", "seinen"), - Genre("Sexual Abuse", "sexual-abuse"), - Genre("Sexual Content", "sexual-content"), - Genre("Shoujo", "shoujo"), - Genre("Shoujo-ai", "shoujo-ai"), - Genre("Shounen", "shounen"), - Genre("Shounen-ai", "shounen-ai"), - Genre("Siblings", "siblings"), - Genre("Slice of Life", "slice-of-life"), - Genre("Smut", "smut"), - Genre("Sports", "sports"), - Genre("Summoned Into Another World", "summoned-into-another-world"), - Genre("Superheroes", "superheroes"), - Genre("Supernatural", "supernatural"), - Genre("Survival", "survival"), - Genre("Thriller", "thriller"), - Genre("Time Travel", "time-travel"), - Genre("Transfer Students", "transfer-students"), - Genre("Uncensored", "uncensored"), - Genre("Vampires", "vampires"), - Genre("Violence", "violence"), - Genre("Virtual Reality", "virtual-reality"), - Genre("Web Novels", "web-novels"), - Genre("Webtoons", "webtoons"), - Genre("Western", "western"), - Genre("Work Life", "work-life"), - Genre("Yaoi", "yaoi"), - Genre("Yuri", "yuri"), - Genre("Zombies", "zombies"), - ) + if (href.isBlank()) return@mapNotNullToSet null - override suspend fun getFilterOptions(): MangaListFilterOptions = - MangaListFilterOptions( - availableTags = genresList.mapToSet { genre -> - MangaTag( - key = genre.id, - title = genre.name, - source = source - ) - } - ) + MangaTag( + key = href, + title = a.text() + .replace(Regex("""\s*\(\d+\)"""), "") + .replace(Regex("""[^\x20-\x7E]"""), "") + .trim() + .toTitleCase(), + source = source, + ) + } + } } From 39f781780cc3f64ca84100e79948312f9f9e58ef Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:58:11 +0200 Subject: [PATCH 018/109] Update NHentaiParser.kt --- .../site/kotatsu/all/hentais/NHentaiParser.kt | 831 +++++++++++++++++- 1 file changed, 803 insertions(+), 28 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt index c0841369..f23e3df5 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/hentais/NHentaiParser.kt @@ -10,7 +10,8 @@ import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.* import java.util.* -private const val ONE_IMG_SERVER = "1" + +// removed first server, it has always problems private const val TWO_IMG_SERVER = "2" private const val THREE_IMG_SERVER = "3" private const val FOUR_IMG_SERVER = "4" @@ -28,8 +29,11 @@ internal class NHentaiParser(context: MangaLoaderContext) : private var displayFullTitle: Boolean = true override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, + SortOrder.UPDATED, // latest (default) + SortOrder.POPULARITY, // popular (all time) + SortOrder.POPULARITY_TODAY, + SortOrder.POPULARITY_WEEK, + SortOrder.POPULARITY_MONTH, ) override val filterCapabilities = MangaListFilterCapabilities( @@ -39,12 +43,11 @@ internal class NHentaiParser(context: MangaLoaderContext) : private val preferredServerKey = ConfigKey.PreferredImageServer( presetValues = mapOf( - ONE_IMG_SERVER to "First server", - TWO_IMG_SERVER to "Second server", - THREE_IMG_SERVER to "Third server", - FOUR_IMG_SERVER to "Fourth server", + TWO_IMG_SERVER to "First server", + THREE_IMG_SERVER to "Second server", + FOUR_IMG_SERVER to "Third server", ), - defaultValue = ONE_IMG_SERVER, + defaultValue = TWO_IMG_SERVER, ) override fun onCreateConfig(keys: MutableCollection>) { @@ -64,9 +67,746 @@ internal class NHentaiParser(context: MangaLoaderContext) : ) } + private val nhTagMap: Map by lazy { + val raw = """ +2937=big breasts +35762=sole female +35763=sole male +8010=group +14283=anal +19440=lolicon +24201=stockings +10314=schoolgirl uniform +13720=nakadashi +29859=blowjob +8378=glasses +20905=full color +32341=shotacon +27553=rape +15658=bondage +23895=yaoi +27473=mosaic censorship +13989=ahegao +22942=incest +21712=males only +1207=milf +19018=dark skin +22945=double penetration +25614=paizuri +20035=x-ray +779=futanari +23237=tankoubon +21572=multi-work series +20525=defloration +14971=sex toys +8653=netorare +3735=swimsuit +19954=yuri +15348=ffm threesome +8368=full censorship +15408=femdom +29224=impregnation +29013=dilf +85295=twintails +31044=collar +85288=ponytail +24380=pantyhose +9260=cheating +28031=sister +16828=hairy +31880=bbm +30555=big penis +15782=crossdressing +31775=tentacles +27384=mind break +19175=bikini +8739=story arc +30473=muscle +24102=lactation +7752=schoolboy uniform +20617=mind control +9083=big ass +29023=tomgirl +81774=kemonomimi +1590=sweating +9162=masturbation +7256=mmf threesome +28550=teacher +190=maid +8693=uncensored +19899=exhibitionism +6343=pregnant +8050=females only +6817=unusual pupils +25871=lingerie +10988=anthology +20282=footjob +15853=mother +15785=harem +14072=huge breasts +30035=gender bender +1643=kissing +130025=anal intercourse +1033=handjob +12824=condom +31386=catgirl +10476=urination +3666=garter belt +26130=fingering +81707=beauty mark +22079=drugs +105833=gloves +4435=gag +25601=small breasts +5820=piercing +12695=prostitution +16228=demon girl +7155=cunnilingus +22950=tanlines +832=elf +31012=blindfold +17773=kimono +2820=scat +29182=blackmail +23132=bunny girl +32484=stomach deformation +2515=virginity +27063=filming +7142=bbw +21989=inflation +88846=horns +104227=tail +26953=bukkake +28800=bloomers +25050=gyaru +24676=rimjob +23632=big areolae +16533=sleeping +73750=bald +18567=monster +35972=sole dickgirl +18328=thigh high boots +5810=strap-on +29565=school swimsuit +32996=deepthroat +370=business suit +7550=monster girl +1067=inseki +50585=webtoon +12523=bestiality +27697=leotard +30645=dick growth +29631=inverted nipples +29366=tomboy +24412=bodysuit +15492=scanmark +9406=enema +35970=dickgirl on dickgirl +29399=daughter +18613=military +11941=replaced +6525=nurse +9661=cervix penetration +33129=slave +4573=corruption +5529=urethra insertion +10542=snuff +683=squirting +51399=crotch tattoo +122908=very long hair +7838=magical girl +24726=apron +23183=breast expansion +20074=latex +28426=hairy armpits +27217=guro +31285=fox girl +106119=no penetration +24764=drunk +9990=prostate massage +35968=dickgirl on male +2956=old man +32752=shibari +6900=miko +2153=wings +706=birth +10794=breast feeding +14069=ryona +25822=smell +5357=humiliation +5962=spanking +2531=transformation +21538=bike shorts +31101=incomplete +32745=chikan +16236=shemale +36957=bisexual +26952=tall girl +25663=oppai loli +7995=big nipples +32602=fisting +106733=hair buns +1088=bdsm +21283=masked face +15225=blowjob face +2633=leg lock +27378=artbook +35971=male on dickgirl +27112=tiara +107705=facial hair +24933=eyepatch +4549=torture +30206=tribadism +1037=oni +89056=hidden sex +13136=facesitting +3391=nun +25766=gokkun +5200=pegging +17531=cosplaying +28521=voyeurism +19479=nipple fuck +17349=tracksuit +22221=blood +50505=oyakodon +50486=tail plug +560=twins +23965=chloroform +15425=vore +25457=possession +129668=eye-covering bang +24984=orgasm denial +144644=extraneous ads +28589=hotpants +17752=foot licking +32282=piss drinking +19390=cousin +32589=feminization +11376=body modification +20362=gyaru-oh +28778=large insertions +27720=smegma +10811=double vaginal +3614=triple penetration +3455=chastity belt +2452=scar +31319=yandere +7354=amputee +28335=giantess +26848=waitress +28349=cbt +24967=sumata +104893=vtuber +8516=emotionless sex +26380=demon +17591=robot +17801=solo action +13640=frottage +25996=gaping +23035=aunt +23967=huge penis +31846=body writing +25744=cheerleader +24708=cowgirl +25085=swinging +18322=brother +101724=leash +10354=milking +97795=pixie cut +11089=body swap +32224=eggs +10606=pasties +3947=onahole +14573=tall man +10604=dog +14362=low lolicon +15242=lab coat +4935=farting +13468=shimapan +5620=double anal +14138=freckles +50390=josou seme +15119=dog girl +93324=fishnets +22025=prolapse +15471=asphyxiation +21774=human pet +31337=kunoichi +15712=eyemask +30126=big clit +92409=thick eyebrows +109360=cumflation +7208=catboy +31687=randoseru +24529=bride +19561=big balls +24450=chinese dress +121738=focus anal +22967=diaper +29347=miniguy +29001=parasite +25296=armpit licking +6220=orc +7546=witch +30895=sunglasses +7372=corset +28119=nose hook +8429=machine +7684=armpit sex +14516=wolf girl +15045=niece +13882=tutor +8391=public use +30811=christmas +104245=small penis +266=sundress +17501=phimosis +17800=tickling +25794=widow +7288=vomit +1215=unusual teeth +72471=dickgirls only +107503=soushuuhen +138044=exposed clothing +1352=slime +31986=age regression +23917=long tongue +24115=angel +114993=shimaidon +13722=moral degeneration +26898=age progression +27120=selfcest +7577=vampire +17676=ghost +88103=clothed female nude male +13515=coach +141098=nipple stimulation +9116=unbirth +5936=time stop +18420=all the way through +72139=clothed paizuri +27530=ball sucking +16518=coprophagia +28869=stuck in wall +2527=bandages +24621=insect +11399=metal armor +106006=large tattoo +3843=fundoshi +20120=multiple paizuri +8400=goblin +129321=mesuiki +124610=mouth mask +10693=dougi +31371=mecha girl +21450=minigirl +10685=double blowjob +118056=petplay +20789=policewoman +3031=underwater +31173=first person perspective +78262=shaved head +19064=pubic stubble +14280=bunny boy +25949=gothic lolita +23463=wrestling +16947=horse +11247=skinsuit +11073=living clothes +30786=watermarked +23073=assjob +52826=dark sclera +107478=drill hair +23225=non-h +109930=domination loss +20170=poor grammar +138200=gender change +16759=artistcg +80978=nudity only +15749=oil +30176=petrification +25848=human cattle +559=ttf threesome +14010=snake girl +11276=multiple penises +90671=original +18024=touhou project +1841=kantai collection +35605=fate grand order +20925=the idolmaster +972=granblue fantasy +78245=azur lane +17137=neon genesis evangelion +3185=love live +391=girls und panzer +11219=pokemon +15021=sailor moon +4505=mahou shoujo lyrical nanoha +128408=blue archive +10222=fate stay night +27431=to love-ru +13159=naruto +123503=genshin impact +3984=sword art online +3603=street fighter +22174=one piece +16285=puella magi madoka magica +91195=princess connect +12232=my hero academia +3163=king of fighters +26172=k-on +7259=touken ranbu +19080=code geass +37544=love live sunshine +17077=cardcaptor sakura +27547=the melancholy of haruhi suzumiya +13508=final fantasy vii +10954=shingeki no kyojin +25430=vocaloid +32687=free +4577=toheart2 +22146=dead or alive +20025=gochuumon wa usagi desu ka +8485=dragon ball z +5037=bleach +3218=bakemonogatari +12624=ore no imouto ga konna ni kawaii wake ga nai +37109=kono subarashii sekai ni syukufuku o +4369=monster hunter +127065=hololive +74788=girls frontline +24886=fate kaleid liner prisma illya +6999=toaru kagaku no railgun +22032=boku wa tomodachi ga sukunai +18350=ragnarok online +21674=dragon quest iii +14345=ojamajo doremi +7832=darkstalkers +24135=ah my goddess +32394=samurai spirits +1283=queens blade +16639=haikyuu +13924=yu-gi-oh +79467=kimetsu no yaiba +18238=danganronpa +26336=yu-gi-oh zexal +16984=persona 4 +18569=kuroko no basuke +1910=smile precure +30587=sakura taisen +16166=mahou sensei negima +12285=ranma 12 +8470=infinite stratos +32363=toaru majutsu no index +22708=saki +8708=to heart +108082=arknights +16707=detective conan +22210=guilty gear +947=gundam seed destiny +22677=tenchi muyo +23429=pretty cure +18512=strike witches +31027=lucky star +7408=league of legends +394=love hina +23201=kanon +27704=amagami +127052=nijisanji +70802=kemono friends +52098=persona 5 +22215=super robot wars +27567=hayate no gotoku +35251=osomatsu-san +7633=pripara +34823=ensemble stars +37914=re zero kara hajimeru isekai seikatsu +74918=bang dream +15041=martian successor nadesico +24783=dragon ball +120519=love live nijigasaki high school idol club +2803=love plus +5085=senki zesshou symphogear +28474=zero no tsukaima +15197=gundam build fighters +15427=dragon quest iv +1163=rozen maiden +23859=yu-gi-oh arc-v +75023=dragon quest xi +2112=dungeon ni deai o motomeru no wa machigatteiru darou ka +36418=voiceroid +28281=mitsudomoe +11624=the legend of zelda +14694=fullmetal alchemist +16847=dragon quest v +2497=urusei yatsura +5671=tengen toppa gurren lagann +22754=amagi brilliant park +20606=tsukihime +5165=gundam build fighters try +4114=macross frontier +20763=inazuma eleven +14550=sister princess +19083=jojos bizarre adventure +21052=fate hollow ataraxia +29922=teitoku +51810=gudao +16643=producer +13848=reimu hakurei +25125=asuka langley soryu +17279=sakuya izayoi +10496=patchouli knowledge +37739=shielder +3206=shinji ikari +38068=gran +4675=sanae kochiya +21779=rei ayanami +14040=fate testarossa +3870=flandre scarlet +23902=remilia scarlet +21688=atago +11373=marisa kirisame +35128=kashima +17154=sakura kinomoto +31462=satori komeiji +30080=kaga +10802=alice margatroid +17017=aya shameimaru +17862=yukari yakumo +5340=shimakaze +18935=nanoha takamachi +18896=shirou emiya +16555=rin tosaka +16130=rito yuuki +15890=reisen udongein inaba +7724=takao +27060=jeanne darc +78989=jeanne alter +7718=naruto uzumaki +5337=nami +22975=chun-li +17502=illyasviel von einzbern +20111=tifa lockhart +21131=youmu konpaku +18026=kazuto kirigaya +92923=shikikan +29856=saber +71442=minamoto no raikou +1843=asuna yuuki +51419=gudako +7488=mai shiranui +9835=koishi komeiji +16916=kasumi +30026=maki nishikino +143975=sensei +26906=izuku midoriya +37275=scathach +7696=momiji inubashiri +38039=astolfo +27794=mikoto misaka +20062=hamakaze +78285=artoria pendragon +34860=katsuki bakugou +3328=homura akemi +37687=djeeta +32200=suzuya +21108=rin shibuya +35964=nico yazawa +27494=levi ackerman +609=eren jaeger +11920=sakura haruno +20427=sailor mercury +24714=chino kafuu +31456=mikan yuuki +866=koyomi araragi +12149=kyousuke kousaka +277=haruka nanase +19926=haruna +3763=haruhi suzumiya +26427=mio akiyama +25439=hinata hyuga +17811=ran yakumo +14857=kongou +18548=kotori minami +32364=rider +15641=madoka kaname +2613=hong meiling +491=makoto tachibana +20702=koakuma +15315=tomoyo daidouji +10730=shigure +14265=touma kamijou +80311=bb +4203=mami tomoe +37706=kazuma satou +33070=umi sonoda +27172=yuyuko saigyouji +3353=yuuka kazami +2078=nagato +6311=arisu tachibana +647=belldandy +9274=maya +24889=sena kashiwazaki +15125=golden darkness +6555=sailor jupiter +25695=mika jougasaki +50929=shuten douji +33077=sailor mars +8293=minami nitta +7451=lelouch vi britannia +389=rika jougasaki +22469=prinz eugen +16108=azusa nakano +12812=tenryuu +7311=ami mizuno +6642=byakuren hijiri +7097=suwako moriya +19172=miki hoshii +9657=ayane +29433=c.c. +25220=sakura matou +14499=tsunade +10665=tenshi hinanai +16564=miku hatsune +29190=kallen stadtfeld +3312=kirino kousaka +1234=yuki nagato +26261=ranma saotome +19002=rin kaenbyou +12748=nico robin +32765=rin matsuoka +4241=fumika sagisawa +1729=tamaki kousaka +23997=ruri gokou +29684=sailor venus +19160=nitori kawashiro +27302=uzuki shimamura +23216=android 18 +8489=hibiki +7333=suguha kirigaya +1267=kodaka hasegawa +2345=morrigan aensland +9371=yamato +26087=inazuma +27532=archer +26587=miho nishizumi +12346=utsuho reiuji +37108=megumin +22407=takane shijou +15914=sasuke uchiha +2774=kyouko sakura +80930=abigail williams +81288=gudao | ritsuka fujimaru +73756=nightingale +6109=eri ayase +27492=akagi +17899=sakura kasugano +32137=cirno +11760=yui kotegawa +75029=eli ayase +11740=sailor moon +49158=narmaya +29693=ikazuchi +126586=aether +20918=iori minase +24832=misato katsuragi +2883=kasen ibara +6932=souji okita +28555=tamamo-no-mae +14428=kokoa hoto +26783=taihou +12763=rumia +401=nakoruru +72475=musashi miyamoto +23122=maho nishizumi +29188=eirin yagokoro +466=usagi tsukino +29638=kyon +15995=makoto kino +11744=amatsukaze +6175=cammy white +30331=ichika orimura +23473=mikuru asahina +28807=ruri hoshino +2572=hatate himekaidou +15291=chen +23386=fujiwara no mokou +9237=shoukaku +28763=tewi inaba +23851=gilgamesh +10672=aqua +9702=ro-500 +31074=keine kamishirasawa +32443=charlotte dunois +2196=sayaka miki +1645=zuikaku +5925=akatsuki +4196=hestia +33171=shiho nishizumi +19534=hayate yagami +79507=belfast +12433=kaede takagaki +12872=warrior +8170=len kagamine +50415=rem +14409=momoka sakurai +2211=mari illustrious makinami +99075=kokkoro +1907=rei hino +15651=miyu edelfelt +26169=musashi +8053=lum +50596=you watanabe +9883=kagami hiiragi +24509=darjeeling +11992=lala satalin deviluke +32683=hachiman hikigaya +31076=kuroko shirai +20836=red saber +12902=isuzu sento +10379=bianca whitaker +16181=nozomi toujou +27774=bismarck +28219=yui hirasawa +1271=momo velia deviluke +49852=subaru natsuki +5918=shinobu oshino +28056=link +25605=rangiku matsumoto +35313=cagliostro +18453=hero +75102=nozomi tojo +20722=mutsu +29170=yuma tsukumo +9486=nue houjuu +33049=ritsuko akizuki +23626=murakumo +20323=tsumugi kotobuki +16566=ritsu tainaka +14016=yuu narukami +11609=yoko ritona +107011=chloe von einzbern +52132=riko sakurauchi +32114=onpu segawa +11924=kagerou imaizumi + """.trimIndent() + raw.lineSequence() + .mapNotNull { line -> + val parts = line.split('=') + if (parts.size == 2 && parts[0].isNotBlank() && parts[1].isNotBlank()) { + parts[0] to parts[1] + } else null + }.toMap() + } override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = emptySet(), + availableTags = nhTagMap.map { (id, name) -> + MangaTag( + title = name.replace("-", " ").replaceFirstChar { it.uppercase() }, + key = id, // numeric tag ID + source = source, + ) + }.toSet(), availableLocales = setOf( Locale.ENGLISH, Locale.JAPANESE, @@ -74,7 +814,6 @@ internal class NHentaiParser(context: MangaLoaderContext) : ), ) - private suspend fun ensureNhConfig() { if (imageServer.isBlank() || thumbServer.isBlank()) { try { @@ -92,6 +831,7 @@ internal class NHentaiParser(context: MangaLoaderContext) : } } + override suspend fun getListPage( page: Int, order: SortOrder, @@ -99,11 +839,6 @@ internal class NHentaiParser(context: MangaLoaderContext) : ): List { ensureNhConfig() - val langQuery = filter.locale?.let { loc -> - val langKey = loc.language.lowercase() - LANG_MAP[langKey]?.let { "language:$it" } - } - // Direct ID search val directId = extractGalleryId(filter.query) if (directId != null) { @@ -114,24 +849,39 @@ internal class NHentaiParser(context: MangaLoaderContext) : } } - val isLatest = order == SortOrder.UPDATED && filter.query.isNullOrBlank() && langQuery == null && filter.tags.isEmpty() + val selectedTag = filter.tags.firstOrNull() + if (selectedTag != null) { + val url = "https://$domain/api/v2/galleries/tagged?tag_id=${selectedTag.key}&page=$page" + val json = webClient.httpGet(url).parseJson() + val items = json.getJSONArray("result") + return (0 until items.length()).map { i -> items.getJSONObject(i).toManga() } + } - val url = buildString { - append("https://$domain/api/v2/") - if (isLatest) { - append("galleries?page=$page") - } else { - val queryParts = mutableListOf() - if (!filter.query.isNullOrBlank()) queryParts.add(filter.query!!) - if (langQuery != null) queryParts.add(langQuery) + val lang = filter.locale?.let { loc -> + LANG_MAP[loc.language] + } - val finalQuery = queryParts.joinToString(" ").ifBlank { "\"\"" } + val url = when { + // Search with language + lang != null -> { val sort = when (order) { - SortOrder.UPDATED -> "date" SortOrder.POPULARITY -> "popular" else -> "date" } - append("search?query=${finalQuery.urlEncoded()}&sort=$sort&page=$page") + "https://$domain/api/v2/search?query=language:$lang&sort=$sort&page=$page" + } + // Latest + order == SortOrder.UPDATED -> "https://$domain/api/v2/galleries?page=$page" + // Popular (all / today / week / month) + else -> { + val sort = when (order) { + SortOrder.POPULARITY_TODAY -> "popular-today" + SortOrder.POPULARITY_WEEK -> "popular-week" + SortOrder.POPULARITY_MONTH -> "popular-month" + SortOrder.POPULARITY -> "popular" + else -> "popular" + } + "https://$domain/api/v2/search?query=%22%22&sort=$sort&page=$page" } } @@ -236,8 +986,33 @@ internal class NHentaiParser(context: MangaLoaderContext) : val groups = getTagsOfType("group") val author = artists.joinToString(", ").ifBlank { groups.joinToString(", ") } + var langName: String? + var translated = false + val languageCandidates = mutableListOf() + + for (tag in tags) { + val parts = tag.key.split(":", limit = 2) + if (parts.size == 2 && parts[0] == "language") { + val name = parts[1] + when { + name.equals("translated", ignoreCase = true) -> translated = true + else -> languageCandidates.add(name) + } + } + } + + langName = languageCandidates.firstOrNull { it.equals("chinese", ignoreCase = true) } + ?: languageCandidates.firstOrNull { it.equals("japanese", ignoreCase = true) } + ?: languageCandidates.firstOrNull { it.equals("english", ignoreCase = true) } + ?: languageCandidates.firstOrNull() + val description = buildString { - append("Pages: $numPages\n") + langName?.let { + val displayLang = it.replaceFirstChar { c -> c.uppercase() } + append(if (translated) "$displayLang (translated)" else displayLang) + append("\n") + } + append("Pages: $numPages") } return Manga( From 09897ed80e6a2e9e128f1ec52cde03ece13e76d6 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:23:11 +0200 Subject: [PATCH 019/109] fix for cloudfare captacha? --- .../koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt index 146673f0..5a1d0609 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.parsers.MadaraParser +import okhttp3.Interceptor @MangaSourceParser("MANHUAUS", "Manhuaus", "en") internal class Manhuaus(context: MangaLoaderContext): @@ -23,6 +24,12 @@ internal class Manhuaus(context: MangaLoaderContext): isYearSupported = false, ) + override fun intercept(chain: Interceptor.Chain) = chain.proceed( + chain.request().newBuilder() + .header("Referer", "https://$domain/") + .build() + ) + override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = availableTags(), ) From b584c07164ae3727f178f606483ee9588e263fb5 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:24:26 +0200 Subject: [PATCH 020/109] Update Manhuaus.kt --- .../koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt index 5a1d0609..146673f0 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt @@ -8,7 +8,6 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.parsers.MadaraParser -import okhttp3.Interceptor @MangaSourceParser("MANHUAUS", "Manhuaus", "en") internal class Manhuaus(context: MangaLoaderContext): @@ -24,12 +23,6 @@ internal class Manhuaus(context: MangaLoaderContext): isYearSupported = false, ) - override fun intercept(chain: Interceptor.Chain) = chain.proceed( - chain.request().newBuilder() - .header("Referer", "https://$domain/") - .build() - ) - override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = availableTags(), ) From 528ff67fd9266c4c0bdcd540ff78b1f7c8da14b4 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:36:57 +0200 Subject: [PATCH 021/109] added uploaddate support --- .../parsers/site/kotatsu/all/ProjectSuki.kt | 134 ++++++++++++++---- 1 file changed, 110 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt index 3665034d..205e6835 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt @@ -32,6 +32,8 @@ import java.util.EnumSet import java.util.LinkedHashMap import java.util.LinkedHashSet import java.util.Locale +import java.text.SimpleDateFormat +import java.util.Calendar @MangaSourceParser("PROJECTSUKI", "ProjectSuki") internal class ProjectSuki(context: MangaLoaderContext) : @@ -44,7 +46,6 @@ internal class ProjectSuki(context: MangaLoaderContext) : override val availableSortOrders: Set = EnumSet.of( SortOrder.UPDATED, SortOrder.POPULARITY, - SortOrder.RELEVANCE, ) override val filterCapabilities: MangaListFilterCapabilities @@ -178,6 +179,11 @@ internal class ProjectSuki(context: MangaLoaderContext) : val bookId = manga.url.toBookId() ?: document.location().toBookId() ?: manga.url.substringAfterLast('/') + val fallbackTimestamp = document.selectFirst("meta[property='og:updated_time']") + ?.attr("content") + ?.toLongOrNull() + ?.times(1000L) + val details = parseDetailsTable(document) val title = document.selectFirst("h2[itemprop=title]")?.text()?.nullIfEmpty() ?: document.selectFirst("h2")?.text()?.nullIfEmpty() @@ -191,7 +197,7 @@ internal class ProjectSuki(context: MangaLoaderContext) : val state = parseState(details["status"]) val genres = parseGenres(document) val altTitles = setOfNotNull(details["alt titles"] ?: details["alternative titles"]) - val chapters = parseChapters(document) + val chapters = parseChapters(document, fallbackTimestamp) return manga.copy( title = title, @@ -264,35 +270,115 @@ internal class ProjectSuki(context: MangaLoaderContext) : } } - private fun parseChapters(document: Document): List { + private fun parseChapters(document: Document, fallbackTimestamp: Long? = null): List { val chaptersMap = LinkedHashMap() - document.select("a[href*=/read/]").forEach { anchor -> - val parts = anchor.absUrl("href").toChapterParts() ?: return@forEach - val key = "${parts.bookId}/${parts.chapterId}" - if (key in chaptersMap) return@forEach - - val parentText = anchor.parent()?.text().orEmpty() - val title = anchor.text().trim().nullIfEmpty() - val chapterNumber = parseChapterNumber(title ?: parentText) - - val chapter = MangaChapter( - id = generateUid(key), - title = title, - number = chapterNumber, - volume = 0, - url = "/read/${parts.bookId}/${parts.chapterId}/1", - scanlator = null, - uploadDate = 0L, - branch = null, - source = source, - ) - chaptersMap[key] = chapter + val table = document.select("table").firstOrNull { table -> + table.select("tr").firstOrNull()?.let { row -> + row.select("td, th").any { cell -> + cell.text().trim().matches(Regex("added|date", RegexOption.IGNORE_CASE)) + } + } ?: false + } + + if (table != null) { + val headerRow = table.select("tr").first() + val headers = headerRow!!.select("td, th").map { it.text().trim().lowercase() } + + val dateCol = headers.indexOfFirst { it == "added" || it == "date" } + val chapterCol = headers.indexOfFirst { it.matches(Regex("chapter|ch\\.|name|#")) } + .takeIf { it >= 0 } ?: 0 + + val dataRows = table.select("tbody tr").ifEmpty { table.select("tr").drop(1) } + for (row in dataRows) { + val cells = row.select("td, th") + if (cells.size <= maxOf(chapterCol, dateCol)) continue + + val chapterCell = cells[chapterCol] + val link = chapterCell.selectFirst("a[href]") ?: continue + val parts = link.absUrl("href").toChapterParts() ?: continue + val key = "${parts.bookId}/${parts.chapterId}" + if (key in chaptersMap) continue + + val title = link.text().trim().nullIfEmpty() + ?: chapterCell.text().trim().nullIfEmpty() + val number = parseChapterNumber(title ?: "") + + val dateText = if (dateCol >= 0) cells[dateCol].text().trim() else "" + val uploadDate = parseChapterDate(dateText) ?: fallbackTimestamp ?: 0L + + chaptersMap[key] = MangaChapter( + id = generateUid(key), + title = title, + number = number, + volume = 0, + url = "/read/${parts.bookId}/${parts.chapterId}/1", + scanlator = null, + uploadDate = uploadDate, + branch = null, + source = source, + ) + } + } else { + document.select("a[href*=/read/]").forEach { anchor -> + val parts = anchor.absUrl("href").toChapterParts() ?: return@forEach + val key = "${parts.bookId}/${parts.chapterId}" + if (key in chaptersMap) return@forEach + + val title = anchor.text().trim().nullIfEmpty() + val number = parseChapterNumber(title ?: "") + chaptersMap[key] = MangaChapter( + id = generateUid(key), + title = title, + number = number, + volume = 0, + url = "/read/${parts.bookId}/${parts.chapterId}/1", + scanlator = null, + uploadDate = fallbackTimestamp ?: 0L, + branch = null, + source = source, + ) + } } return chaptersMap.values.sortedBy { it.number } } + private fun parseChapterDate(text: String): Long? { + if (text.isBlank()) return null + + val relativeRegex = Regex("""(\d+)\s+(years?|months?|weeks?|days?|hours?|mins?|minutes?|seconds?|sec)\s+ago""", RegexOption.IGNORE_CASE) + relativeRegex.matchEntire(text.trim())?.let { match -> + val number = match.groupValues[1].toInt() + val unit = match.groupValues[2].lowercase() + val cal = Calendar.getInstance() + when { + unit.startsWith("year") -> cal.add(Calendar.YEAR, -number) + unit.startsWith("month") -> cal.add(Calendar.MONTH, -number) + unit.startsWith("week") -> cal.add(Calendar.DAY_OF_MONTH, -number * 7) + unit.startsWith("day") -> cal.add(Calendar.DAY_OF_MONTH, -number) + unit.startsWith("hour") -> cal.add(Calendar.HOUR, -number) + unit.startsWith("min") -> cal.add(Calendar.MINUTE, -number) + unit.startsWith("sec") -> cal.add(Calendar.SECOND, -number) + } + return cal.timeInMillis + } + + val formats = listOf( + SimpleDateFormat("MMMM dd, yyyy", Locale.ENGLISH), + SimpleDateFormat("MMM dd, yyyy", Locale.ENGLISH), + SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH), + SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH), + ) + for (fmt in formats) { + try { + return fmt.parse(text)?.time + } catch (_: Exception) {} + } + + return null + } + private fun parseChapterNumber(text: String): Float { val match = CHAPTER_NUMBER_REGEX.find(text) ?: return 0f val main = match.groupValues.getOrNull(1)?.toFloatOrNull() ?: return 0f From c8f59c2196296b045713ca98339625de4b2bf18b Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:53:30 +0200 Subject: [PATCH 022/109] use custom date --- .../parsers/site/kotatsu/en/AquaManga.kt | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt index 7cd932c0..079c25df 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/AquaManga.kt @@ -136,23 +136,60 @@ internal class AquaManga(context: MangaLoaderContext) : } override suspend fun getChapters(manga: Manga, doc: Document): List { - val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") } return doc.select(selectChapter).mapChapters(reversed = true) { i, el -> val a = el.selectFirstOrThrow("a") val href = a.attrAsRelativeUrl("href") val name = el.selectFirstOrThrow(".aqua-ch-item__name").text() - val dateText = el.selectFirst(".aqua-ch-item__time")?.text() // doesn't really work idk + val dateText = el.selectFirst(".aqua-ch-item__time")?.text()?.trim() MangaChapter( id = generateUid(href), url = href + stylePage, title = name, number = i + 1f, volume = 0, - uploadDate = parseChapterDate(dateFormat, dateText), + uploadDate = parseChapterDate(dateText), source = source, scanlator = null, branch = null, ) } } + + private fun parseChapterDate(text: String?): Long { + if (text.isNullOrBlank()) return 0L + + val relativeRegex = Regex( + """(\d+)\s+(years?|months?|weeks?|days?|hours?|mins?|minutes?|sec(?:onds?)?)\s+ago""", + RegexOption.IGNORE_CASE + ) + val match = relativeRegex.matchEntire(text) + if (match != null) { + val number = match.groupValues[1].toInt() + val unit = match.groupValues[2].lowercase() + val cal = Calendar.getInstance() + when { + unit.startsWith("year") -> cal.add(Calendar.YEAR, -number) + unit.startsWith("month") -> cal.add(Calendar.MONTH, -number) + unit.startsWith("week") -> cal.add(Calendar.DAY_OF_MONTH, -number * 7) + unit.startsWith("day") -> cal.add(Calendar.DAY_OF_MONTH, -number) + unit.startsWith("hour") -> cal.add(Calendar.HOUR, -number) + unit.startsWith("min") -> cal.add(Calendar.MINUTE, -number) + unit.startsWith("sec") -> cal.add(Calendar.SECOND, -number) + } + return cal.timeInMillis + } + + val formats = listOf( + SimpleDateFormat("MMM d, yyyy", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }, + SimpleDateFormat("MMM dd, yyyy", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }, + SimpleDateFormat("MMMM d, yyyy", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }, + ) + for (fmt in formats) { + try { + return fmt.parse(text)?.time ?: 0L + } catch (_: Exception) {} + } + + return 0L + } } From 426c23c2069bd616140513730d751ca474ae8737 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:09:26 +0200 Subject: [PATCH 023/109] Update ProjectSuki.kt --- .../kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt index 205e6835..08fd7947 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/ProjectSuki.kt @@ -365,10 +365,10 @@ internal class ProjectSuki(context: MangaLoaderContext) : } val formats = listOf( - SimpleDateFormat("MMMM dd, yyyy", Locale.ENGLISH), - SimpleDateFormat("MMM dd, yyyy", Locale.ENGLISH), - SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH), - SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH), + SimpleDateFormat("MMMM dd, yyyy", Locale.US), + SimpleDateFormat("MMM dd, yyyy", Locale.US), + SimpleDateFormat("MMM d, yyyy", Locale.US), + SimpleDateFormat("yyyy-MM-dd", Locale.US), ) for (fmt in formats) { try { From b492c749b5e2755386715971da59966f009ea02c Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:24:42 +0200 Subject: [PATCH 024/109] Update MangaFire.kt --- .../parsers/site/kotatsu/all/MangaFire.kt | 116 +++++++++--------- 1 file changed, 55 insertions(+), 61 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt index 85a089d6..6a909a92 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt @@ -39,7 +39,7 @@ internal abstract class MangaFireParser( SortOrder.UPDATED, // chapter update SortOrder.POPULARITY, // most views SortOrder.RATING, // rating score - SortOrder.ADDED, // created manga + SortOrder.NEWEST, // created manga SortOrder.ALPHABETICAL, // title asc SortOrder.RELEVANCE, // relevance sc SortOrder.POPULARITY_WEEK, @@ -70,7 +70,6 @@ internal abstract class MangaFireParser( isTagsExclusionSupported = true, isSearchSupported = true, isSearchWithFiltersSupported = true, - isYearSupported = true, isYearRangeSupported = true, ) @@ -155,10 +154,22 @@ internal abstract class MangaFireParser( order: SortOrder, filter: MangaListFilter, ): List { - val url = StringBuilder("https://$domain/api/titles?page=$page&limit=50") + val urlBuilder = okhttp3.HttpUrl.Builder() + .scheme("https") + .host(domain) + .addPathSegments("api/titles") + .addQueryParameter("page", page.toString()) + .addQueryParameter("limit", "50") if (!filter.query.isNullOrBlank()) { - url.append("&keyword=").append(filter.query) + urlBuilder.addQueryParameter("keyword", filter.query) + } + + if (filter.yearFrom > 0) { + urlBuilder.addQueryParameter("year_from", filter.yearFrom.toString()) + } + if (filter.yearTo > 0) { + urlBuilder.addQueryParameter("year_to", filter.yearTo.toString()) } filter.types.forEach { type -> @@ -169,7 +180,7 @@ internal abstract class MangaFireParser( ContentType.OTHER -> "other" else -> null } - value?.let { url.append("&types[]=$it") } + value?.let { urlBuilder.addQueryParameter("types[]", it) } } filter.demographics.forEach { demo -> @@ -180,42 +191,43 @@ internal abstract class MangaFireParser( Demographic.SHOUNEN -> "268918" else -> null } - id?.let { url.append("&demographics[]=$it") } + id?.let { urlBuilder.addQueryParameter("demographics[]", it) } } - filter.tags.forEach { url.append("&genres_in[]=").append(it.key) } - filter.tagsExclude.forEach { url.append("&genres_ex[]=").append(it.key) } - - filter.states.forEach { - url.append("&statuses[]=").append( - when (it) { - MangaState.ONGOING -> "releasing" - MangaState.FINISHED -> "finished" - MangaState.ABANDONED -> "discontinued" - MangaState.PAUSED -> "on_hiatus" - MangaState.UPCOMING -> "not_yet_released" - else -> "" - } - ) - } + filter.tags.forEach { urlBuilder.addQueryParameter("genres_in[]", it.key) } + filter.tagsExclude.forEach { urlBuilder.addQueryParameter("genres_ex[]", it.key) } - filter.yearFrom.let { url.append("&year_from=$it") } - filter.yearTo.let { url.append("&year_to=$it") } + filter.states.forEach { state -> + val apiState = when (state) { + MangaState.ONGOING -> "releasing" + MangaState.FINISHED -> "finished" + MangaState.ABANDONED -> "discontinued" + MangaState.PAUSED -> "on_hiatus" + MangaState.UPCOMING -> "not_yet_released" + else -> null + } + apiState?.let { urlBuilder.addQueryParameter("statuses[]", it) } + } val sortParam = when (order) { - SortOrder.UPDATED -> "order[chapter_updated_at]=desc" - SortOrder.POPULARITY -> "order[views_total]=desc" - SortOrder.RATING -> "order[score]=desc" - SortOrder.ADDED -> "order[created_at]=desc" - SortOrder.ALPHABETICAL -> "order[title]=asc" - SortOrder.RELEVANCE -> "order[relevance]=desc" - SortOrder.POPULARITY_WEEK -> "order[views_7d]=desc" - SortOrder.POPULARITY_MONTH -> "order[views_30d]=desc" - else -> "" + SortOrder.UPDATED -> "chapter_updated_at" to "desc" + SortOrder.POPULARITY -> "views_total" to "desc" + SortOrder.RATING -> "score" to "desc" + SortOrder.NEWEST -> "created_at" to "desc" + SortOrder.ALPHABETICAL -> "title" to "asc" + SortOrder.RELEVANCE -> "relevance" to "desc" + SortOrder.POPULARITY_WEEK -> "views_7d" to "desc" + SortOrder.POPULARITY_MONTH -> "views_30d" to "desc" + else -> null } - if (sortParam.isNotEmpty()) url.append("&").append(sortParam) - val response = apiClient.httpGet(url.toString()).parseJson() + sortParam?.let { (field, dir) -> + urlBuilder.addQueryParameter("order[$field]", dir) + } + + val url = urlBuilder.build().toString() + + val response = apiClient.httpGet(url).parseJson() val items = response.getJSONArray("items") val mangas = mutableListOf() for (i in 0 until items.length()) { @@ -248,7 +260,6 @@ internal abstract class MangaFireParser( } return mangas } - override suspend fun getDetails(manga: Manga): Manga = coroutineScope { val hid = extractHid(manga.url) @@ -267,16 +278,12 @@ internal abstract class MangaFireParser( val synopsisHtml = data.optString("synopsisHtml", null) val status = data.optString("status", null) val type = data.optString("type", null) - - // Authors & Artists - val authors = data.optJSONArray("authors")?.let { arr -> + val authorsList = data.optJSONArray("authors")?.let { arr -> (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } - }?.joinToString() - val artists = data.optJSONArray("artists")?.let { arr -> + }.orEmpty() + val artistsList = data.optJSONArray("artists")?.let { arr -> (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } - }?.joinToString() - val authorSet = setOfNotNull(authors, artists).filter { it.isNotBlank() }.toSet() - + }.orEmpty() val genres = data.optJSONArray("genres")?.let { arr -> (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } } @@ -287,25 +294,11 @@ internal abstract class MangaFireParser( (0 until arr.length()).map { arr.getString(it) } } ?: emptyList() - val raw = data.optDouble("rating", -1.0) - val ratingValue = if (raw >= 0.0) raw.toFloat() else null + val rawRating = data.optDouble("rating", -1.0) + val rating = if (rawRating >= 0.0) (rawRating / 10.0).toFloat() else RATING_UNKNOWN val synopsisText = synopsisHtml?.let { Jsoup.parseBodyFragment(it).text() } ?: "" - val richDescription = buildString { - ratingValue?.let { rating -> - val stars = (rating / 2.0).toInt().coerceIn(0, 5) - append("${"★".repeat(stars)}${"☆".repeat(5 - stars)} $rating\n\n") - } - if (synopsisText.isNotBlank()) { - append(synopsisText.trim(), "\n\n") - } - if (altTitlesArray.isNotEmpty()) { - append("Alternative Names:\n") - altTitlesArray.forEach { append("- ", it, "\n") } - } - }.trim() - val genreList = buildList { type?.let { add(it.replaceFirstChar { c -> c.uppercase() }) } genres?.let { addAll(it) } @@ -320,8 +313,9 @@ internal abstract class MangaFireParser( manga.copy( title = title, coverUrl = cover ?: manga.coverUrl, - authors = authorSet, - description = richDescription, + authors = (authorsList + artistsList).toSet(), + description = synopsisText.trim(), + rating = rating, state = when (status?.lowercase()) { "releasing" -> MangaState.ONGOING "finished" -> MangaState.FINISHED From 4ca2a1344228b76e3747035a3c9101df736c1a48 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:48:13 +0200 Subject: [PATCH 025/109] Update Mangadotnet.kt --- .../parsers/site/kotatsu/en/Mangadotnet.kt | 78 +++++-------------- 1 file changed, 21 insertions(+), 57 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt index 72558613..60165bda 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt @@ -44,6 +44,11 @@ internal class Mangadotnet(context: MangaLoaderContext) : "Wuxia", "Yaoi", "Yuri", "Zombies" ) + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + // RSC decoder private fun decodeRsc(flat: JSONArray): Any? { val cache = arrayOfNulls(flat.length()) @@ -240,30 +245,30 @@ internal class Mangadotnet(context: MangaLoaderContext) : else -> null } } - val hiatus = mangaInfo["hiatus"] as? String - val status = mangaInfo["status"] as? String + val genres = (mangaInfo["genres"] as? List<*>)?.filterIsInstance()?.mapNotNull { it.trim().ifBlank { null } }.orEmpty() val altTitles = (mangaInfo["alt_titles"] as? List<*>)?.filterIsInstance()?.mapNotNull { it.trim().ifBlank { null } }?.toSet().orEmpty() val origin = mangaInfo["country_of_origin"] as? String - val ratingValue = (mangaInfo["avg_rating"] as? Number)?.toFloat() val authorRaw = mangaInfo["authors"] as? String val author = parseJsonArrayString(authorRaw)?.joinToString(", ") ?: authorRaw - val year = (mangaInfo["year"] as? Number)?.toInt() - val chapterCount = (mangaInfo["chapter_count"] as? Number)?.toInt() - val trackedCount = (mangaInfo["tracked_count"] as? Number)?.toInt() - val contentRating = mangaInfo["content_rating"] as? String - val ratingCount = (mangaInfo["rating_count"] as? Number)?.toInt() + val statusText = mangaInfo["status_text"] as? String + val status = mangaInfo["status"] as? String val state = when { - "One Shot" in genres -> MangaState.FINISHED - hiatus.equals("Yes", ignoreCase = true) -> MangaState.PAUSED - else -> when (status?.lowercase()) { + !statusText.isNullOrBlank() -> when (statusText.lowercase()) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + else -> null + } + !status.isNullOrBlank() -> when (status.lowercase()) { "ongoing" -> MangaState.ONGOING "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED else -> null } + else -> null } - val tagSet = genres.mapTo(LinkedHashSet(genres.size)) { MangaTag(key = it, title = it, source = source) } when (origin) { "JP" -> tagSet.add(MangaTag("Manga", "Manga", source)) @@ -282,50 +287,8 @@ internal class Mangadotnet(context: MangaLoaderContext) : .forEach { tagSet.add(MangaTag(it, it, source)) } } - val anilistId = (mangaInfo["anilist_id"] as? Number)?.toLong() - val malId = (mangaInfo["mal_id"] as? Number)?.toLong() - val mangadexId = mangaInfo["mangadex_id"] as? String - val mangaupdatesId = mangaInfo["mangaupdates_id"] as? String - val kitsuId = (mangaInfo["kitsu_id"] as? Number)?.toLong() - val mangabakaId = (mangaInfo["mangabaka_id"] as? Number)?.toLong() - val sourceUrl = mangaInfo["source_url"] as? String - - val richDescription = buildString { - ratingValue?.let { rating -> - val stars = (rating / 2).toInt().coerceIn(0, 5) - append("${"★".repeat(stars)}${"☆".repeat(5 - stars)} $rating\n\n") - } - val metaInfo = buildList { - year?.let { add("**Year:** $it") } - chapterCount?.let { add("**Chapters:** $it") } - trackedCount?.let { add("**Tracked:** $it") } - contentRating?.let { add("**Content Rating:** ${it.replaceFirstChar { c -> c.uppercase() }}") } - ratingCount?.takeIf { it > 0 }?.let { add("$it ratings") } - } - if (metaInfo.isNotEmpty()) { - append(metaInfo.joinToString(" · "), "\n\n") - } - description?.let { - append(it.replace("\r\n", "\n").replace(Regex("\n{3,}"), "\n\n").trim(), "\n\n") - } - val links = buildList { - anilistId?.let { add("[AniList](https://anilist.co/manga/$it)") } - malId?.let { add("[MyAnimeList](https://myanimelist.net/manga/$it)") } - mangadexId?.let { add("[MangaDex](https://mangadex.org/title/$it)") } - mangaupdatesId?.let { add("[MangaUpdates](https://www.mangaupdates.com/series/$it)") } - kitsuId?.let { add("[Kitsu](https://kitsu.app/manga/$it)") } - mangabakaId?.let { add("[MangaBaka](https://mangabaka.org/$it)") } - sourceUrl?.let { add("[Source]($it)") } - } - if (links.isNotEmpty()) { - append("\n**Links:**\n") - links.forEach { append("- ", it, "\n") } - } - if (altTitles.isNotEmpty()) { - append("\n**Alternative Names:**\n") - altTitles.forEach { append("- ", it, "\n") } - } - }.trim() + val rawRating = (mangaInfo["avg_rating"] as? Number)?.toDouble() ?: -1.0 + val rating = if (rawRating >= 0.0) (rawRating / 10.0).toFloat() else RATING_UNKNOWN val chapters = fetchChapters(manga.url) @@ -333,7 +296,8 @@ internal class Mangadotnet(context: MangaLoaderContext) : title = title, coverUrl = coverUrl, largeCoverUrl = largeCoverUrl, - description = richDescription, + rating=rating, + description = description, altTitles = altTitles, tags = tagSet, state = state, From e7ae3939222b01e223c392c2edae6b824b15a242 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:12:56 +0200 Subject: [PATCH 026/109] Update Mangataro.kt --- .../parsers/site/kotatsu/en/Mangataro.kt | 407 +++++++++++++----- 1 file changed, 296 insertions(+), 111 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangataro.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangataro.kt index 4b6b97ba..205ae6ea 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangataro.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangataro.kt @@ -10,9 +10,6 @@ import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.exception.ParseException -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter @@ -38,6 +35,7 @@ import java.security.MessageDigest import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.format.DateTimeFormatter +import java.util.ArrayList import java.util.Calendar import java.util.EnumSet import java.util.HashSet @@ -54,47 +52,6 @@ internal class Mangataro(context: MangaLoaderContext) : private val tokenFormatter = DateTimeFormatter.ofPattern("yyyyMMddHH", Locale.US) - private val genreTags by lazy { - setOf( - tag("7", "Action"), - tag("19", "Adventure"), - tag("41", "Award Winning"), - tag("49", "Boys Love"), - tag("62", "Comedy"), - tag("79", "Drama"), - tag("83", "Ecchi"), - tag("87", "Erotica"), - tag("91", "Fantasy"), - tag("93", "Full Color"), - tag("100", "Girls Love"), - tag("104", "Harem"), - tag("108", "Historical"), - tag("109", "Horror"), - tag("114", "Isekai"), - tag("116", "Josei"), - tag("123", "Long Strip"), - tag("134", "Manga"), - tag("136", "Manhua"), - tag("137", "Manhwa"), - tag("138", "Martial Arts"), - tag("152", "Mystery"), - tag("159", "One-shot"), - tag("170", "Psychological"), - tag("173", "Reincarnation"), - tag("175", "Romance"), - tag("178", "School"), - tag("180", "Sci-Fi"), - tag("181", "Seinen"), - tag("184", "Shoujo"), - tag("186", "Shounen"), - tag("190", "Slice of Life"), - tag("194", "Sports"), - tag("198", "Supernatural"), - tag("206", "Time Travel"), - tag("220", "Web Comic"), - ) - } - override fun onCreateConfig(keys: MutableCollection>) { super.onCreateConfig(keys) keys.add(userAgentKey) @@ -112,12 +69,16 @@ internal class Mangataro(context: MangaLoaderContext) : isSearchSupported = true, isSearchWithFiltersSupported = true, isMultipleTagsSupported = true, + isYearSupported = true, ) override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = genreTags, + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), + availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER), ) + override suspend fun getListPage( page: Int, order: SortOrder, @@ -126,10 +87,41 @@ internal class Mangataro(context: MangaLoaderContext) : val payload = JSONObject().apply { put("page", page) put("search", filter.query.orEmpty()) - put("years", "[]") + + // Year + val yearArray = JSONArray() + if (filter.year > 0) yearArray.put(filter.year.toString()) + put("years", yearArray.toString()) + + // Genres (tags) put("genres", filter.tags.toGenrePayload()) - put("types", "[]") - put("statuses", "[]") + + // Types + val typeArray = JSONArray() + filter.types.forEach { ct -> + val apiType = when (ct) { + ContentType.MANGA -> "Manga" + ContentType.MANHWA -> "Manhwa" + ContentType.MANHUA -> "Manhua" + ContentType.OTHER -> "Novel" + else -> null + } + if (apiType != null) typeArray.put(apiType) + } + put("types", typeArray.toString()) + + // Statuses + val statusArray = JSONArray() + filter.states.forEach { state -> + val apiStatus = when (state) { + MangaState.ONGOING -> "Ongoing" + MangaState.FINISHED -> "Completed" + else -> null + } + if (apiStatus != null) statusArray.put(apiStatus) + } + put("statuses", statusArray.toString()) + put("sort", order.toApiSort()) put("genreMatchMode", "any") } @@ -157,64 +149,53 @@ internal class Mangataro(context: MangaLoaderContext) : val chapters = loadChapters(mangaId, detailUrl) return manga.copy( description = description, - tags = if (tags.isEmpty()) manga.tags else tags, - authors = if (authors.isEmpty()) manga.authors else authors, - altTitles = if (altTitles.isEmpty()) manga.altTitles else altTitles, + tags = tags.ifEmpty { manga.tags }, + authors = authors.ifEmpty { manga.authors }, + altTitles = altTitles.ifEmpty { manga.altTitles }, state = state, rating = rating, chapters = chapters, ) } - private suspend fun loadChapters( - mangaId: String, - referer: String, - ): List = coroutineScope { - + private suspend fun loadChapters(mangaId: String, referer: String): List { val headers = Headers.headersOf("Referer", referer) - - val offsets = listOf(0, 100, 200, 300) - - val chapters = offsets.map { offset -> - async { - val (token, timestamp) = generateChapterToken() - - val url = buildChapterUrl( - mangaId, - offset, - 100, - token, - timestamp, - ) - - val raw = webClient.httpGet(url, headers) - .parseRaw() - .trim() - - val json = raw.toJsonObjectOrNull() - ?: return@async emptyList() - - if (!json.optBoolean("success")) { - return@async emptyList() - } - - val array = json.optJSONArray("chapters") - ?: return@async emptyList() - - List(array.length()) { i -> - array.getJSONObject(i) - .toMangaChapter( - (offset + i + 1).toFloat() - ) + val result = ArrayList() + val seenIds = HashSet() + var offset = 0 + val limit = 500 + var expectedTotal = Int.MAX_VALUE + while (offset < expectedTotal) { + val (token, timestamp) = generateChapterToken() + val requestUrl = buildChapterUrl(mangaId, offset, limit, token, timestamp) + val raw = webClient.httpGet(requestUrl, headers).parseRaw().trim() + if (raw.isEmpty()) { + break + } + val json = runCatching { JSONObject(raw) }.getOrNull() ?: break + if (!json.optBoolean("success", false)) { + break + } + expectedTotal = json.optInt("total", expectedTotal) + val chaptersArray = json.optJSONArray("chapters") ?: break + if (chaptersArray.length() == 0) { + break + } + for (i in 0 until chaptersArray.length()) { + val item = chaptersArray.getJSONObject(i) + val key = item.optString("id").ifEmpty { item.optString("url") } + if (!seenIds.add(key)) { + continue } + val fallbackNumber = (offset + i + 1).toFloat() + result.add(item.toMangaChapter(fallbackNumber)) + } + offset += chaptersArray.length() + if (!json.optBoolean("has_more", false)) { + break } } - - chapters - .awaitAll() - .flatten() - .distinctBy { it.id } - .sortedBy { it.number } + return result } private fun buildChapterUrl( @@ -239,19 +220,16 @@ internal class Mangataro(context: MangaLoaderContext) : .toString() } - private val md5 = MessageDigest.getInstance("MD5") - - private fun generateChapterToken(): Pair { - val timestamp = (System.currentTimeMillis()/1000L).toString() - val hourKey = ZonedDateTime.now(ZoneOffset.UTC) - .format(tokenFormatter) - - val input = timestamp + "mng_ch_$hourKey" - - val token = md5.digest(input.toByteArray()) + private fun generateChapterToken(): Pair { + val timestamp = (System.currentTimeMillis() / 1000L).toString() + val hourKey = ZonedDateTime.now(ZoneOffset.UTC).format(tokenFormatter) + val secret = "mng_ch_$hourKey" + val hashInput = timestamp + secret + val token = MessageDigest + .getInstance("MD5") + .digest(hashInput.toByteArray()) .toHex() - .substring(0,16) - + .substring(0, 16) return token to timestamp } @@ -626,10 +604,10 @@ internal class Mangataro(context: MangaLoaderContext) : } val ids = LinkedHashSet(size) for (tag in this) { - val numericKey = tag.key?.toIntOrNull()?.let { it.toString() } + val numericKey = tag.key.toIntOrNull()?.toString() ?: genreTags.firstOrNull { ref -> - ref.title.equals(tag.title, ignoreCase = true) - }?.key?.toIntOrNull()?.let { it.toString() } + ref.title.equals(tag.title, ignoreCase = true) + }?.key?.toIntOrNull()?.toString() if (numericKey != null) { ids.add(numericKey) } @@ -673,4 +651,211 @@ internal class Mangataro(context: MangaLoaderContext) : ?: attrAsAbsoluteUrlOrNull("data-lazy-src") ?: attrAsAbsoluteUrlOrNull("src") } -} \ No newline at end of file + + private val genreTags by lazy { + setOf( + tag("7", "Action"), + tag("12", "Adventure"), + tag("17", "Award Winning"), + tag("80", "Boys Love"), + tag("20", "Comedy"), + tag("8", "Drama"), + tag("40", "Ecchi"), + tag("71", "Erotica"), + tag("2", "Fantasy"), + tag("34", "Full Color"), + tag("49", "Girls Love"), + tag("64", "Harem"), + tag("18", "Historical"), + tag("44", "Horror"), + tag("3", "Isekai"), + tag("43", "Josei"), + tag("1172", "Long Strip"), + tag("14", "Manga"), + tag("33", "Manhua"), + tag("6", "Manhwa"), + tag("4", "Martial Arts"), + tag("24", "Mystery"), + tag("48", "One-shot"), + tag("26", "Psychological"), + tag("5", "Reincarnation"), + tag("29", "Romance"), + tag("10", "School"), + tag("15", "Sci-Fi"), + tag("19", "Seinen"), + tag("42", "Shoujo"), + tag("13", "Shounen"), + tag("47", "Slice of Life"), + tag("25", "Sports"), + tag("9", "Supernatural"), + tag("32", "Time Travel"), + tag("39", "Webtoon"), + tag("2094", "4-Koma"), + tag("1050", "Abandoned Children"), + tag("2386", "Ability Steal"), + tag("2397", "Absent Parents"), + tag("4012", "Academy"), + tag("999", "Accelerated Growth"), + tag("1351", "Adaptation"), + tag("1000", "Adapted to Anime"), + tag("5072", "Adapted to Drama CD"), + tag("1001", "Adapted to Game"), + tag("5069", "Adapted to Manga"), + tag("963", "Adapted to Manhua"), + tag("1002", "Adapted to Manhwa"), + tag("5070", "Adapted to Visual Novel"), + tag("1058", "Adopted Children"), + tag("88", "Adult"), + tag("16", "Adult Cast"), + tag("2861", "Age Regression"), + tag("964", "Alchemy"), + tag("965", "Aliens"), + tag("966", "Alternate World"), + tag("2862", "Ancient Times"), + tag("1135", "Animals"), + tag("2096", "Anthology"), + tag("93", "Anthropomorphic"), + tag("5071", "Appearance Different from Actual Age"), + tag("1037", "Aristocracy"), + tag("2387", "Army"), + tag("1005", "Army Building"), + tag("1047", "Arranged Marriage"), + tag("2401", "Arrogant Characters"), + tag("4551", "Artbook"), + tag("4004", "Artifacts"), + tag("2863", "Assassins"), + tag("98", "Avant Garde"), + tag("5279", "Battle Academy"), + tag("2388", "Battle Competition"), + tag("1137", "BD"), + tag("968", "Beast Companions"), + tag("969", "Beautiful Female Lead"), + tag("2864", "Betrayal"), + tag("971", "Blacksmith"), + tag("4237", "Boys' Love"), + tag("1038", "Brotherhood"), + tag("2398", "Business Management"), + tag("1006", "Calm Protagonist"), + tag("2392", "Cautious Protagonist"), + tag("94", "CGDCT"), + tag("4929", "Character growth"), + tag("51", "Childcare"), + tag("4818", "Children's"), + tag("2393", "Cold Love Interests"), + tag("2394", "Cold Protagonist"), + tag("92", "Combat Sports"), + tag("2118", "Cooking"), + tag("1753", "Crime"), + tag("81", "Crossdressing"), + tag("2395", "Cruel Characters"), + tag("1039", "Cunning Protagonist"), + tag("4817", "Cute Stuffs"), + tag("4930", "Dark"), + tag("1044", "Death of Loved Ones"), + tag("52", "Delinquents"), + tag("1376", "Demons"), + tag("975", "Dense Protagonist"), + tag("58", "Detective"), + tag("4008", "Devoted love interests"), + tag("79", "Doujinshi"), + tag("1040", "Dragons"), + tag("1048", "Drugs"), + tag("4795", "Dungeon"), + tag("1011", "Dungeons"), + tag("66", "Educational"), + tag("976", "Elemental Magic"), + tag("4010", "Elves"), + tag("4005", "Family"), + tag("59", "Gag Humor"), + tag("36", "Game"), + tag("979", "Game Elements"), + tag("106", "Gender Bender"), + tag("2093", "Genderswap"), + tag("1134", "Ghosts"), + tag("4739", "Gyaru"), + tag("76", "High Stakes Game"), + tag("82", "Hentai"), + tag("4932", "Human nonhuman relationship"), + tag("95", "Idols (Female)"), + tag("101", "Idols (Male)"), + tag("109", "Indonesian"), + tag("91", "Iyashikei"), + tag("107", "Kids"), + tag("4006", "Kingdoms"), + tag("1020", "Level System"), + tag("55", "Light Novel"), + tag("4207", "Loli"), + tag("89", "Lolicon"), + tag("1173", "Web Comic"), + tag("41", "Love Polygon"), + tag("100", "Love Status Quo"), + tag("2061", "Mafia"), + tag("35", "Magic"), + tag("2095", "Magical Girls"), + tag("68", "Magical Sex Shift"), + tag("97", "Mahou Shoujo"), + tag("4933", "Male"), + tag("4792", "Mangataro Exclusive"), + tag("1414", "Monster Girls"), + tag("37", "Monsters"), + tag("1024", "Multiple POV"), + tag("38", "Murim"), + tag("83", "Music"), + tag("53", "Mythology"), + tag("4601", "Ninja"), + tag("103", "OEL"), + tag("2062", "Office Workers"), + tag("4834", "Official Colored"), + tag("4210", "Oneshot"), + tag("50", "Organized Crime"), + tag("70", "Otaku Culture"), + tag("21", "Parody"), + tag("72", "Performing Arts"), + tag("105", "Pets"), + tag("63", "Philosophical"), + tag("1136", "Police"), + tag("1557", "Post-Apocalyptic"), + tag("102", "Racing"), + tag("4014", "Reincarnated in another world"), + tag("61", "Reverse Harem"), + tag("28", "Romantic Subtext"), + tag("60", "Samurai"), + tag("73", "School Life"), + tag("1413", "Self-Published"), + tag("4116", "Sexual Violence"), + tag("90", "Shoujo Ai"), + tag("104", "Shounen Ai"), + tag("54", "Showbiz"), + tag("4009", "Skill books"), + tag("85", "Smut"), + tag("84", "Space"), + tag("77", "Strategy Game"), + tag("22", "Super Power"), + tag("4098", "Superhero"), + tag("56", "Survival"), + tag("75", "Suspense"), + tag("995", "Sword And Magic"), + tag("27", "Team Sports"), + tag("591", "Thai"), + tag("1341", "Thriller"), + tag("4742", "Traditional Games"), + tag("65", "Tragedy"), + tag("4011", "Transported to another world"), + tag("110", "Urban Fantasy"), + tag("45", "Vampire"), + tag("2102", "Vampires"), + tag("31", "Video Game"), + tag("1366", "Video Games"), + tag("62", "Villainess"), + tag("4485", "Virtual Reality"), + tag("57", "Visual Arts"), + tag("69", "Workplace"), + tag("1350", "Wuxia"), + tag("2391", "Xianxia"), + tag("962", "Xuanhuan"), + tag("86", "Yaoi"), + tag("99", "Yuri"), + tag("1342", "Zombies"), + ) + } +} From 203b2217dab88b622e7ade5ce75d83ea78e9eab6 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:34:17 +0200 Subject: [PATCH 027/109] Delete src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Utoon.kt --- .../kotatsu/parsers/site/kotatsu/en/Utoon.kt | 270 ------------------ 1 file changed, 270 deletions(-) delete mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Utoon.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Utoon.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Utoon.kt deleted file mode 100644 index 18f0fe38..00000000 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Utoon.kt +++ /dev/null @@ -1,270 +0,0 @@ -package org.koitharu.kotatsu.parsers.site.kotatsu.en - -import okhttp3.HttpUrl.Companion.toHttpUrl -import org.json.JSONArray -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.MangaSourceParser -import org.koitharu.kotatsu.parsers.config.ConfigKey -import org.koitharu.kotatsu.parsers.core.PagedMangaParser -import org.koitharu.kotatsu.parsers.model.* -import org.koitharu.kotatsu.parsers.util.* -import java.util.Calendar -import java.util.EnumSet -import java.util.Locale - -@MangaSourceParser("UTOON", "UToon", "en") -internal class UToon(context: MangaLoaderContext) : - PagedMangaParser(context, MangaParserSource.UTOON, pageSize = 28) { - - override val configKeyDomain = ConfigKey.Domain("utoon.net") - - override val availableSortOrders: Set = EnumSet.of( - SortOrder.POPULARITY, - SortOrder.UPDATED, - SortOrder.NEWEST, - SortOrder.ALPHABETICAL, - ) - - override val filterCapabilities = MangaListFilterCapabilities( - isSearchSupported = true, - isSearchWithFiltersSupported = true, - ) - - override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = genreTags, - availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), - ) - - override suspend fun getListPage( - page: Int, - order: SortOrder, - filter: MangaListFilter, - ): List { - - val url = buildUrl(page, order, filter) - val doc = webClient.httpGet(url).parseHtml() - - return doc.select(".agrid .acard").map { it.toManga() } - } - - private fun buildUrl( - page: Int, - order: SortOrder, - filter: MangaListFilter - ): String { - return buildString { - append("https://") - append(domain) - append("/manga/") - if (page > 1) { - append("page/") - append(page) - append("/") - } - }.toHttpUrl().newBuilder().apply { - - filter.query?.takeIf { it.isNotBlank() }?.let { - addQueryParameter("q", it) - } - - when (order) { - SortOrder.POPULARITY -> addQueryParameter("orderby", "popular") - SortOrder.NEWEST -> addQueryParameter("orderby", "new") - SortOrder.ALPHABETICAL -> addQueryParameter("orderby", "alphabet") - else -> Unit - } - - filter.states.firstOrNull()?.let { state -> - val value = when (state) { - MangaState.ONGOING -> "ongoing" - MangaState.FINISHED -> "completed" - else -> null - } - if (value != null) addQueryParameter("status", value) - } - - filter.tags.firstOrNull()?.key?.let { - addQueryParameter("genre", it) - } - - }.build().toString() - } - - private fun Element.toManga(): Manga { - val href = attrAsRelativeUrl("href") - - return Manga( - id = generateUid(href), - title = selectFirst(".ac-t")?.text().orEmpty(), - altTitles = emptySet(), - url = href, - publicUrl = href.toAbsoluteUrl(domain), - rating = selectFirst(".ac-rate") - ?.text() - ?.toFloatOrNull() - ?.div(5f) ?: RATING_UNKNOWN, - contentRating = null, - coverUrl = selectFirst("img")?.src(), - tags = emptySet(), - state = parseState(selectFirst(".ac-status")?.text()), - authors = emptySet(), - source = source, - ) - } - - override suspend fun getDetails(manga: Manga): Manga { - val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() - - var author: String? = null - var artist: String? = null - var state: MangaState? = null - - doc.select(".sinfo-grid .sir").forEach { row -> - val label = row.selectFirst(".l")?.text()?.trim().orEmpty() - val value = row.selectFirst(".v")?.text()?.trim().orEmpty() - - when (label.lowercase(Locale.ROOT)) { - "author" -> author = value.nullIfEmpty() - "artist" -> artist = value.nullIfEmpty() - "status" -> state = parseState(value) - } - } - - if (state == null) { - val fallback = doc.select(".hinfo .hi") - .joinToString(" ") { it.text() } - state = parseState(fallback) - } - - val base = manga.copy( - title = doc.selectFirst(".htitle")?.text() ?: manga.title, - coverUrl = doc.selectFirst(".poster img")?.src() ?: manga.coverUrl, - description = doc.selectFirst(".syn")?.text(), - tags = doc.select(".genres .genre").mapNotNullToSet { el -> - val title = el.text().trim().nullIfEmpty() ?: return@mapNotNullToSet null - val key = el.attr("href") - .trimEnd('/') - .substringAfterLast('/') - .nullIfEmpty() - ?: return@mapNotNullToSet null - - MangaTag(key = key, title = title, source = source) - }, - authors = setOfNotNull(author ?: artist), - state = state, - ) - - val chapters = parseChapters(doc) - - return base.copy(chapters = chapters) - } - - private fun parseChapters(doc: Document): List { - val script = doc.selectFirst("script:containsData(var CH=)")?.data() - ?: return emptyList() - - val json = script.substringAfter("var CH=").substringBefore(";").trim() - val array = runCatching { JSONArray(json) }.getOrNull() - ?: return emptyList() - - return (0 until array.length()).mapNotNull { i -> - val obj = array.optJSONObject(i) ?: return@mapNotNull null - if (obj.optBoolean("locked", false)) return@mapNotNull null - - val url = obj.optString("url").nullIfEmpty() - ?.toRelativeUrl(domain) ?: return@mapNotNull null - - val label = obj.optString("label").nullIfEmpty() - ?: return@mapNotNull null - - MangaChapter( - id = generateUid(url), - title = label, - number = obj.optDouble("num", 0.0).toFloat(), - volume = 0, - url = url, - scanlator = null, - uploadDate = parseChapterDate(obj.optString("ago")), - branch = null, - source = source, - ) - }.reversed() - } - - override suspend fun getPages(chapter: MangaChapter): List { - val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() - - if (doc.selectFirst(".zx-locked__card") != null) { - throw IllegalStateException("Locked premium chapter") - } - - return doc.select("div.reading-content img").mapNotNull { img -> - val src = img.src() ?: return@mapNotNull null - MangaPage( - id = generateUid(src), - url = src, - preview = null, - source = source, - ) - } - } - - override suspend fun getPageUrl(page: MangaPage): String = page.url - - private fun parseState(status: String?): MangaState? = when { - status.isNullOrBlank() -> null - status.contains("ongoing", true) -> MangaState.ONGOING - status.contains("completed", true) -> MangaState.FINISHED - status.contains("hiatus", true) || status.contains("on hold", true) -> MangaState.PAUSED - status.contains("cancel", true) -> MangaState.ABANDONED - else -> null - } - - private fun parseChapterDate(value: String?): Long { - val v = value?.lowercase(Locale.ROOT)?.nullIfEmpty() ?: return 0L - if (!v.contains("ago")) return 0L - - val amount = Regex("\\d+").find(v)?.value?.toIntOrNull() ?: return 0L - val cal = Calendar.getInstance() - - when { - "second" in v -> cal.add(Calendar.SECOND, -amount) - "minute" in v -> cal.add(Calendar.MINUTE, -amount) - "hour" in v -> cal.add(Calendar.HOUR_OF_DAY, -amount) - "day" in v -> cal.add(Calendar.DAY_OF_YEAR, -amount) - "week" in v -> cal.add(Calendar.WEEK_OF_YEAR, -amount) - "month" in v -> cal.add(Calendar.MONTH, -amount) - "year" in v -> cal.add(Calendar.YEAR, -amount) - else -> return 0L - } - - return cal.timeInMillis - } - - private val genreTags: Set - get() = setOf( - "fantasy" to "Fantasy", - "drama" to "Drama", - "adventure" to "Adventure", - "action" to "Action", - "comedy" to "Comedy", - "shounen" to "Shounen", - "comic" to "Comic", - "manhwa" to "Manhwa", - "fight" to "Fight", - "magic" to "Magic", - "supernatural" to "Supernatural", - "manga" to "Manga", - "romance" to "Romance", - "martial-arts" to "Martial Arts", - "crime" to "Crime", - "mystery" to "Mystery", - "isekai" to "Isekai", - "historical" to "Historical", - "reincarnation" to "Reincarnation", - "slice-of-life" to "Slice of Life", - "manhua" to "Manhua", - ).mapToSet { (k, t) -> MangaTag(k, t, source) } -} From 1be2bdb05dca67f23d566e0f90a6fd69edb83c36 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:51:42 +0200 Subject: [PATCH 028/109] Update MangaFire.kt --- .../koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt index 6a909a92..d20d9ff4 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt @@ -393,8 +393,8 @@ internal abstract class MangaFireParser( append("Vol. ") append(volNumber.toString().removeSuffix(".0")) if (volName != null) append(" - $volName") - if (chapterCount > 0) append(" ($chapterCount chapters)") } + val name = if (chapterCount > 0) "$chapterCount chapters" else "" chapters.add( MangaChapter( @@ -403,7 +403,7 @@ internal abstract class MangaFireParser( number = volNumber, volume = 0, url = "/title/$hid/vol/$volId", - scanlator = "Volume", + scanlator = name, uploadDate = 0L, branch = "Volume", source = source, From a613ec8f876b08988d010f3010185665d7edb8ca Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:11:00 +0200 Subject: [PATCH 029/109] Update Manhuaus.kt --- .../org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt index 146673f0..ffb883a6 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Manhuaus.kt @@ -13,7 +13,7 @@ import org.koitharu.kotatsu.parsers.parsers.MadaraParser internal class Manhuaus(context: MangaLoaderContext): MadaraParser(context, MangaParserSource.MANHUAUS, "manhuaus.com") { - override val postReq = true + override val postReq = false override val filterCapabilities = MangaListFilterCapabilities( isMultipleTagsSupported = true, From 72b16e9fe7083c0f178fbe23341b6f13cd7dde4a Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:11:22 +0200 Subject: [PATCH 030/109] Create ManhwaNex.kt --- .../kotatsu/parsers/site/kotatsu/en/ManhwaNex.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaNex.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaNex.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaNex.kt new file mode 100644 index 00000000..ffc91246 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaNex.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.en + +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.parsers.MadaraParser + +@MangaSourceParser("MANHWANEX", "ManhwaNex", "en") +internal class ManhwaNex(context: MangaLoaderContext): + MadaraParser(context, MangaParserSource.MANHWANEX, "manhwanex.com") { + + override val postReq = false + +} From 86fde71b5dbc9c2e6345150fbe88c29682a3ae2d Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:33:30 +0200 Subject: [PATCH 031/109] Update MangaFire.kt --- .../parsers/site/kotatsu/all/MangaFire.kt | 141 ++++++++++-------- 1 file changed, 78 insertions(+), 63 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt index d20d9ff4..7217044b 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt @@ -260,74 +260,89 @@ internal abstract class MangaFireParser( } return mangas } - override suspend fun getDetails(manga: Manga): Manga = coroutineScope { - val hid = extractHid(manga.url) - - val detailsJson = apiClient.httpGet("https://$domain/api/titles/$hid").parseJson() - val data = detailsJson.getJSONObject("data") - - val hasVolumes = data.optBoolean("hasVolumes", false) - - val chaptersDeferred = async { fetchChapters(hid, hasVolumes) } - - val title = data.getString("title") - val poster = data.optJSONObject("poster") - val cover = poster?.optString("large") - ?: poster?.optString("medium") - ?: poster?.optString("small") - val synopsisHtml = data.optString("synopsisHtml", null) - val status = data.optString("status", null) - val type = data.optString("type", null) - val authorsList = data.optJSONArray("authors")?.let { arr -> - (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } - }.orEmpty() - val artistsList = data.optJSONArray("artists")?.let { arr -> - (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } - }.orEmpty() - val genres = data.optJSONArray("genres")?.let { arr -> - (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } - } - val themes = data.optJSONArray("themes")?.let { arr -> - (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } - } - val altTitlesArray = data.optJSONArray("altTitles")?.let { arr -> - (0 until arr.length()).map { arr.getString(it) } - } ?: emptyList() - val rawRating = data.optDouble("rating", -1.0) - val rating = if (rawRating >= 0.0) (rawRating / 10.0).toFloat() else RATING_UNKNOWN + override suspend fun getDetails(manga: Manga): Manga { + detailsCache[manga.url]?.let { return it } + + val result = coroutineScope { + val hid = extractHid(manga.url) + + val detailsJson = apiClient.httpGet("https://$domain/api/titles/$hid").parseJson() + val data = detailsJson.getJSONObject("data") + + val hasVolumes = data.optBoolean("hasVolumes", false) + + val chaptersDeferred = async { fetchChapters(hid, hasVolumes) } + + val title = data.getString("title") + val poster = data.optJSONObject("poster") + val cover = poster?.optString("large") + ?: poster?.optString("medium") + ?: poster?.optString("small") + val synopsisHtml = data.optString("synopsisHtml", null) + val status = data.optString("status", null) + val type = data.optString("type", null) + val authorsList = data.optJSONArray("authors")?.let { arr -> + (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } + }.orEmpty() + val artistsList = data.optJSONArray("artists")?.let { arr -> + (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } + }.orEmpty() + val genres = data.optJSONArray("genres")?.let { arr -> + (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } + } + val themes = data.optJSONArray("themes")?.let { arr -> + (0 until arr.length()).map { arr.getJSONObject(it).getString("title") } + } + val altTitlesArray = data.optJSONArray("altTitles")?.let { arr -> + (0 until arr.length()).map { arr.getString(it) } + } ?: emptyList() + + val rawRating = data.optDouble("rating", -1.0) + val rating = if (rawRating >= 0.0) (rawRating / 10.0).toFloat() else RATING_UNKNOWN - val synopsisText = synopsisHtml?.let { Jsoup.parseBodyFragment(it).text() } ?: "" + val synopsisText = synopsisHtml?.let { Jsoup.parseBodyFragment(it).text() } ?: "" - val genreList = buildList { - type?.let { add(it.replaceFirstChar { c -> c.uppercase() }) } - genres?.let { addAll(it) } - themes?.let { addAll(it) } + val genreList = buildList { + type?.let { add(it.replaceFirstChar { c -> c.uppercase() }) } + genres?.let { addAll(it) } + themes?.let { addAll(it) } + } + val genreTags = genreList.mapNotNull { name -> + tags.find { it.title == name } + }.toSet() + + val chapters = chaptersDeferred.await() + + manga.copy( + title = title, + coverUrl = cover ?: manga.coverUrl, + authors = (authorsList + artistsList).toSet(), + description = synopsisText.trim(), + rating = rating, + state = when (status?.lowercase()) { + "releasing" -> MangaState.ONGOING + "finished" -> MangaState.FINISHED + "discontinued" -> MangaState.ABANDONED + "on_hiatus" -> MangaState.PAUSED + "not_yet_released" -> MangaState.UPCOMING + else -> null + }, + tags = genreTags, + altTitles = altTitlesArray.toSet(), + chapters = chapters, + ) } - val genreTags = genreList.mapNotNull { name -> - tags.find { it.title == name } - }.toSet() - val chapters = chaptersDeferred.await() - - manga.copy( - title = title, - coverUrl = cover ?: manga.coverUrl, - authors = (authorsList + artistsList).toSet(), - description = synopsisText.trim(), - rating = rating, - state = when (status?.lowercase()) { - "releasing" -> MangaState.ONGOING - "finished" -> MangaState.FINISHED - "discontinued" -> MangaState.ABANDONED - "on_hiatus" -> MangaState.PAUSED - "not_yet_released" -> MangaState.UPCOMING - else -> null - }, - tags = genreTags, - altTitles = altTitlesArray.toSet(), - chapters = chapters, - ) + detailsCache.put(manga.url, result) + return result + } + + @get:Synchronized + private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > 20 + } } private suspend fun fetchChapters(hid: String, hasVolumes: Boolean): List { From 6cb6d324467ea721f09bb3adb971d215c29bdae9 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:33:50 +0200 Subject: [PATCH 032/109] Update Atsumaru.kt --- .../parsers/site/kotatsu/en/Atsumaru.kt | 201 ++++++++++-------- 1 file changed, 115 insertions(+), 86 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt index 1a240f7d..2f1d291a 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt @@ -11,6 +11,8 @@ import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.* import java.util.* +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope @MangaSourceParser("ATSUMARU", "Atsumaru", "en") internal class Atsumaru(context: MangaLoaderContext) : @@ -251,102 +253,129 @@ internal class Atsumaru(context: MangaLoaderContext) : // limit tags to 20 override suspend fun getDetails(manga: Manga): Manga { - val mangaId = manga.url.substringAfterLast("/") - val pageJson = webClient.httpGet("$baseUrl/api/manga/page?id=$mangaId", apiHeaders).parseJson() - val mangaPage = pageJson.optJSONObject("mangaPage") ?: return manga - - val title = mangaPage.optString("title").ifEmpty { mangaPage.optString("englishTitle", manga.title) } - val description = mangaPage.optString("synopsis") ?: manga.description - - val posterObj = mangaPage.optJSONObject("poster") - val posterImage = posterObj?.optString("mediumImage") - val coverUrl = if (!posterImage.isNullOrEmpty()) { - "https://$domain/static/$posterImage" - } else manga.coverUrl - - val authors = mangaPage.optJSONArray("authors")?.let { arr -> - (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("name") }.toSet() - } ?: emptySet() - - val statusText = mangaPage.optString("status").orEmpty() - val state = when (statusText.lowercase()) { - "ongoing" -> MangaState.ONGOING - "completed" -> MangaState.FINISHED - "hiatus" -> MangaState.PAUSED - "canceled" -> MangaState.ABANDONED - else -> manga.state - } + detailsCache[manga.url]?.let { return it } - val tagSet = mutableSetOf() - mangaPage.optJSONArray("genres")?.let { arr -> - for (i in 0 until minOf(arr.length(), 10)) { - val obj = arr.optJSONObject(i) ?: continue - val name = obj.optString("name").takeIf { it.isNotEmpty() } ?: continue - tagSet.add(MangaTag(name, "genre:${obj.optString("id")}", source)) + val result = coroutineScope { + val mangaId = manga.url.substringAfterLast("/") + + val pageDeferred = async { + webClient.httpGet("$baseUrl/api/manga/page?id=$mangaId", apiHeaders).parseJson() } - } - mangaPage.optJSONArray("tags")?.let { arr -> - for (i in 0 until minOf(arr.length(), 10)) { - val obj = arr.optJSONObject(i) ?: continue - val name = obj.optString("name").takeIf { it.isNotEmpty() } ?: continue - tagSet.add(MangaTag(name, "tag:${obj.optString("id")}", source)) + val chaptersDeferred = async { + webClient.httpGet("$baseUrl/api/manga/allChapters?mangaId=$mangaId", apiHeaders).parseJson() } - } - val chaptersJson = webClient.httpGet("$baseUrl/api/manga/allChapters?mangaId=$mangaId", apiHeaders).parseJson() - val chaptersArray = chaptersJson.optJSONArray("chapters") ?: JSONArray() - val scanlators = mutableMapOf() - mangaPage.optJSONArray("scanlators")?.let { scanArr -> - for (i in 0 until scanArr.length()) { - val sc = scanArr.optJSONObject(i) ?: continue - val id = sc.optString("id").takeIf { it.isNotEmpty() } ?: continue - val name = sc.optString("name").takeIf { it.isNotEmpty() } ?: continue - scanlators[id] = name + val pageJson = pageDeferred.await() + val mangaPage = pageJson.optJSONObject("mangaPage") ?: return@coroutineScope manga + + val title = mangaPage.optString("title").ifEmpty { mangaPage.optString("englishTitle", manga.title) } + val description = mangaPage.optString("synopsis") ?: manga.description + + val rawRating = mangaPage.optDouble("avgRating", -1.0) + val rating = if (rawRating >= 0.0) (rawRating / 10.0).toFloat() else RATING_UNKNOWN + + val posterObj = mangaPage.optJSONObject("poster") + val posterImage = posterObj?.optString("mediumImage") + val coverUrl = if (!posterImage.isNullOrEmpty()) { + "https://$domain/static/$posterImage" + } else manga.coverUrl + + val authors = mangaPage.optJSONArray("authors")?.let { arr -> + (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("name") }.toSet() + } ?: emptySet() + + val statusText = mangaPage.optString("status").orEmpty() + val state = when (statusText.lowercase()) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + "canceled" -> MangaState.ABANDONED + else -> manga.state } - } - val chapters = (0 until chaptersArray.length()).map { i -> - val ch = chaptersArray.getJSONObject(i) - val chId = ch.getString("id") - val number = ch.optDouble("number", 0.0).toFloat() - val chTitle = ch.optString("title") - val scanId = ch.optString("scanlationMangaId").nullIfEmpty() - val scanName = scanId?.let { scanlators[it] } - val createdAt = ch.optLong("createdAt", 0L) - - MangaChapter( - id = generateUid("$mangaId/$chId"), - title = chTitle.ifBlank { "Chapter $number" }, - number = number, - volume = 0, - url = "$mangaId/$chId", - uploadDate = createdAt, - source = source, - scanlator = scanName, - branch = scanName, - ) - }.reversed() + val tagSet = mutableSetOf() + mangaPage.optJSONArray("genres")?.let { arr -> + for (i in 0 until minOf(arr.length(), 10)) { + val obj = arr.optJSONObject(i) ?: continue + val name = obj.optString("name").takeIf { it.isNotEmpty() } ?: continue + tagSet.add(MangaTag(name, "genre:${obj.optString("id")}", source)) + } + } + mangaPage.optJSONArray("tags")?.let { arr -> + for (i in 0 until minOf(arr.length(), 10)) { + val obj = arr.optJSONObject(i) ?: continue + val name = obj.optString("name").takeIf { it.isNotEmpty() } ?: continue + tagSet.add(MangaTag(name, "tag:${obj.optString("id")}", source)) + } + } + + val chaptersJson = chaptersDeferred.await() + val chaptersArray = chaptersJson.optJSONArray("chapters") ?: JSONArray() + + val scanlators = mutableMapOf() + mangaPage.optJSONArray("scanlators")?.let { scanArr -> + for (i in 0 until scanArr.length()) { + val sc = scanArr.optJSONObject(i) ?: continue + val id = sc.optString("id").takeIf { it.isNotEmpty() } ?: continue + val name = sc.optString("name").takeIf { it.isNotEmpty() } ?: continue + scanlators[id] = name + } + } + + val chapters = (0 until chaptersArray.length()).map { i -> + val ch = chaptersArray.getJSONObject(i) + val chId = ch.getString("id") + val number = ch.optDouble("number", 0.0).toFloat() + val chTitle = ch.optString("title") + val scanId = ch.optString("scanlationMangaId").nullIfEmpty() + val scanName = scanId?.let { scanlators[it] } + val createdAt = ch.optLong("createdAt", 0L) + + MangaChapter( + id = generateUid("$mangaId/$chId"), + title = chTitle.ifBlank { "Chapter $number" }, + number = number, + volume = 0, + url = "$mangaId/$chId", + uploadDate = createdAt, + source = source, + scanlator = scanName, + branch = scanName, + ) + }.reversed() + + val groupedChapters = if (chapters.map { it.branch }.distinct().size > 1) { + chapters.map { it.copy(branch = it.branch ?: "Unknown") } + } else { + chapters.map { it.copy(branch = null, scanlator = null) } + } - val groupedChapters = if (chapters.map { it.branch }.distinct().size > 1) { - chapters.map { it.copy(branch = it.branch ?: "Unknown") } - } else { - chapters.map { it.copy(branch = null, scanlator = null) } + val altTitles = mangaPage.optJSONArray("otherNames")?.let { arr -> + (0 until arr.length()).mapNotNull { arr.optString(it) }.toSet() + } ?: emptySet() + + manga.copy( + title = title, + description = description, + coverUrl = coverUrl, + authors = authors, + state = state, + tags = tagSet, + rating = rating, + chapters = groupedChapters, + altTitles = altTitles, + ) } - val altTitles = mangaPage.optJSONArray("otherNames")?.let { arr -> - (0 until arr.length()).mapNotNull { arr.optString(it) }.toSet() - } ?: emptySet() + detailsCache.put(manga.url, result) + return result + } - return manga.copy( - title = title, - description = description, - coverUrl = coverUrl, - authors = authors, - state = state, - tags = tagSet, - chapters = groupedChapters, - altTitles = altTitles, - ) + @get:Synchronized + private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > 20 + } } override suspend fun getRelatedManga(seed: Manga): List { From ac57121558dd5e924027d085d1aee1cb538275bd Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:41:17 +0200 Subject: [PATCH 033/109] Bump version from 1.0.0 to 1.0.1 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index c0ab7906..81ed09e1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } group = "org.usagi" -version = "1.0.0" +version = "1.0.1" tasks.test { useJUnitPlatform() From f3def627611d029c4b412f056b377267a1f11f9b Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:39:34 +0200 Subject: [PATCH 034/109] Update MangaK.kt --- .../kotatsu/parsers/site/kotatsu/en/MangaK.kt | 162 +++++++++++++++--- 1 file changed, 137 insertions(+), 25 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaK.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaK.kt index e5d71fa7..508a8099 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaK.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaK.kt @@ -6,7 +6,9 @@ import org.json.JSONObject import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.exception.ParseException +import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.Demographic import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaListFilter @@ -32,6 +34,8 @@ import org.koitharu.kotatsu.parsers.util.urlEncoded import java.text.SimpleDateFormat import java.util.EnumSet import java.util.Locale +import okhttp3.Interceptor +import okhttp3.Response @MangaSourceParser("MANGAK", "MangaK", "en", ContentType.MANGA) internal class MangaK(context: MangaLoaderContext) : @@ -48,6 +52,18 @@ internal class MangaK(context: MangaLoaderContext) : keys.add(userAgentKey) } + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + if (!response.isSuccessful && request.url.host.matches(IMAGE_FALLBACK_REGEX)) { + response.close() + val newUrl = request.url.newBuilder().host("rx.rzyn.net").build() + return chain.proceed(request.newBuilder().url(newUrl).build()) + } + return response + } + + override val availableSortOrders: Set = EnumSet.of( SortOrder.UPDATED, SortOrder.NEWEST, @@ -65,7 +81,28 @@ internal class MangaK(context: MangaLoaderContext) : override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = fetchTags(), - availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, +// MangaState.PAUSED, +// MangaState.ABANDONED, + ), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ), + availableDemographics = EnumSet.of( + Demographic.SHOUJO, + Demographic.SEINEN, + Demographic.SHOUNEN, + Demographic.JOSEI, + ), + availableContentRating = EnumSet.of( + ContentRating.SAFE, + ContentRating.SUGGESTIVE, + ContentRating.ADULT, + ), ) private suspend fun fetchTags(): Set { @@ -79,7 +116,12 @@ internal class MangaK(context: MangaLoaderContext) : } } - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + override suspend fun getListPage( + page: Int, + order: SortOrder, + filter: MangaListFilter, + ): List { + val allExcludedTags = filter.tagsExclude.map { it.key }.toMutableSet() val query = filter.query val url = buildString { append(apiUrl) @@ -100,27 +142,70 @@ internal class MangaK(context: MangaLoaderContext) : if (!query.isNullOrEmpty()) { append("&q=") - append(query.urlEncoded()) + // api does not like commans and character limits to 60-70 + append(query.replace(",", "").urlEncoded()) } + if (filter.tags.isNotEmpty()) { append("&genres=") append(filter.tags.joinToString(",") { it.key }) } - if (filter.tagsExclude.isNotEmpty()) { + + if (allExcludedTags.isNotEmpty()) { append("&exclude=") - append(filter.tagsExclude.joinToString(",") { it.key }) + append(allExcludedTags.joinToString(",")) } + + filter.types.oneOrThrowIfMany()?.let { + append("&type=") + append( + when (it) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.MANHUA -> "manhua" + else -> null + } ?: return@let + ) + } + + filter.contentRating.oneOrThrowIfMany()?.let { + append("&content_rating=") + append( + when (it) { + ContentRating.SAFE -> "safe" + ContentRating.SUGGESTIVE -> "suggestive" + ContentRating.ADULT -> "pornographic" + } + ) + } + + filter.demographics.oneOrThrowIfMany()?.let { + append("&demographic=") + append( + when (it) { + Demographic.SHOUJO -> "shoujo" + Demographic.SEINEN -> "seinen" + Demographic.SHOUNEN -> "shounen" + Demographic.JOSEI -> "josei" + else -> null + } ?: return@let + ) + } + filter.states.oneOrThrowIfMany()?.let { append("&status=") append( when (it) { MangaState.ONGOING -> "ongoing" MangaState.FINISHED -> "completed" - else -> "all" - }, +// MangaState.PAUSED -> "hiatus" +// MangaState.ABANDONED -> "cancelled" + else -> null + } ?: return@let ) } } + val json = webClient.httpGet(url).parseJson() return json.getJSONObject("data").getJSONArray("items").mapJSON { item -> item.toManga() @@ -132,16 +217,13 @@ internal class MangaK(context: MangaLoaderContext) : return Manga( id = generateUid(getString("id")), title = getString("name"), - altTitles = emptySet(), + altTitles = parseAltTitles(), url = getString("id"), publicUrl = "https://$domain$relativeUrl", rating = optDouble("rating", 0.0).toRating(), - contentRating = null, + contentRating = optString("content_rating").nullIfEmpty().toContentRating(), coverUrl = optString("cover").nullIfEmpty(), - tags = optJSONArray("genres")?.mapJSONNotNullToSet { genre -> - val slug = genre.optString("slug").nullIfEmpty() ?: return@mapJSONNotNullToSet null - MangaTag(genre.getString("name").toTitleCase(sourceLocale), slug, source) - }.orEmpty(), + tags = parseTags(), state = optString("status").toMangaState(), authors = emptySet(), description = optString("summary").nullIfEmpty(), @@ -149,6 +231,34 @@ internal class MangaK(context: MangaLoaderContext) : ) } + + private fun String?.toContentRating() = when (this) { + "safe" -> ContentRating.SAFE + "suggestive" -> ContentRating.SUGGESTIVE + "erotica", "pornographic" -> ContentRating.ADULT + else -> null + } + + + private fun JSONObject.parseAltTitles(): Set { + val result = mutableSetOf() + optJSONArray("alt_names")?.let { arr -> + for (i in 0 until arr.length()) { + arr.optJSONObject(i)?.optString("name")?.nullIfEmpty()?.let { result.add(it) } + } + } + optString("alt_name").nullIfEmpty()?.let { result.add(it) } + return result + } + + + private fun JSONObject.parseTags(): Set = + optJSONArray("genres")?.mapJSONNotNullToSet { genre -> + val slug = genre.optString("slug").nullIfEmpty() ?: return@mapJSONNotNullToSet null + MangaTag(genre.getString("name").toTitleCase(sourceLocale), slug, source) + }.orEmpty() + + override suspend fun getDetails(manga: Manga): Manga { val json = webClient.httpGet("$apiUrl/titles/${manga.url}").parseJson() .getJSONObject("data").getJSONObject("title") @@ -159,18 +269,11 @@ internal class MangaK(context: MangaLoaderContext) : val artists = json.optJSONArray("artists")?.mapJSONNotNullToSet { it.optString("name").nullIfEmpty() }.orEmpty() - val tags = json.optJSONArray("genres")?.mapJSONNotNullToSet { genre -> - val slug = genre.optString("slug").nullIfEmpty() ?: return@mapJSONNotNullToSet null - MangaTag(genre.getString("name").toTitleCase(sourceLocale), slug, source) - }.orEmpty() - val altTitles = json.optString("alt_name").split(";") - .mapNotNull { it.trim().nullIfEmpty() } - .toSet() return manga.copy( title = json.optString("name").nullIfEmpty() ?: manga.title, - altTitles = altTitles, + altTitles = json.parseAltTitles().ifEmpty { manga.altTitles }, description = json.optString("summary").nullIfEmpty() ?: manga.description, - tags = tags.ifEmpty { manga.tags }, + tags = json.parseTags().ifEmpty { manga.tags }, authors = authors + artists, state = json.optString("status").toMangaState() ?: manga.state, rating = json.optDouble("rating", 0.0).toRating(), @@ -178,6 +281,7 @@ internal class MangaK(context: MangaLoaderContext) : ) } + private suspend fun fetchChapters(id: String, cv: Long): List { val json = webClient.httpGet("$apiUrl/titles/$id/chapters?cv=$cv").parseJson() val chapters = json.getJSONObject("data").getJSONArray("chapters").mapJSON { it } @@ -198,6 +302,7 @@ internal class MangaK(context: MangaLoaderContext) : }.reversed() } + override suspend fun getPages(chapter: MangaChapter): List { val doc = webClient.httpGet("https://$domain${chapter.url}").parseHtml() val raw = doc.selectFirst("script#__NEXT_DATA__")?.data() @@ -218,11 +323,18 @@ internal class MangaK(context: MangaLoaderContext) : } } - private fun Double.toRating(): Float = if (this > 0.0) (this / 5.0).toFloat() else RATING_UNKNOWN - private fun String?.toMangaState(): MangaState? = when (this?.lowercase(Locale.US)) { + private fun Double.toRating() = if (this > 0.0) (this / 5.0).toFloat() else RATING_UNKNOWN + + private fun String?.toMangaState() = when (this?.lowercase(Locale.US)) { "ongoing" -> MangaState.ONGOING "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + "cancelled" -> MangaState.ABANDONED else -> null } -} \ No newline at end of file + + companion object { + private val IMAGE_FALLBACK_REGEX = Regex("rx\\.qvzr[a-z]\\.org") + } +} From c616342320a44fe77e8b1951de12ab809240597d Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:57:08 +0200 Subject: [PATCH 035/109] Update Webtoons.kt --- .../parsers/site/kotatsu/all/Webtoons.kt | 268 ++++++++++++++---- 1 file changed, 211 insertions(+), 57 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Webtoons.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Webtoons.kt index eb1e87dd..500ae46c 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Webtoons.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Webtoons.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.all import androidx.collection.arraySetOf import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import okhttp3.HttpUrl.Companion.toHttpUrl import org.jsoup.nodes.Element import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser @@ -14,6 +15,11 @@ import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import java.util.EnumSet +private const val ID_SEARCH_PREFIX = "id:" + +private const val webtoons = "webtoons" +private const val canvas = "canvas" + internal abstract class WebtoonsParser( context: MangaLoaderContext, source: MangaParserSource, @@ -24,6 +30,14 @@ internal abstract class WebtoonsParser( private val mobileApiDomain = "m.webtoons.com" private val staticDomain = "webtoon-phinf.pstatic.net" + private val contentKey = ConfigKey.PreferredImageServer( + presetValues = mapOf( + webtoons to "Webtoons", + canvas to "Canvas", + ), + defaultValue = webtoons, + ) + override val availableSortOrders: EnumSet = EnumSet.of( SortOrder.POPULARITY, SortOrder.RATING, @@ -35,8 +49,7 @@ internal abstract class WebtoonsParser( isSearchSupported = true, ) - override val userAgentKey = - ConfigKey.UserAgent("Mozilla/5.0 (Linux; Android 12; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36") + override val userAgentKey = ConfigKey.UserAgent("Mozilla/5.0 (Linux; Android 12; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36") override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = availableTags(), @@ -45,6 +58,7 @@ internal abstract class WebtoonsParser( override fun onCreateConfig(keys: MutableCollection>) { super.onCreateConfig(keys) keys.add(userAgentKey) + keys.add(contentKey) } override suspend fun getPageUrl(page: MangaPage): String { @@ -59,7 +73,12 @@ internal abstract class WebtoonsParser( } private suspend fun fetchEpisodes(titleNo: Long, type: String): List { - val url = "https://$mobileApiDomain/api/v1/$type/$titleNo/episodes?pageSize=99999" + val baseUrl = "https://$mobileApiDomain/api/v1/$type/$titleNo/episodes" + val url = if (type == "canvas") { + "$baseUrl?pageSize=99999&readingLanguageCode=$languageCode" + } else { + "$baseUrl?pageSize=99999" + } val json = webClient.httpGet(url).parseJson() val episodeList = json.optJSONObject("result")?.optJSONArray("episodeList") @@ -69,10 +88,15 @@ internal abstract class WebtoonsParser( val episodeTitle = jo.getStringOrNull("episodeTitle") ?: "" val episodeNo = jo.getInt("episodeNo") val viewerLink = jo.getString("viewerLink") + val hasBgm = jo.optBoolean("hasBgm", false) MangaChapter( id = generateUid("$titleNo-$episodeNo"), - title = episodeTitle, + title = buildString { + append(org.jsoup.parser.Parser.unescapeEntities(episodeTitle, false)) + append(" (ch. $episodeNo)") + if (hasBgm) append(" ♫") + }, number = episodeNo.toFloat(), volume = 0, url = viewerLink, @@ -81,8 +105,7 @@ internal abstract class WebtoonsParser( scanlator = null, source = source, ) - }.sortedBy(MangaChapter::number) - + }.sortedBy { it.number } } override suspend fun getDetails(manga: Manga): Manga = coroutineScope { @@ -108,10 +131,15 @@ internal abstract class WebtoonsParser( val author = listOf( doc.select("meta[property='com-linewebtoon:webtoon:author']").attr("content"), - doc.select(".detail_header .info .author").firstOrNull()?.text(), + doc.select(".detail_header .info .author:nth-of-type(1)").firstOrNull()?.ownText(), doc.select(".author_area").text(), ).firstOrNull { !it.isNullOrBlank() && it != "null" } + val artist = listOf( + doc.select(".detail_header .info .author:nth-of-type(2)").firstOrNull()?.ownText(), + doc.select(".author_area").text().takeIf { it != author }, + ).firstOrNull { !it.isNullOrBlank() } ?: author + val genreElements = doc.select(".detail_header .info .genre").ifEmpty { doc.select("h2.genre") } @@ -140,7 +168,7 @@ internal abstract class WebtoonsParser( coverUrl = coverUrl, largeCoverUrl = null, tags = genres.map { genre -> MangaTag(title = genre, key = genre.lowercase(), source = source) }.toSet(), - authors = setOfNotNull(author.takeIf { it != "null" }), + authors = setOfNotNull(author, artist).filter { it != "null" }.toSet(), description = description, state = state, chapters = chapters, @@ -182,39 +210,132 @@ internal abstract class WebtoonsParser( } override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List { - val query = filter.query - val document = when { - !query.isNullOrEmpty() -> { - val searchUrl = "https://$domain/$languageCode/search?keyword=${query.urlEncoded()}" - webClient.httpGet(searchUrl).parseHtml() + val query = filter.query.orEmpty() + val currentContent = config[contentKey] ?: webtoons + + var directTitleNo: Long? = null + var directType: String? = null + + when { + query.startsWith("https://") -> { + val url = query.toHttpUrl() + if (!url.host.endsWith("webtoons.com")) { + throw Exception("Unsupported domain: ${url.host}. Only webtoons.com and m.webtoons.com are allowed.") + } + directTitleNo = url.queryParameter("title_no")?.toLongOrNull() + ?: throw Exception("Missing 'title_no' parameter in URL") + val path = url.pathSegments.filter(String::isNotEmpty) + if (path.size < 2) throw Exception("Invalid URL path") + if (path[0] != languageCode) { + throw Exception("URL language (${path[0]}) does not match parser language ($languageCode)") + } + directType = if (path.getOrNull(1) == "canvas") canvas else webtoons + } + + query.startsWith(ID_SEARCH_PREFIX) -> { + val idPart = query.removePrefix(ID_SEARCH_PREFIX) + val parts = idPart.split(":") + val (type, lang, titleNo) = when (parts.size) { + 1 -> Triple(currentContent, languageCode, parts[0].toLongOrNull() + ?: throw Exception("Invalid title number: ${parts[0]}")) + 2 -> Triple(parts[0], languageCode, parts[1].toLongOrNull() + ?: throw Exception("Invalid title number: ${parts[1]}")) + 3 -> Triple(parts[0], parts[1], parts[2].toLongOrNull() + ?: throw Exception("Invalid title number: ${parts[2]}")) + else -> throw Exception("Invalid id search format. Use id:type:lang:titleNo, id:type:titleNo, or id:titleNo") + } + if (lang != languageCode) return emptyList() + if (type !in listOf(webtoons, canvas)) { + throw Exception("Unknown type '$type'. Use 'webtoons' or 'canvas'.") + } + directTitleNo = titleNo + directType = type } - filter.tags.isNotEmpty() -> { - val selectedGenre = filter.tags.first() - val genreUrlPath = genreUrlMap[selectedGenre.key] ?: selectedGenre.key - val sortParam = getSortOrderParam(order) - val genreUrl = "https://$domain/$languageCode/genres/$genreUrlPath?sortOrder=$sortParam" - webClient.httpGet(genreUrl).parseHtml() + query.toLongOrNull() != null -> { + directTitleNo = query.toLong() + directType = currentContent } + } + + if (directTitleNo != null && directType != null) { + val detailsUrl = "https://$domain/$languageCode/$directType/placeholder/list?title_no=$directTitleNo" + val manga = getDetails(Manga( + id = generateUid(directTitleNo), + title = "", + altTitles = emptySet(), + url = directTitleNo.toString(), + publicUrl = detailsUrl, + rating = RATING_UNKNOWN, + contentRating = null, + coverUrl = null, + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + )) + return listOf(manga) + } - else -> { - val rankingType = when (order) { - SortOrder.POPULARITY -> "popular" - SortOrder.RATING -> "trending" - SortOrder.UPDATED -> "originals" - else -> "popular" + val page = offset / 20 + 1 + val inPageOffset = offset % 20 + val selectedGenre = filter.tags.firstOrNull() + + val url = buildString { + append("https://$domain/$languageCode") + when { + query.isNotBlank() -> { + append("/search") + var cleanQuery = query + var searchType: String? = null + when { + cleanQuery.startsWith("type:originals ") -> { + searchType = "originals" + cleanQuery = cleanQuery.removePrefix("type:originals ").trim() + } + cleanQuery.startsWith("type:canvas ") -> { + searchType = "canvas" + cleanQuery = cleanQuery.removePrefix("type:canvas ").trim() + } + } + if (searchType == null) { + searchType = when (currentContent) { + webtoons -> "originals" + canvas -> "canvas" + else -> null + } + } + if (searchType != null) append("/$searchType") + append("?keyword=${cleanQuery.urlEncoded()}") + if (page > 1) append("&page=$page") + } + selectedGenre != null -> { + val genreUrlPath = genreUrlMap[selectedGenre.key] ?: selectedGenre.key + append("/genres/$genreUrlPath") + append("?sortOrder=${getSortOrderParam(order)}") + if (page > 1) append("&page=$page") + } + else -> { + val rankingType = when { + currentContent == canvas -> "canvas" + else -> when (order) { + SortOrder.POPULARITY -> "popular" + SortOrder.RATING -> "trending" + SortOrder.UPDATED -> "originals" + else -> "popular" + } + } + append("/ranking/$rankingType") + if (page > 1) append("?page=$page") } - val rankingUrl = "https://$domain/$languageCode/ranking/$rankingType" - webClient.httpGet(rankingUrl).parseHtml() } } - val selectedGenreForManga = if (filter.tags.isNotEmpty()) filter.tags.first() else null + val doc = webClient.httpGet(url).parseHtml() + val items = doc.select(".webtoon_list li a, .card_wrap .card_item a") + .map { element -> createMangaFromElement(element, source as MangaParserSource, selectedGenre) } - return document.select(".webtoon_list li a, .card_wrap .card_item a") - .map { element -> createMangaFromElement(element, source as MangaParserSource, selectedGenreForManga) } - .drop(offset) - .take(20) + return items.drop(inPageOffset).take(20) } private fun createMangaFromElement( @@ -236,11 +357,11 @@ internal abstract class WebtoonsParser( rating = RATING_UNKNOWN, contentRating = null, coverUrl = thumbnailUrl.toAbsoluteUrl(staticDomain), - largeCoverUrl = null, tags = selectedGenre?.let { setOf(it) } ?: emptySet(), + state = null, authors = emptySet(), + largeCoverUrl = null, description = null, - state = null, source = source, ) } @@ -258,44 +379,77 @@ internal abstract class WebtoonsParser( throw ParseException("Failed to get pages for chapter: ${chapter.title}", chapter.url, e) } - fun extractImages(selector: String, attr: String = "data-url"): List { - return doc.select(selector).mapIndexedNotNull { i, element -> - val url = element.attr(attr).takeIf { it.isNotBlank() } - ?: element.attr("src").takeIf { it.contains(staticDomain) } - ?: return@mapIndexedNotNull null - MangaPage( - id = generateUid("${chapter.id}-$i"), - url = url, - preview = null, - source = source, - ) + val images = doc.select("div#_imageList > img").mapIndexedNotNull { i, element -> + val rawUrl = element.attr("data-url").takeIf { it.isNotBlank() } + ?: element.attr("src").takeIf { it.contains(staticDomain) } + ?: return@mapIndexedNotNull null + + MangaPage( + id = generateUid("${chapter.id}-$i"), + url = rawUrl, + preview = null, + source = source, + ) + }.toMutableList() + + if (images.isEmpty()) { + val motionPages = fetchMotionToonPages(doc) + if (motionPages.isNotEmpty()) { + images.addAll(motionPages) } } - return extractImages("div#_imageList > img") - .ifEmpty { extractImages("canvas[data-url]") } - .ifEmpty { extractImages("img[src*='$staticDomain'], img[data-url*='$staticDomain']") } - .ifEmpty { throw ParseException("No images found in chapter.", chapter.url) } + if (images.isEmpty()) { + throw ParseException("No images found in chapter.", chapter.url) + } + return images + } + + private suspend fun fetchMotionToonPages(doc: org.jsoup.nodes.Document): List { + val docString = doc.toString() + val docUrlRegex = Regex("documentURL:.*?'(.*?)'") + val motionToonPathRegex = Regex("jpg:.*?'(.*?)\\{") + + val docUrl = docUrlRegex.find(docString)?.groupValues?.get(1) ?: return emptyList() + val motionToonPath = motionToonPathRegex.find(docString)?.groupValues?.get(1) ?: return emptyList() + + return try { + val motionToonResponse = webClient.httpGet(docUrl).parseJson() + val imagesMap = motionToonResponse.optJSONObject("assets")?.optJSONObject("images") + imagesMap?.let { map -> + map.keySet().filter { it.contains("layer") }.mapIndexedNotNull { i, key -> + val relPath = map.optString(key, null) ?: return@mapIndexedNotNull null + MangaPage( + id = generateUid("motion-$i"), + url = motionToonPath + relPath, + preview = null, + source = source, + ) + } + } ?: emptyList() + } catch (_: Exception) { + emptyList() + } } - @MangaSourceParser("WEBTOONS_EN", "Webtoons English", "en", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_EN", "Webtoons (English)", "en", type = ContentType.MANGA) class English(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_EN) - @MangaSourceParser("WEBTOONS_ID", "Webtoons Indonesia", "id", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_ID", "Webtoons (Indonesia)", "id", type = ContentType.MANGA) class Indonesian(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_ID) - @MangaSourceParser("WEBTOONS_ES", "Webtoons Spanish", "es", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_ES", "Webtoons (Spanish)", "es", type = ContentType.MANGA) class Spanish(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_ES) - @MangaSourceParser("WEBTOONS_FR", "Webtoons French", "fr", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_FR", "Webtoons (French)", "fr", type = ContentType.MANGA) class French(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_FR) - @MangaSourceParser("WEBTOONS_TH", "Webtoons Thai", "th", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_TH", "Webtoons (Thai)", "th", type = ContentType.MANGA) class Thai(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_TH) - @MangaSourceParser("WEBTOONS_ZH", "Webtoons Chinese", "zh", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_ZH", "Webtoons (Chinese)", "zh", type = ContentType.MANGA) class Chinese(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_ZH) - @MangaSourceParser("WEBTOONS_DE", "Webtoons German", "de", type = ContentType.MANGA) + @MangaSourceParser("WEBTOONS_DE", "Webtoons (German)", "de", type = ContentType.MANGA) class German(context: MangaLoaderContext) : WebtoonsParser(context, MangaParserSource.WEBTOONS_DE) -} \ No newline at end of file +} From 44bea8249d01990c88501cf91b8d4cda5a165fe6 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:24:37 +0200 Subject: [PATCH 036/109] Refactor MGKomik to support Interceptor and auth Updated MGKomik class to implement Interceptor and MangaParserAuthProvider. Added intercept method for handling AJAX requests. --- .../parsers/site/kotatsu/id/MGKomik.kt | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/MGKomik.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/MGKomik.kt index e2ebb5c6..225bb7a3 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/MGKomik.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/MGKomik.kt @@ -1,28 +1,53 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.id -import androidx.collection.arraySetOf +import okhttp3.Interceptor import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.parsers.MadaraParser import org.koitharu.kotatsu.parsers.util.* +import androidx.collection.arraySetOf @MangaSourceParser("MGKOMIK", "MGKomik", "id") -internal class MGKomik(context: MangaLoaderContext, ): - MadaraParser(context, MangaParserSource.MGKOMIK, "id.mgkomik.cc",) { - - override val withoutAjax = true +internal class MGKomik(context: MangaLoaderContext) : + MadaraParser(context, MangaParserSource.MGKOMIK, "id.mgkomik.cc"), Interceptor, MangaParserAuthProvider { + override val datePattern = "dd MMM yy" override val tagPrefix = "genres/" + override val selectDesc = "div.description-summary div.summary__content p" - override val datePattern = "dd MMM yy" + override val withoutAjax = true - override val selectDesc = "div.description-summary div.summary__content p" + override fun intercept(chain: Interceptor.Chain): okhttp3.Response { + val request = chain.request() + val path = request.url.encodedPath + val isAjax = path.contains("admin-ajax.php") || + path.contains("wp-json") || + path.endsWith("/ajax/chapters") - override suspend fun fetchAvailableTags(): Set { - return super.fetchAvailableTags() + return if (isAjax) { + chain.proceed( + request.newBuilder() + .header("Sec-CH-UA-Model", "\"\"") + .header("X-Requested-With", "XMLHttpRequest") + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Origin", "https://$domain") + .header("Priority", "u=1, i") + .removeHeader("Sec-Fetch-User") + .removeHeader("Upgrade-Insecure-Requests") + .build() + ) + } else { + chain.proceed( + request.newBuilder() + .header("Sec-CH-UA-Model", "\"\"") + .build() + ) + } } - override suspend fun getListPage( page: Int, order: SortOrder, @@ -45,10 +70,16 @@ internal class MGKomik(context: MangaLoaderContext, ): return super.getListPage(page, order, filter) } + override suspend fun fetchAvailableTags(): Set { + return super.fetchAvailableTags() + } + + override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = availableTags(), ) + private fun availableTags() = arraySetOf( MangaTag("2 hours ago", "2-hours-ago", source), MangaTag("7 hours ago", "7-hours-ago", source), @@ -143,4 +174,5 @@ internal class MGKomik(context: MangaLoaderContext, ): MangaTag("Wuxia", "wuxia", source), MangaTag("Yuri", "yuri", source), ) + } From 0aefae70eecee5bae0c382d4737697a1b5a88166 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:58:23 +0200 Subject: [PATCH 037/109] Delete src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/id/ainzscans directory --- .../site/tachiyomi/id/ainzscans/AinzScans.kt | 146 ------------------ .../tachiyomi/id/ainzscans/AinzScansDto.kt | 107 ------------- .../id/ainzscans/AinzScansFilters.kt | 133 ---------------- 3 files changed, 386 deletions(-) delete mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/id/ainzscans/AinzScans.kt delete mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/id/ainzscans/AinzScansDto.kt delete mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/id/ainzscans/AinzScansFilters.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/id/ainzscans/AinzScans.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/id/ainzscans/AinzScans.kt deleted file mode 100644 index a3a118db..00000000 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/id/ainzscans/AinzScans.kt +++ /dev/null @@ -1,146 +0,0 @@ -package org.koitharu.kotatsu.parsers.site.tachiyomi.id.ainzscans - -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.source.model.FilterList -import eu.kanade.tachiyomi.source.model.MangasPage -import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.source.model.SChapter -import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.source.online.HttpSource -import keiyoushi.utils.parseAs -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.Request -import okhttp3.Response -import org.koitharu.kotatsu.parsers.TachiyomiSource -import java.text.SimpleDateFormat -import java.util.Locale -import java.util.TimeZone - -@TachiyomiSource("AINZSCANS", "Ainz Scans", "id") -class AinzScans : HttpSource() { - - override val name = "Ainz Scans ID" - - override val baseUrl = "https://v2.ainzscans01.com" - - override val lang = "id" - - override val supportsLatest = true - - private val apiUrl = "https://api.ainzscans01.com/api" - - override fun headersBuilder() = super.headersBuilder() - .add("Referer", "$baseUrl/") - .add("Origin", baseUrl) - - // ============================== Popular =============================== - override fun popularMangaRequest(page: Int): Request { - val url = "$apiUrl/search".toHttpUrl().newBuilder() - .addQueryParameter("type", "COMIC") - .addQueryParameter("sort", "views") - .addQueryParameter("order", "desc") - .addQueryParameter("limit", "20") - .addQueryParameter("page", page.toString()) - .build() - return GET(url, headers) - } - - override fun popularMangaParse(response: Response): MangasPage { - val dto = response.parseAs() - val page = response.request.url.queryParameter("page")?.toIntOrNull() ?: 1 - return dto.toMangasPage(page) - } - - // =============================== Latest =============================== - override fun latestUpdatesRequest(page: Int): Request { - val url = "$apiUrl/search".toHttpUrl().newBuilder() - .addQueryParameter("type", "COMIC") - .addQueryParameter("sort", "latest") - .addQueryParameter("order", "desc") - .addQueryParameter("limit", "20") - .addQueryParameter("page", page.toString()) - .build() - return GET(url, headers) - } - - override fun latestUpdatesParse(response: Response) = popularMangaParse(response) - - // =============================== Search =============================== - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val url = "$apiUrl/search".toHttpUrl().newBuilder() - .addQueryParameter("type", "COMIC") - .addQueryParameter("limit", "20") - .addQueryParameter("page", page.toString()) - - url.addQueryParameter("q", query) - - filters.forEach { filter -> - when (filter) { - is SortFilter -> url.addQueryParameter("sort", filter.selectedValue()) - is OrderFilter -> url.addQueryParameter("order", filter.selectedValue()) - is StatusFilter -> url.addQueryParameter("status", filter.selectedValue()) - is GenreFilter -> url.addQueryParameter("genre", filter.selectedValue()) - is TypeFilter -> url.addQueryParameter("comic_type", filter.selectedValue()) - is ColorFilter -> url.addQueryParameter("color_format", filter.selectedValue()) - is ReadingFilter -> url.addQueryParameter("reading_format", filter.selectedValue()) - is TextFilter -> url.addQueryParameter(filter.queryKey, filter.state) - else -> {} - } - } - - return GET(url.build(), headers) - } - - override fun searchMangaParse(response: Response) = popularMangaParse(response) - - override fun getMangaUrl(manga: SManga): String = "$baseUrl${getNormalizedMangaUrl(manga)}" - - // =============================== Details =============================== - override fun mangaDetailsRequest(manga: SManga): Request = GET("$apiUrl/series${getNormalizedMangaUrl(manga)}", headers) - - override fun mangaDetailsParse(response: Response): SManga = response.parseAs().toSManga() - - override fun getChapterUrl(chapter: SChapter): String = "$baseUrl${chapter.url}" - - // ============================== Chapters =============================== - override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga) - - override fun chapterListParse(response: Response): List { - val dto = response.parseAs() - val comicSlug = dto.toSManga().url.substringAfterLast("/") - return dto.units.map { it.toSChapter(comicSlug, dateFormat) } - } - - // =============================== Pages ================================ - override fun pageListRequest(chapter: SChapter): Request = GET("$apiUrl/series${chapter.url}", headers) - - override fun pageListParse(response: Response): List = response.parseAs().toPageList() - - override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() - - // =============================== Filters =============================== - override fun getFilterList() = FilterList( - SortFilter(), - OrderFilter(), - StatusFilter(), - GenreFilter(), - TypeFilter(), - ColorFilter(), - ReadingFilter(), - TextFilter("Author", "author"), - TextFilter("Artist", "artist"), - TextFilter("Publisher", "publisher"), - ) - - private fun getNormalizedMangaUrl(manga: SManga): String = if (manga.url.startsWith("/series/")) { - "/comic/${manga.url.substringAfter("/series/").removeSuffix("/")}" - } else { - manga.url - } - - private val dateFormat by lazy { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/id/ainzscans/AinzScansDto.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/id/ainzscans/AinzScansDto.kt deleted file mode 100644 index 054a3b8d..00000000 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/id/ainzscans/AinzScansDto.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.koitharu.kotatsu.parsers.site.tachiyomi.id.ainzscans - -import eu.kanade.tachiyomi.source.model.MangasPage -import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.source.model.SChapter -import eu.kanade.tachiyomi.source.model.SManga -import keiyoushi.utils.tryParse -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.jsoup.Jsoup -import java.text.SimpleDateFormat - -@Serializable -internal class SearchResponseDto( - private val data: List, - @SerialName("total_pages") private val totalPages: Int, -) { - fun toMangasPage(page: Int): MangasPage = MangasPage( - data.map { it.toSManga() }, - page < totalPages, - ) -} - -@Serializable -internal class MangaDto( - private val title: String, - private val slug: String, - @SerialName("poster_image_url") private val posterImageUrl: String? = null, -) { - fun toSManga(): SManga = SManga.create().apply { - url = "/comic/$slug" - title = this@MangaDto.title - thumbnail_url = posterImageUrl - } -} - -@Serializable -internal class SeriesDetailDto( - private val title: String, - private val slug: String, - private val synopsis: String? = null, - @SerialName("poster_image_url") private val posterImageUrl: String? = null, - @SerialName("comic_status") private val comicStatus: String? = null, - @SerialName("author_name") private val authorName: String? = null, - @SerialName("artist_name") private val artistName: String? = null, - val units: List = emptyList(), -) { - fun toSManga(): SManga = SManga.create().apply { - url = "/comic/$slug" - title = this@SeriesDetailDto.title - thumbnail_url = posterImageUrl - author = authorName - artist = artistName - description = synopsis?.let { Jsoup.parse(it).text() } - status = when (comicStatus?.uppercase()) { - "ONGOING" -> SManga.ONGOING - "COMPLETED" -> SManga.COMPLETED - "HIATUS" -> SManga.ON_HIATUS - else -> SManga.UNKNOWN - } - initialized = true - } -} - -@Serializable -internal class ChapterDto( - private val slug: String, - private val number: String, - @SerialName("created_at") private val createdAt: String? = null, -) { - fun toSChapter(comicSlug: String, dateFormat: SimpleDateFormat): SChapter = SChapter.create().apply { - url = "/comic/$comicSlug/chapter/$slug" - name = "Chapter ${number.removeSuffix(".00")}" - chapter_number = number.toFloatOrNull() ?: -1f - date_upload = dateFormat.tryParse(createdAt) - } -} - -@Serializable -internal class ChapterDetailDto( - private val chapter: ChapterPagesDto, -) { - fun toPageList(): List = chapter.toPageList() -} - -@Serializable -internal class ChapterPagesDto( - private val pages: List, -) { - fun toPageList(): List = pages.mapIndexed { i, page -> page.toPage(i) } -} - -@Serializable -internal class PageDto( - @SerialName("image_url") private val imageUrl: String, -) { - fun toPage(index: Int): Page { - val url = when { - imageUrl.startsWith("http") -> imageUrl - imageUrl.startsWith("//") -> "https:$imageUrl" - else -> "https://api.ainzscans01.com$imageUrl" - } - - return Page(index, imageUrl = url) - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/id/ainzscans/AinzScansFilters.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/id/ainzscans/AinzScansFilters.kt deleted file mode 100644 index 5bc7b66a..00000000 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/id/ainzscans/AinzScansFilters.kt +++ /dev/null @@ -1,133 +0,0 @@ -package org.koitharu.kotatsu.parsers.site.tachiyomi.id.ainzscans - -import eu.kanade.tachiyomi.source.model.Filter - -abstract class SelectFilter( - name: String, - private val options: Array>, -) : Filter.Select(name, options.map { it.first }.toTypedArray()) { - fun selectedValue() = options[state].second -} - -class SortFilter : - SelectFilter( - "Sort By", - arrayOf( - Pair("Latest", "latest"), - Pair("Newest", "new"), - Pair("Top Views", "views"), - Pair("Top Rating", "rate"), - Pair("Top Bookmark", "bookmark"), - Pair("Title A-Z", "az"), - Pair("Title Z-A", "za"), - ), - ) - -class OrderFilter : - SelectFilter( - "Order By", - arrayOf( - Pair("Descending", "desc"), - Pair("Ascending", "asc"), - ), - ) - -class StatusFilter : - SelectFilter( - "Status", - arrayOf( - Pair("All", ""), - Pair("Ongoing", "ONGOING"), - Pair("Completed", "COMPLETED"), - Pair("Hiatus", "HIATUS"), - ), - ) - -class GenreFilter : - SelectFilter( - "Genre", - arrayOf( - Pair("All", ""), - Pair("Action", "action"), - Pair("Adult", "adult"), - Pair("Adventure", "adventure"), - Pair("Beasts", "beasts"), - Pair("Comedy", "comedy"), - Pair("Cooking", "cooking"), - Pair("Crime", "crime"), - Pair("Drama", "drama"), - Pair("Ecchi", "ecchi"), - Pair("Fantasy", "fantasy"), - Pair("Gender Bender", "gender-bender"), - Pair("Gore", "gore"), - Pair("Harem", "harem"), - Pair("Historical", "historical"), - Pair("Horror", "horror"), - Pair("Isekai", "isekai"), - Pair("Josei", "josei"), - Pair("Magic", "magic"), - Pair("Manga", "manga"), - Pair("Manhwa", "manhwa"), - Pair("Martial Arts", "martial-arts"), - Pair("Mature", "mature"), - Pair("Mecha", "mecha"), - Pair("Medical", "medical"), - Pair("Military", "military"), - Pair("Monsters", "monsters"), - Pair("Murim", "murim"), - Pair("Music", "music"), - Pair("Mystery", "mystery"), - Pair("Psychological", "psychological"), - Pair("Reincarnation", "reincarnation"), - Pair("Romance", "romance"), - Pair("School Life", "school-life"), - Pair("Sci Fi", "sci-fi"), - Pair("Seinen", "seinen"), - Pair("Shotacon", "shotacon"), - Pair("Shoujo", "shoujo"), - Pair("Shounen", "shounen"), - Pair("Shounen Ai", "shounen-ai"), - Pair("Slice Of Life", "slice-of-life"), - Pair("Smut", "smut"), - Pair("Sports", "sports"), - Pair("Supernatural", "supernatural"), - Pair("Survival", "survival"), - Pair("System", "system"), - Pair("Thriller", "thriller"), - Pair("Tragedy", "tragedy"), - Pair("Wuxia", "wuxia"), - ), - ) - -class TypeFilter : - SelectFilter( - "Type", - arrayOf( - Pair("All", ""), - Pair("Manhua", "MANHUA"), - Pair("Manhwa", "MANHWA"), - Pair("Manga", "MANGA"), - ), - ) - -class ColorFilter : - SelectFilter( - "Color", - arrayOf( - Pair("All", ""), - Pair("Full Color", "FULL_COLOR"), - Pair("B&W", "BW"), - ), - ) - -class ReadingFilter : - SelectFilter( - "Reading Mode", - arrayOf( - Pair("All", ""), - Pair("Vertical Scroll", "VERTICAL_SCROLL"), - Pair("Page", "PAGE"), - ), - ) - -class TextFilter(name: String, val queryKey: String) : Filter.Text(name) \ No newline at end of file From 82b296fe0b09a7073464dd8f9c80a050804a008d Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:59:10 +0200 Subject: [PATCH 038/109] Create AinzScans.kt --- .../parsers/site/kotatsu/id/AinzScans.kt | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/AinzScans.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/AinzScans.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/AinzScans.kt new file mode 100644 index 00000000..016822ce --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/AinzScans.kt @@ -0,0 +1,306 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.id + +import okhttp3.Headers +import org.json.JSONObject +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.util.* + +@MangaSourceParser("AINZSCANS", "AinzScans", "id") +internal class AinzScans(context: MangaLoaderContext) : + PagedMangaParser(context, MangaParserSource.AINZSCANS, pageSize = 30, searchPageSize = 30) { + + override val configKeyDomain = ConfigKey.Domain("v2.ainzscans01.com") + + override val sourceLocale: Locale = Locale.ENGLISH + + override val availableSortOrders: Set + get() = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.NEWEST, + SortOrder.ALPHABETICAL, + SortOrder.ALPHABETICAL_DESC, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = false, + isTagsExclusionSupported = false, + ) + + override suspend fun getFavicons(): Favicons { + return Favicons.single("https://api.ainzscans01.com/api/uploads/site-branding/favicon-1773679687168-jsyhcr.png") + } + + override suspend fun getFilterOptions(): MangaListFilterOptions { + return MangaListFilterOptions( + availableTags = fetchGenreMap().values.toSet(), + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED), + availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA), + ) + } + + override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder() + .add("Referer", "https://$domain/") + .add("Origin", "https://$domain") + .build() + + private var genreCache: Map? = null + + private suspend fun fetchGenreMap(): Map { + genreCache?.let { return it } + val url = "https://api.ainzscans01.com/api/genres" + val jsonStr = webClient.httpGet(url).body?.string() ?: return emptyMap() + val map = mutableMapOf() + try { + val array = org.json.JSONArray(jsonStr) + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + val name = obj.optString("name") + val slug = obj.optString("slug") + if (name.isEmpty() || slug.isEmpty()) continue + map[slug] = MangaTag( + title = name, + key = slug, + source = source, + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + genreCache = map + return map + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query + val url = buildString { + append("https://api.ainzscans01.com/api/search?type=COMIC") + append("&limit=").append(pageSize) + append("&page=").append(page) + + if (!query.isNullOrEmpty()) { + append("&q=").append(query.urlEncoded()) + } + + append("&sort=") + append( + when (order) { + SortOrder.UPDATED -> "new" + SortOrder.POPULARITY -> "views" + SortOrder.NEWEST -> "new" + SortOrder.ALPHABETICAL -> "az" + SortOrder.ALPHABETICAL_DESC -> "za" + else -> "new" + }, + ) + append("&order=desc") + + filter.tags.firstOrNull()?.let { + append("&genre=").append(it.key) + } + + filter.states.oneOrThrowIfMany()?.let { state -> + append("&status=") + append( + when (state) { + MangaState.ONGOING -> "ONGOING" + MangaState.FINISHED -> "COMPLETED" + MangaState.PAUSED -> "HIATUS" + else -> "" + }, + ) + } + + filter.types.oneOrThrowIfMany()?.let { type -> + append("&comic_type=") + append( + when (type) { + ContentType.MANGA -> "MANGA" + ContentType.MANHWA -> "MANHWA" + ContentType.MANHUA -> "MANHUA" + else -> "" + }, + ) + } + } + + val json = webClient.httpGet(url).body?.string() ?: return emptyList() + return parseListJson(json) + } + + private fun parseListJson(json: String): List { + val result = mutableListOf() + try { + val responseObj = org.json.JSONObject(json) + val dataArray = responseObj.optJSONArray("data") ?: return emptyList() + + for (i in 0 until dataArray.length()) { + val obj = dataArray.getJSONObject(i) + val slug = obj.optString("slug") + if (slug.isEmpty()) continue + + val relativeUrl = "/comic/$slug" + val rating = obj.optString("rating_average").toFloatOrNull()?.div(10f) ?: RATING_UNKNOWN + val state = when (obj.optString("comic_status").uppercase()) { + "ONGOING" -> MangaState.ONGOING + "COMPLETED" -> MangaState.FINISHED + "HIATUS" -> MangaState.PAUSED + else -> null + } + + val manga = Manga( + id = generateUid(relativeUrl), + url = relativeUrl, + title = obj.optString("title").ifEmpty { slug }, + altTitles = emptySet(), + publicUrl = "https://$domain/comic/$slug", + rating = rating, + contentRating = ContentRating.SAFE, + coverUrl = obj.optString("poster_image_url").nullIfEmpty(), + tags = emptySet(), + state = state, + authors = setOfNotNull(obj.optString("author_name").takeIf { it.isNotEmpty() }), + source = source, + ) + result.add(manga) + } + } catch (e: Exception) { + e.printStackTrace() + } + return result + } + + override suspend fun getDetails(manga: Manga): Manga { + val slug = manga.url.substringAfter("/comic/") + val url = "https://api.ainzscans01.com/api/series/comic/$slug" + + val json = webClient.httpGet(url).body?.string() + ?: throw Exception("Failed to fetch manga details") + return parseDetailsJson(json, manga) + } + + private fun parseDetailsJson(json: String, manga: Manga): Manga { + try { + val obj = JSONObject(json) + val slug = obj.optString("slug") + val relativeUrl = "/comic/$slug" + + val description = obj.optString("synopsis").nullIfEmpty() + val rating = obj.optString("rating_average").toFloatOrNull()?.div(10f) ?: RATING_UNKNOWN + val state = when (obj.optString("comic_status").uppercase()) { + "ONGOING" -> MangaState.ONGOING + "COMPLETED" -> MangaState.FINISHED + "HIATUS" -> MangaState.PAUSED + else -> null + } + + val genres = obj.optJSONArray("genres")?.let { genresArray -> + (0 until genresArray.length()).mapNotNull { i -> + val genreObj = genresArray.optJSONObject(i) ?: return@mapNotNull null + val slug = genreObj.optString("slug") + val name = genreObj.optString("name") + if (slug.isEmpty() || name.isEmpty()) return@mapNotNull null + MangaTag(title = name, key = slug, source = source) + }.toSet() + } ?: emptySet() + + val chapters = parseUnitsJson(obj.optJSONArray("units"), relativeUrl, slug) + + return manga.copy( + title = obj.optString("title").takeIf { it.isNotEmpty() } ?: manga.title, + description = description, + rating = rating, + state = state, + authors = setOfNotNull(obj.optString("author_name").takeIf { it.isNotEmpty() }), + tags = genres, + chapters = chapters, + ) + } catch (e: Exception) { + e.printStackTrace() + return manga + } + } + + private fun parseUnitsJson(array: org.json.JSONArray?, mangaUrl: String, seriesSlug: String): List { + if (array == null) return emptyList() + val result = mutableListOf() + try { + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + val chapterSlug = obj.optString("slug") + if (chapterSlug.isEmpty()) continue + + val chapterUrl = "/comic/$seriesSlug/chapter/$chapterSlug" + val numberStr = obj.optString("number") + val number = numberStr.toFloatOrNull() ?: (result.size + 1).toFloat() + + val chapter = MangaChapter( + id = generateUid(chapterUrl), + url = chapterUrl, + title = obj.optString("title").nullIfEmpty() ?: "Chapter $numberStr", + number = number, + volume = 0, + branch = null, + scanlator = null, + uploadDate = parseChapterDate(obj.optString("created_at")), + source = source, + ) + result.add(chapter) + } + } catch (e: Exception) { + e.printStackTrace() + } + return result.reversed() + } + + override suspend fun getPages(chapter: MangaChapter): List { + val seriesSlug = chapter.url.substringAfter("/comic/").substringBefore("/chapter") + val chapterSlug = chapter.url.substringAfter("/chapter/") + val url = "https://api.ainzscans01.com/api/series/comic/$seriesSlug/chapter/$chapterSlug" + + val json = webClient.httpGet(url).body?.string() + ?: throw Exception("Failed to fetch chapter pages") + return parsePagesJson(json) + } + + private fun parsePagesJson(json: String): List { + try { + val obj = JSONObject(json) + val chapterObj = obj.optJSONObject("chapter") ?: return emptyList() + val pagesArray = chapterObj.optJSONArray("pages") ?: return emptyList() + + return (0 until pagesArray.length()).mapNotNull { i -> + val pageObj = pagesArray.getJSONObject(i) + val imgUrl = pageObj.optString("image_url").nullIfEmpty() ?: return@mapNotNull null + MangaPage( + id = generateUid(imgUrl), + url = imgUrl, + preview = null, + source = source, + ) + } + } catch (e: Exception) { + e.printStackTrace() + return emptyList() + } + } + + private fun parseChapterDate(dateStr: String?): Long { + if (dateStr.isNullOrEmpty()) return 0 + return try { + java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.ENGLISH) + .parse(dateStr).time + } catch (_: Exception) { + 0L + } + } + + private fun String?.nullIfEmpty(): String? = if (this.isNullOrEmpty()) null else this +} From 1ba50df4425f7f42b1af091c870d9effe21c4141 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:05:21 +0200 Subject: [PATCH 039/109] Refactor ManhwaTop class by removing loadChapters Removed the loadChapters function and related imports. --- .../parsers/site/kotatsu/en/ManhwaTop.kt | 46 +------------------ 1 file changed, 1 insertion(+), 45 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaTop.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaTop.kt index c5612a97..b264ee08 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaTop.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ManhwaTop.kt @@ -1,54 +1,10 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.en -import org.jsoup.nodes.Document import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser -import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.parsers.MadaraParser -import org.koitharu.kotatsu.parsers.util.* -import java.text.SimpleDateFormat @MangaSourceParser("MANHWATOP", "ManhwaTop", "en") internal class ManhwaTop(context: MangaLoaderContext) : - MadaraParser(context, MangaParserSource.MANHWATOP, "manhwatop.com") { - - override val postReq = true - - override suspend fun loadChapters(mangaUrl: String, document: Document): List { - - val mangaId = document.select("div#manga-chapters-holder").attr("data-id") - val url = "https://$domain/wp-admin/admin-ajax.php" - val postData = "action=manga_get_chapters&manga=$mangaId" - val doc = webClient.httpPost(url, postData).parseHtml() - - val dateFormat = SimpleDateFormat(datePattern, sourceLocale) - - return doc.select(selectChapter).mapChapters(reversed = true) { i, li -> - val a = li.selectFirst("a") - val href = a?.attrAsRelativeUrlOrNull("href") ?: li.parseFailed("Link is missing") - val link = href + stylePage - val dateText = li.selectFirst("a.c-new-tag")?.attr("title") ?: li.selectFirst(selectDate)?.text() - val name = a.selectFirst("p")?.text() ?: a.ownText() - val dateText2 = if (dateText != "Complete") { - dateText - } else { - null - } - MangaChapter( - id = generateUid(href), - url = link, - title = name, - number = i + 1f, - volume = 0, - branch = null, - uploadDate = parseChapterDate( - dateFormat, - dateText2, - ), - scanlator = null, - source = source, - ) - } - } -} \ No newline at end of file + MadaraParser(context, MangaParserSource.MANHWATOP, "manhwatop.com") From 6244787032b6c53f766bff201b230f6af82f2ce1 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:40:47 +0200 Subject: [PATCH 040/109] Create DoujinDesuParser.kt --- .../parsers/parsers/DoujinDesuParser.kt | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt new file mode 100644 index 00000000..dba7db54 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt @@ -0,0 +1,361 @@ +package org.koitharu.kotatsu.parsers.parsers + +import okhttp3.Headers +import org.json.JSONArray +import org.json.JSONObject +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.time.Instant +import java.util.* + +internal abstract class DoujinDesuParser( + context: MangaLoaderContext, + source: MangaParserSource +) : PagedMangaParser(context, source, pageSize = 18) { + + protected abstract val defaultTypes: String + protected abstract val availableContentTypes: Set + + override val configKeyDomain: ConfigKey.Domain + get() = ConfigKey.Domain("doujin.desu.xxx") + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + @get:Synchronized + private val genresCache = object : LinkedHashMap>(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>?): Boolean = size > 3 + } + + @get:Synchronized + private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 20 + } + + override val defaultSortOrder: SortOrder + get() = SortOrder.UPDATED + + override val availableSortOrders: Set + get() = EnumSet.of(SortOrder.UPDATED, SortOrder.NEWEST, SortOrder.ALPHABETICAL, SortOrder.POPULARITY) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isMultipleTagsSupported = true, + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isAuthorSearchSupported = false, + ) + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = getOrFetchGenres(), + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), + availableContentTypes = availableContentTypes, + ) + + private suspend fun getOrFetchGenres(): Set { + val cacheKey = "all" // single key since genre list rarely changes + genresCache[cacheKey]?.let { return it } + val tags = fetchAvailableTags() + genresCache[cacheKey] = tags + return tags + } + + private suspend fun fetchAvailableTags(): Set { + val url = "/api/terms?taxonomy=genre".toAbsoluteUrl(domain) + val jsonResponse = webClient.httpGet(url, extraHeaders = getRequestHeaders()).parseJson() + val encHex = jsonResponse.getString("_enc_resp_") + val decrypted = decrypt(encHex) + val array = JSONArray(decrypted) + val tags = mutableSetOf() + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + val name = obj.getString("name") + val slug = obj.getString("slug") + tags.add(MangaTag(key = slug, title = name, source = source)) + } + return tags + } + + override fun getRequestHeaders(): Headers = Headers.Builder() + .add("X-Requested-With", "XMLHttpRequest") + .add("Referer", "https://$domain/") + .add("X-App-Secret", "dfdf72051dbfdc7d76889ebd31324e74") + .build() + + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val limit = pageSize + val offset = (page - 1) * limit + + val url = urlBuilder().apply { + addPathSegment("api") + addPathSegment("manga") + + addQueryParameter("limit", limit.toString()) + addQueryParameter("offset", offset.toString()) + + filter.query?.let { + addQueryParameter("search", it) + } + + val sortParam = when (order) { + SortOrder.POPULARITY -> "rating" + SortOrder.ALPHABETICAL -> "title_asc" + SortOrder.NEWEST -> "newest" + SortOrder.UPDATED -> "latest_chapter" + else -> "latest_chapter" + } + addQueryParameter("sort", sortParam) + + filter.types.oneOrThrowIfMany()?.let { + val typeValue = when (it) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.DOUJINSHI -> "doujinshi" + else -> null + } + if (!typeValue.isNullOrEmpty()) { + addQueryParameter("type", typeValue) + } + } + + filter.states.oneOrThrowIfMany()?.let { + val stateParam = when (it) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + else -> "" + } + if (stateParam.isNotEmpty()) { + addQueryParameter("status", stateParam) + } + } + + if (filter.tags.isNotEmpty()) { + addQueryParameter("genre", filter.tags.joinToString(",") { it.key }) + } + }.build() + + val jsonResponse = webClient.httpGet(url, extraHeaders = getRequestHeaders()).parseJson() + val encHex = jsonResponse.getString("_enc_resp_") + val decrypted = decrypt(encHex) + + val array = JSONArray(decrypted) + + return (0 until array.length()).map { i -> + val obj = array.getJSONObject(i) + val slug = obj.getString("slug") + val href = "/manga/$slug" + Manga( + id = generateUid(href), + title = obj.getString("title"), + altTitles = emptySet(), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + rating = obj.optDouble("rating", 0.0).toFloat() / 10f, + contentRating = ContentRating.ADULT, + coverUrl = obj.optString("cover_url").takeIf { it.isNotEmpty() }?.let { cover -> + if (cover.startsWith("http")) cover else cover.toAbsoluteUrl(domain) + }, + tags = emptySet(), + state = null, + authors = emptySet(), + largeCoverUrl = null, + description = null, + source = source, + ) + } + } + + + override suspend fun getDetails(manga: Manga): Manga { + // Return cached details if available + detailsCache[manga.url]?.let { return it } + + val slug = manga.url.removePrefix("/manga/").removeSuffix("/") + val url = "/api/manga/$slug".toAbsoluteUrl(domain) + + val jsonResponse = webClient.httpGet(url, extraHeaders = getRequestHeaders()).parseJson() + val encHex = jsonResponse.getString("_enc_resp_") + val decrypted = decrypt(encHex) + val obj = JSONObject(decrypted) + + val state = when (obj.optString("status")) { + "completed" -> MangaState.FINISHED + "ongoing" -> MangaState.ONGOING + else -> null + } + + val author = obj.optString("author").takeIf { it.isNotEmpty() && it != "null" } + + val tags = mutableSetOf() + val genresArray = obj.optJSONArray("manga_genres") + if (genresArray != null) { + for (i in 0 until genresArray.length()) { + val genreObj = genresArray.getJSONObject(i).getJSONObject("genres") + tags.add( + MangaTag( + key = genreObj.getString("slug"), + title = genreObj.getString("name"), + source = source + ) + ) + } + } + + val chaptersArray = obj.getJSONArray("chapters") + val chapters = mutableListOf() + for (i in 0 until chaptersArray.length()) { + val chapObj = chaptersArray.getJSONObject(i) + val chId = chapObj.getString("id") + val chNum = chapObj.optDouble("chapter_number", 0.0).toFloat() + val chTitle = "Chapter $chNum" + val chUrl = "/reader/$chId" + val createdAt = chapObj.optString("created_at") + val uploadDate = runCatching { Instant.parse(createdAt).toEpochMilli() }.getOrDefault(0L) + + chapters.add( + MangaChapter( + id = generateUid(chUrl), + title = chTitle, + number = chNum, + volume = 0, + url = chUrl, + scanlator = null, + uploadDate = uploadDate, + branch = null, + source = source + ) + ) + } + + val rawDesc = obj.optString("description").takeIf { it.isNotEmpty() && it != "null" } + val cleanDesc = rawDesc?.let { html -> + org.jsoup.Jsoup.parseBodyFragment(html).text() + .replace(Regex("^Sinopsis:\\s*", RegexOption.IGNORE_CASE), "") + .trim() + } + + val coverUrl = obj.optString("cover_url").takeIf { it.isNotEmpty() }?.let { cover -> + if (cover.startsWith("http")) cover else cover.toAbsoluteUrl(domain) + } + + val fullManga = manga.copy( + authors = setOfNotNull(author), + description = cleanDesc, + state = state, + rating = obj.optDouble("rating", 0.0).toFloat() / 10f, + tags = tags, + coverUrl = coverUrl ?: manga.coverUrl, + chapters = chapters.reversed() + ) + + detailsCache[manga.url] = fullManga + return fullManga + } + + + override suspend fun getPages(chapter: MangaChapter): List { + val chId = chapter.url.removePrefix("/reader/").removeSuffix("/") + val url = "/api/chapters/$chId".toAbsoluteUrl(domain) + + val jsonResponse = webClient.httpGet(url, extraHeaders = getRequestHeaders()).parseJson() + val encHex = jsonResponse.getString("_enc_resp_") + val decrypted = decrypt(encHex) + val obj = JSONObject(decrypted) + + val pagesList = mutableListOf() + val contentUrls = obj.optJSONArray("content_urls") + if (contentUrls != null && contentUrls.length() > 0) { + for (i in 0 until contentUrls.length()) { + val rawUrl = contentUrls.getString(i) + pagesList.add( + MangaPage( + id = generateUid(rawUrl), + url = transformPageUrl(rawUrl), + preview = null, + source = source + ) + ) + } + } else { + val signedUrlsStr = obj.optString("signed_content_urls") + if (signedUrlsStr.isNotEmpty()) { + val signedUrls = JSONArray(signedUrlsStr) + for (i in 0 until signedUrls.length()) { + val rawUrl = signedUrls.getString(i) + pagesList.add( + MangaPage( + id = generateUid(rawUrl), + url = transformPageUrl(rawUrl), + preview = null, + source = source + ) + ) + } + } + } + + return pagesList + } + + + private fun generateKey(step: Long): String { + val input = "doujindesu-scrapers-cannot-read-this-super-secret-salt-2026-v2_$step" + var n = 0 + for (i in 0 until input.length) { + n = (n shl 5) - n + input[i].code + } + var seed = if (n == 0) 123456789L else kotlin.math.abs(n.toLong()) + val keyBuilder = StringBuilder() + for (i in 0 until 32) { + seed = (seed * 1664525L + 1013904223L) and 0xFFFFFFFFL + val charCode = 33 + (seed % 93).toInt() + keyBuilder.append(charCode.toChar()) + } + return keyBuilder.toString() + } + + private fun decrypt(encHex: String): String { + val timeStep = 3600000L + val now = System.currentTimeMillis() + val currentStep = now / timeStep + + var lastError: Exception? = null + for (offset in intArrayOf(0, -1, 1)) { + val step = currentStep + offset + val key = generateKey(step) + try { + val encBytes = ByteArray(encHex.length / 2) { i -> + encHex.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + val decBytes = ByteArray(encBytes.size) + var c = 42 + for (i in encBytes.indices) { + val p = encBytes[i].toInt() and 0xFF + val f = key[i % key.length].code + val k = p xor f xor (i * 13) xor c + decBytes[i] = (k and 0xFF).toByte() + c = (c + p) % 256 + } + val decoded = String(decBytes, Charsets.UTF_8) + return java.net.URLDecoder.decode(decoded, "UTF-8") + } catch (e: Exception) { + lastError = e + } + } + throw lastError ?: RuntimeException("Decryption failed") + } + + private fun transformPageUrl(page: String): String = when { + page.contains("/uploads/") && !page.contains("/storage/uploads/") -> + page.replace("/uploads/", "/storage/uploads/") + page.contains("/upload/") && !page.contains("/storage/upload/") -> + page.replace("/upload/", "/storage/upload/") + else -> page + } +} From d166cf70c692f1785c0fbfeb7f08258c178e0d0e Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:41:11 +0200 Subject: [PATCH 041/109] Create DoujinDesu.kt --- .../parsers/site/kotatsu/id/DoujinDesu.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/DoujinDesu.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/DoujinDesu.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/DoujinDesu.kt new file mode 100644 index 00000000..e91d4099 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/DoujinDesu.kt @@ -0,0 +1,20 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.id + +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.parsers.DoujinDesuParser +import java.util.EnumSet + +@MangaSourceParser("DOUJINDESU", "DoujinDesu", "id") +internal class DoujinDesu(context: MangaLoaderContext) : + DoujinDesuParser(context, MangaParserSource.DOUJINDESU) { + + override val defaultTypes: String = "doujinshi,manga,manhwa" + + override val availableContentTypes: Set = EnumSet.of( + ContentType.MANGA, + ContentType.DOUJINSHI, + ContentType.MANHWA + ) +} From 63dc8a2e05de9f3f95101ed6f35a09c14c38c2ae Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:44:15 +0200 Subject: [PATCH 042/109] Update and rename src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/DoujinDesu.kt to src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/hentais/DoujinDesu.kt --- .../parsers/site/kotatsu/id/{ => hentais}/DoujinDesu.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) rename src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/{ => hentais}/DoujinDesu.kt (68%) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/DoujinDesu.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/hentais/DoujinDesu.kt similarity index 68% rename from src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/DoujinDesu.kt rename to src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/hentais/DoujinDesu.kt index e91d4099..b6c01ea9 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/DoujinDesu.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/hentais/DoujinDesu.kt @@ -1,12 +1,13 @@ -package org.koitharu.kotatsu.parsers.site.kotatsu.id +package org.koitharu.kotatsu.parsers.site.kotatsu.id.hentais import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.parsers.DoujinDesuParser import java.util.EnumSet -@MangaSourceParser("DOUJINDESU", "DoujinDesu", "id") +@MangaSourceParser("DOUJINDESU", "DoujinDesu", "id", ContentType.HENTAI) internal class DoujinDesu(context: MangaLoaderContext) : DoujinDesuParser(context, MangaParserSource.DOUJINDESU) { From 9aa93a21f9e6db9e978b656f14d29cddab5276b8 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:47:49 +0200 Subject: [PATCH 043/109] Create MangaReaderParser.kt --- .../parsers/parsers/MangaReaderParser.kt | 417 ++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaReaderParser.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaReaderParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaReaderParser.kt new file mode 100644 index 00000000..438354da --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaReaderParser.kt @@ -0,0 +1,417 @@ +package org.koitharu.kotatsu.parsers.parsers + +import androidx.collection.ArrayMap +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.* +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.internal.closeQuietly +import org.json.JSONObject +import org.jsoup.nodes.Document +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.Base64 +import java.util.EnumSet + +internal abstract class MangaReaderParser( + context: MangaLoaderContext, + source: MangaParserSource, + domain: String, + pageSize: Int, + searchPageSize: Int, +) : PagedMangaParser(context, source, pageSize, searchPageSize), Interceptor { + + override val configKeyDomain = ConfigKey.Domain(domain) + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override val availableSortOrders: Set + get() = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.ALPHABETICAL, + SortOrder.ALPHABETICAL_DESC, + SortOrder.NEWEST, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + isSearchSupported = true, + ) + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = getOrCreateTagMap().values.toSet(), + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ContentType.COMICS, + ContentType.NOVEL, + ), + ) + + protected open val listUrl = "/manga" + protected open val datePattern = "MMMM d, yyyy" + protected open val isNetShieldProtected = false + + protected var tagCache: ArrayMap? = null + protected val mutex = Mutex() + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query + val url = buildString { + append("https://") + append(domain) + + when { + + !query.isNullOrEmpty() -> { + append("/page/") + append(page.toString()) + append("/?s=") + append(query.urlEncoded()) + } + + else -> { + append(listUrl) + + append("/?order=") + append( + when (order) { + SortOrder.ALPHABETICAL -> "title" + SortOrder.ALPHABETICAL_DESC -> "titlereverse" + SortOrder.NEWEST -> "latest" + SortOrder.POPULARITY -> "popular" + SortOrder.UPDATED -> "update" + else -> "" + }, + ) + + filter.tags.forEach { + append("&") + append("genre[]".urlEncoded()) + append("=") + append(it.key) + } + + filter.tagsExclude.forEach { + append("&") + append("genre[]".urlEncoded()) + append("=-") + append(it.key) + } + + if (filter.states.isNotEmpty()) { + filter.states.oneOrThrowIfMany()?.let { + append("&status=") + when (it) { + MangaState.ONGOING -> append("ongoing") + MangaState.FINISHED -> append("completed") + MangaState.PAUSED -> append("hiatus") + else -> append("") + } + } + } + + filter.types.oneOrThrowIfMany()?.let { + append("&type=") + append( + when (it) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.MANHUA -> "manhua" + ContentType.COMICS -> "comic" + ContentType.NOVEL -> "novel" + else -> "" + }, + ) + } + + append("&page=") + append(page.toString()) + } + } + } + return parseMangaList(webClient.httpGet(url).parseHtml()) + } + + protected open val selectMangaList = ".postbody .listupd .bs .bsx" + protected open val selectMangaListImg = "img.ts-post-image" + protected open val selectMangaListTitle = "div.tt" + + protected open fun parseMangaList(docs: Document): List { + return docs.select(selectMangaList).mapNotNull { + val a = it.selectFirst("a") ?: return@mapNotNull null + val relativeUrl = a.attrAsRelativeUrl("href") + val rating = it.selectFirst(".numscore")?.text() + ?.toFloatOrNull()?.div(10) ?: RATING_UNKNOWN + Manga( + id = generateUid(relativeUrl), + url = relativeUrl, + title = it.selectFirst(selectMangaListTitle)?.text() ?: a.attr("title"), + altTitles = emptySet(), + publicUrl = a.attrAsAbsoluteUrl("href"), + rating = rating, + contentRating = if (isNsfwSource) ContentRating.ADULT else null, + coverUrl = it.selectFirst(selectMangaListImg)?.src(), + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + ) + } + } + + protected open val selectChapter = "#chapterlist > ul > li" + override suspend fun getDetails(manga: Manga): Manga { + val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + val dateFormat = SimpleDateFormat(datePattern, sourceLocale) + val chapters = docs.select(selectChapter).mapChapters(reversed = true) { index, element -> + val url = element.selectFirst("a")?.attrAsRelativeUrlOrNull("href") ?: return@mapChapters null + MangaChapter( + id = generateUid(url), + title = element.selectFirst(".chapternum")?.textOrNull(), + url = url, + number = index + 1f, + volume = 0, + scanlator = null, + uploadDate = dateFormat.parseSafe(element.selectFirst(".chapterdate")?.text()), + branch = null, + source = source, + ) + } + return parseInfo(docs, manga, chapters) + } + + protected open val detailsDescriptionSelector = "div.entry-content" + + open suspend fun parseInfo(docs: Document, manga: Manga, chapters: List): Manga { + /// set if is table + val tableMode = + docs.selectFirst("div.seriestucontent > div.seriestucontentr") ?: docs.selectFirst("div.seriestucontentr") + ?: docs.selectFirst("div.seriestucon") + + val tagMap = getOrCreateTagMap() + + val selectTag = if (tableMode != null) { + docs.select(".seriestugenre > a") + } else { + docs.select(".wd-full .mgen > a") + } + + val tags = selectTag.mapNotNullToSet { tagMap[it.text()] } + + val stateSelect = if (tableMode != null) { + tableMode.selectFirst(".infotable td:contains(Status)") + ?: tableMode.selectFirst(".infotable td:contains(Statut)") + ?: tableMode.selectFirst(".infotable td:contains(حالة العمل)") + ?: tableMode.selectFirst(".infotable td:contains(الحالة)") + ?: tableMode.selectFirst(".infotable td:contains(Estado)") + ?: tableMode.selectFirst(".infotable td:contains(สถานะ)") + ?: tableMode.selectFirst(".infotable td:contains(Stato )") + ?: tableMode.selectFirst(".infotable td:contains(Durum)") + ?: tableMode.selectFirst(".infotable td:contains(Statüsü)") + + } else { + docs.selectFirst(".tsinfo div:contains(Status)") ?: docs.selectFirst(".tsinfo div:contains(Statut)") + ?: docs.selectFirst(".tsinfo div:contains(حالة العمل)") ?: docs.selectFirst(".tsinfo div:contains(Estado)") + ?: docs.selectFirst(".tsinfo div:contains(สถานะ)") ?: docs.selectFirst(".tsinfo div:contains(Stato )") + ?: docs.selectFirst(".tsinfo div:contains(Durum)") ?: docs.selectFirst(".tsinfo div:contains(Statüsü)") + ?: docs.selectFirst(".tsinfo div:contains(Statü)") ?: docs.selectFirst(".tsinfo div:contains(الحالة)") + } + + val state = if (tableMode != null) { + stateSelect?.lastElementSibling() + + } else { + stateSelect?.lastElementChild() + } + + val mangaState = state?.let { + when (it.text()) { + + "مستمرة", "En curso", "En Curso", "Ongoing", "OnGoing", "On going", "Ativo", "En Cours", "En cours", "En cours \uD83D\uDFE2", + "En cours de publication", "Đang tiến hành", "Em lançamento", "em lançamento", "Em Lançamento", "Онгоінг", "Publishing", + "Devam Ediyor", "Em Andamento", "In Corso", "Güncel", "Berjalan", "Продолжается", "Updating", "Lançando", "In Arrivo", "Emision", + "En emision", "مستمر", "Curso", "En marcha", "Publicandose", "Publicando", "连载中", "Devam ediyor", "Devam Etmekte", + -> MangaState.ONGOING + + "Completed", "Completo", "Complété", "Fini", "Achevé", "Terminé", "Terminé ⚫", "Tamamlandı", "Đã hoàn thành", "Hoàn Thành", + "مكتملة", "Завершено", "Finished", "Finalizado", "Completata", "One-Shot", "Bitti", "Tamat", "Completado", "Concluído", + "Concluido", "已完结", "Bitmiş", + -> MangaState.FINISHED + + "Canceled", "Cancelled", "Cancelado", "cancellato", "Cancelados", "Dropped", "Discontinued", "abandonné", "Abandonné", + -> MangaState.ABANDONED + + "Hiatus", "On Hold", "Pausado", "En espera", "En pause", "En Pause", "En attente", + -> MangaState.PAUSED + + else -> null + } + } + + val author = tableMode?.selectFirst(".infotable td:contains(Author)")?.lastElementSibling()?.textOrNull() + ?: docs.selectFirst(".tsinfo div:contains(Author)")?.lastElementChild()?.textOrNull() + ?: docs.selectFirst(".tsinfo div:contains(Auteur)")?.lastElementChild()?.textOrNull() + ?: docs.selectFirst(".tsinfo div:contains(Artist)")?.lastElementChild()?.textOrNull() + ?: docs.selectFirst(".tsinfo div:contains(Durum)")?.lastElementChild()?.textOrNull() + + val nsfw = docs.selectFirst(".restrictcontainer") != null + || docs.selectFirst(".info-right .alr") != null + || docs.selectFirst(".postbody .alr") != null + + val title = docs.selectFirst("h1.entry-title")?.text() + ?: docs.selectFirst("h1")?.text() + ?: docs.selectFirst(".entry-title")?.text() + ?: docs.selectFirst(".seriestucontent h1")?.text() + ?: docs.selectFirst(".postbody h1")?.text() + ?: docs.selectFirst("title")?.text()?.substringBefore(" - ")?.trim() + ?: manga.title + + return manga.copy( + title = title, + description = docs.selectFirst(detailsDescriptionSelector)?.text(), + state = mangaState, + authors = setOfNotNull(author), + contentRating = if (manga.contentRating == ContentRating.ADULT || nsfw) { + ContentRating.ADULT + } else { + ContentRating.SAFE + }, + tags = tags, + chapters = chapters, + ) + } + + override suspend fun getRelatedManga(seed: Manga): List { + return parseMangaList(webClient.httpGet(seed.url.toAbsoluteUrl(domain)).parseHtml()) + } + + protected open val encodedSrc = false + protected open val selectScript = "div.wrapper script" + protected open val selectPage = "div#readerarea img" + + protected open val selectTestScript = "script:containsData(ts_reader)" + override suspend fun getPages(chapter: MangaChapter): List { + val chapterUrl = chapter.url.toAbsoluteUrl(domain) + val docs = webClient.httpGet(chapterUrl).parseHtml() + + val test = docs.select(selectTestScript) + if (test.isEmpty() and !encodedSrc) { + return docs.select(selectPage).map { img -> + val url = img.requireSrc().toRelativeUrl(domain) + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ) + } + } else { + val images = if (encodedSrc) { + val script = docs.select(selectScript) + var decode = "" + for (i in script) { + if (i.attr("src").startsWith("data:text/javascript;base64,")) { + decode = Base64.getDecoder().decode(i.attr("src").replace("data:text/javascript;base64,", "")) + .decodeToString() + if (decode.startsWith("ts_reader.run")) { + break + } + } + } + JSONObject(decode.substringAfter('(').substringBeforeLast(')')) + .getJSONArray("sources") + .getJSONObject(0) + .getJSONArray("images") + + } else { + val script = docs.selectFirstOrThrow(selectTestScript) + JSONObject(script.data().substringAfter('(').substringBeforeLast(')')) + .getJSONArray("sources") + .getJSONObject(0) + .getJSONArray("images") + } + + val pages = ArrayList(images.length()) + for (i in 0 until images.length()) { + pages.add( + MangaPage( + id = generateUid(images.getString(i)), + url = images.getString(i), + preview = null, + source = source, + ), + ) + } + return pages + } + } + + protected open suspend fun getOrCreateTagMap(): Map = mutex.withLock { + tagCache?.let { return@withLock it } + val url = listUrl.toAbsoluteUrl(domain) + val tagElements = webClient.httpGet(url).parseHtml().select("ul.genrez > li") + val tagMap = ArrayMap(tagElements.size) + for (el in tagElements) { + tagMap[el.text()] = MangaTag( + title = el.textOrNull()?.toTitleCase(sourceLocale) ?: continue, + key = el.selectFirst("input")?.attrOrNull("value") ?: continue, + source = source, + ) + } + tagCache = tagMap + return@withLock tagMap + } + + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + if (!isNetShieldProtected) { + return response + } + val contentType = response.mimeType + if ( + contentType?.endsWith("/html") != false && + context.cookieJar.getCookies(domain).none { it.name.contains("NetShield") } + ) { + val cookie = runBlocking { response.copy().parseHtml().getNetShieldCookie() } ?: return response + context.cookieJar.insertCookie(domain, cookie) + return chain.proceed(response.request.newBuilder().build()).also { + response.closeQuietly() + } + } + return response + } + + @Suppress("DEPRECATION") + private suspend fun Document.getNetShieldCookie(): Cookie? = runCatchingCancellable { + val script = select("script").firstNotNullOfOrNull { s -> + s.html().takeIf { x -> x.contains("slowAES.decrypt") } + } ?: return@runCatchingCancellable null + val min = webClient.httpGet("https://$domain/min.js").parseRaw() + val res = context.evaluateJs(min + "\n\n" + script.replace(Regex("document.cookie\\s*=\\s*"), "return ")) + res?.let { + Cookie.parse(baseUri().toHttpUrl(), it) + } + }.getOrNull() + + override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? { + val mangaSlug = link.pathSegments[1].nullIfEmpty() ?: return null + val url = "/manga/$mangaSlug/" + return resolver.resolveManga( + this, url, generateUid(url), + ) + } +} From 5ef5f623b750a56bbb10aecd501b747bdd047f96 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:48:06 +0200 Subject: [PATCH 044/109] Create EnThunderScans.kt --- .../parsers/site/kotatsu/en/EnThunderScans.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/EnThunderScans.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/EnThunderScans.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/EnThunderScans.kt new file mode 100644 index 00000000..03153da3 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/EnThunderScans.kt @@ -0,0 +1,24 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.en + +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.parsers.MangaReaderParser + +@MangaSourceParser("ENTHUNDERSCANS", "EnThunderScans", "en") +internal class EnThunderScans(context: MangaLoaderContext) : + MangaReaderParser( + context, + MangaParserSource.ENTHUNDERSCANS, + "en-thunderscans.com", + pageSize = 30, + searchPageSize = 10, + ) { + override val listUrl = "/comics" + + override val filterCapabilities: MangaListFilterCapabilities + get() = super.filterCapabilities.copy( + isTagsExclusionSupported = false, + ) +} From 8f5138b8a90b7f6f094ed0f2332d800116d1473d Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:21:09 +0200 Subject: [PATCH 045/109] Update README.md --- README.md | 84 +++++++++++++++++++++++++++---------------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 10228b04..34beb84b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Stars][stars-shield]][stars-url] [![Issues][issues-shield]][issues-url] [![License][license-shield]][license-url] - +### Please give the repo a ⭐ # Sources Requests Open up an Issue with these contents: @@ -40,49 +40,10 @@ There are actually two ways, one which you manually update the Plugin from time
---- -
-Manually way -
-📥 How to download .jar File - -![Screenshot][img3] - -
-
-📷 Image Turoial - -![Screenshot][img5] - -
- -1. Download the latest version of the Plugin from the [Releases](https://github.com/InvalidDavid/UMA/releases) page. -Click on the blue Name or press the three dots on the right side and press Download. - - -2. Go back to the App and open up `Explore` - - -3. Click on `three Dots in the top right corner` and select `Manage Sources` - - -4. Go to `Manage Plugins` and press the `Add` / `+` button in the top right corner. - - -5. Select `Import from local storage` and select `UMA.jar` file which you downloaded in the previous step. - -And that's it! Select your new sources in the catalog and have fun reading. -
- -
- ---- - -
-Automatically +Automatically (recommended)
@@ -133,10 +94,49 @@ And that's it! Select your new sources in the catalog and have fun reading.
- + --- +
+Manually way + +
+
+📥 How to download .jar File + +![Screenshot][img3] + +
+
+📷 Image Turoial + +![Screenshot][img5] + +
+ +1. Download the latest version of the Plugin from the [Releases](https://github.com/InvalidDavid/UMA/releases) page. +Click on the blue Name or press the three dots on the right side and press Download. + + +2. Go back to the App and open up `Explore` + + +3. Click on `three Dots in the top right corner` and select `Manage Sources` + + +4. Go to `Manage Plugins` and press the `Add` / `+` button in the top right corner. + + +5. Select `Import from local storage` and select `UMA.jar` file which you downloaded in the previous step. + +And that's it! Select your new sources in the catalog and have fun reading. +
+ + +
+ + # Setup
From 168f7bfce302a716025ca2c6120b7733545326fb Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:41:44 +0200 Subject: [PATCH 046/109] Update DoujinDesuParser.kt --- .../kotatsu/parsers/parsers/DoujinDesuParser.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt index dba7db54..c003cc6f 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt @@ -27,14 +27,12 @@ internal abstract class DoujinDesuParser( keys.add(userAgentKey) } - @get:Synchronized - private val genresCache = object : LinkedHashMap>(16, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry>?): Boolean = size > 3 - } + @Volatile + private var genresCache: Set? = null @get:Synchronized private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 20 + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 5 } override val defaultSortOrder: SortOrder @@ -58,10 +56,9 @@ internal abstract class DoujinDesuParser( ) private suspend fun getOrFetchGenres(): Set { - val cacheKey = "all" // single key since genre list rarely changes - genresCache[cacheKey]?.let { return it } + genresCache?.let { return it } val tags = fetchAvailableTags() - genresCache[cacheKey] = tags + genresCache = tags return tags } @@ -307,7 +304,7 @@ internal abstract class DoujinDesuParser( private fun generateKey(step: Long): String { val input = "doujindesu-scrapers-cannot-read-this-super-secret-salt-2026-v2_$step" var n = 0 - for (i in 0 until input.length) { + for (i in input.indices) { n = (n shl 5) - n + input[i].code } var seed = if (n == 0) 123456789L else kotlin.math.abs(n.toLong()) From 960fa8fed19686d733628510c359ba76f70643ad Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:43:15 +0200 Subject: [PATCH 047/109] Update Atsumaru.kt --- .../org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt index 2f1d291a..40cb76b6 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Atsumaru.kt @@ -374,7 +374,7 @@ internal class Atsumaru(context: MangaLoaderContext) : @get:Synchronized private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { - return size > 20 + return size > 5 } } From 0555f6196ed89977c6399bf6edb5215b699d4c9a Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:43:42 +0200 Subject: [PATCH 048/109] Update MangaFire.kt --- .../org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt index 7217044b..20880ffa 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/MangaFire.kt @@ -341,7 +341,7 @@ internal abstract class MangaFireParser( @get:Synchronized private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { - return size > 20 + return size > 5 } } From c8e2ae84bfa05413beec7b243b2d660d99951df2 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:53:09 +0200 Subject: [PATCH 049/109] Update MangaKatana.kt --- .../parsers/site/kotatsu/en/MangaKatana.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaKatana.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaKatana.kt index 9293c4c5..a4b9072b 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaKatana.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaKatana.kt @@ -8,7 +8,8 @@ import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.* import java.text.SimpleDateFormat -import java.util.* +import java.util.EnumSet +import java.util.Locale private const val two = "?sv=mk" private const val three = "?sv=3" @@ -107,15 +108,18 @@ internal class MangaKatana(context: MangaLoaderContext): order: SortOrder, filter: MangaListFilter, ): String { - val query = filter.query?.trim().orEmpty() val tags = filter.tags.map { it.key }.filter { it.isNotBlank() } - val status = filter.states.firstOrNull() return when { - - query.isNotEmpty() -> "$baseUrl/page/$page?search=${query.urlEncoded()}&search_by=m_name" + query.isNotEmpty() -> { + if (page == 1) { + "$baseUrl/?search=${query.urlEncoded()}&search_by=m_name" + } else { + "$baseUrl/page/$page?search=${query.urlEncoded()}&search_by=m_name" + } + } status != null -> { "$baseUrl/genres/?" + @@ -387,6 +391,6 @@ internal class MangaKatana(context: MangaLoaderContext): } .toList() - return if (pages.isEmpty()) null else pages + return pages.ifEmpty { null } } } From e7c86792e10a9acfafd286f5a1681a106bb65a3d Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:59:14 +0200 Subject: [PATCH 050/109] Update OlympusScan.kt --- .../parsers/site/tachiyomi/es/olympusscan/OlympusScan.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/es/olympusscan/OlympusScan.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/es/olympusscan/OlympusScan.kt index 4ee42def..823932cd 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/es/olympusscan/OlympusScan.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/tachiyomi/es/olympusscan/OlympusScan.kt @@ -191,9 +191,9 @@ class OlympusScan : return MangasPage(paginatedList.map { it.toSManga() }, hasNextPage) } - override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw UnsupportedOperationException() + override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw Exception("La búsqueda no es compatible.") - override fun searchMangaParse(response: Response) = throw UnsupportedOperationException() + override fun searchMangaParse(response: Response) = throw Exception("La búsqueda no es compatible.") override fun getMangaUrl(manga: SManga): String { val slug = preferences.slugMap[manga.url.toInt()]!! @@ -258,7 +258,7 @@ class OlympusScan : resultSize += newData.data.size page += 1 } - return data.data.map { it.toSChapter(mangaId, dateFormat) } + return data.data.map { it.toSChapter(mangaId, dateFormat) }.sortedBy { it.chapter_number } } override fun pageListRequest(chapter: SChapter): Request { @@ -335,4 +335,4 @@ class OlympusScan : private const val CACHE_DURATION_MS = 60 * 60 * 1000L // 1 hour } -} \ No newline at end of file +} From f7f1f0e7a8296e508514fc5f89df8a9d9664fda9 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:00:02 +0200 Subject: [PATCH 051/109] Delete src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/vi/hentai/MeHentai.kt --- .../site/kotatsu/vi/hentai/MeHentai.kt | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/vi/hentai/MeHentai.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/vi/hentai/MeHentai.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/vi/hentai/MeHentai.kt deleted file mode 100644 index 6733042c..00000000 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/vi/hentai/MeHentai.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.koitharu.kotatsu.parsers.site.kotatsu.vi.hentai - -import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.MangaSourceParser -import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.parsers.ManhwaZ - -// idk how to fix image cover url -// they are using /cache-image/404 -// cant rly scrape them over html - -@MangaSourceParser("MEHENTAI", "MeHentai", "vi", ContentType.HENTAI) -internal class MeHentai(context: MangaLoaderContext) : - ManhwaZ(context, MangaParserSource.MEHENTAI, "mehentai.blog") { - - override val searchPath = "tim-kiem" - override val tagPath = "the-loai" -} From 6622f8173b9c6926037ee466346dc27bcea376b8 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:00:09 +0200 Subject: [PATCH 052/109] Delete src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/vi/hentai/SayHentai.kt --- .../site/kotatsu/vi/hentai/SayHentai.kt | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/vi/hentai/SayHentai.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/vi/hentai/SayHentai.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/vi/hentai/SayHentai.kt deleted file mode 100644 index d6472a90..00000000 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/vi/hentai/SayHentai.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.koitharu.kotatsu.parsers.site.kotatsu.vi.hentai - -import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.MangaSourceParser -import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities -import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions -import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.parsers.ManhwaZ - -@MangaSourceParser("SAYHENTAI", "SayHentai", "vi", ContentType.HENTAI) -internal class SayHentai(context: MangaLoaderContext) : - ManhwaZ(context, MangaParserSource.SAYHENTAI, "sayhentai.cx") { - - override val searchPath = "search" - - override val tagPath = "genre" - - override val availableSortOrders: Set = emptySet() - - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isSearchSupported = true, - isMultipleTagsSupported = false, - ) - - override suspend fun getFilterOptions(): MangaListFilterOptions { - return MangaListFilterOptions( - availableTags = setOf( - MangaTag( - key = "manhwa", - title = "Manhwa", - source = source, - ), - MangaTag( - key = "manga", - title = "Manga", - source = source, - ), - ), - ) - } -} \ No newline at end of file From 0ec56eb9eb77df4d5f0384040474de7453aee30c Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:21:00 +0200 Subject: [PATCH 053/109] Create MangaTown.kt --- .../parsers/site/kotatsu/en/MangaTown.kt | 375 ++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaTown.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaTown.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaTown.kt new file mode 100644 index 00000000..2ecac3ad --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaTown.kt @@ -0,0 +1,375 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.en + +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.* + +@MangaSourceParser("MANGATOWN", "MangaTown", "en") +internal class MangaTown(context: MangaLoaderContext) : + PagedMangaParser(context, MangaParserSource.MANGATOWN, 30) { + + override val configKeyDomain = ConfigKey.Domain("www.mangatown.com") + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override fun getRequestHeaders(): Headers = Headers.Builder() + .add("Referer", "https://$domain/") + .build() + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.ALPHABETICAL, + SortOrder.RATING, + SortOrder.POPULARITY, + SortOrder.UPDATED, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + isYearSupported = false, + ) + + + @Volatile + private var allTagsCache: List? = null + + private suspend fun getAllTags(): List { + if (allTagsCache == null) { + allTagsCache = fetchAvailableTags().toList() + } + return allTagsCache!! + } + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = getAllTags().toSet(), + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, + ), + availableDemographics = EnumSet.of( + Demographic.SHOUJO, + Demographic.SEINEN, + Demographic.SHOUNEN, + Demographic.JOSEI, + ), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHUA, + ContentType.MANHWA, + ), + ) + + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query?.trim() + val hasFilters = filter.types.isNotEmpty() || filter.demographics.isNotEmpty() || + filter.tags.isNotEmpty() || filter.tagsExclude.isNotEmpty() || + filter.states.isNotEmpty() + + val url = when { + hasFilters -> { + val builder = "https://$domain/search".toHttpUrl().newBuilder() + .addQueryParameter("name_method", "cw") + .addQueryParameter("name", query ?: "") + .addQueryParameter("author_method", "cw") + .addQueryParameter("author", "") + .addQueryParameter("artist_method", "cw") + .addQueryParameter("artist", "") + + builder.addQueryParameter("type", + if (filter.types.size == 1) when (filter.types.first()) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.MANHUA -> "manhua" + else -> "" + } else "" + ) + + builder.addQueryParameter("demographic", + if (filter.demographics.size == 1) when (filter.demographics.first()) { + Demographic.SEINEN -> "SEINEN" + Demographic.SHOUJO -> "SHOUJO" + Demographic.SHOUNEN -> "SHOUNEN" + Demographic.JOSEI -> "JOSEI" + else -> "" + } else "" + ) + + if (filter.states.size == 1) { + when (filter.states.first()) { + MangaState.FINISHED -> builder.addQueryParameter("is_completed", "1") + MangaState.ONGOING -> builder.addQueryParameter("is_completed", "0") + else -> builder.addQueryParameter("is_completed", "") + } + } else { + builder.addQueryParameter("is_completed", "") + } + + val allTags = getAllTags() + val includedKeys = filter.tags.map { it.key }.toSet() + val excludedKeys = filter.tagsExclude.map { it.key }.toSet() + for (tag in allTags) { + val value = when (tag.key) { + in includedKeys -> "1" + in excludedKeys -> "2" + else -> "0" + } + builder.addQueryParameter("genres[${tag.title}]", value) + } + + builder.addQueryParameter("released_method", "eq") + builder.addQueryParameter("released", "") + builder.addQueryParameter("rating_method", "eq") + builder.addQueryParameter("rating", "") + builder.addQueryParameter("advopts", "1") + builder.addQueryParameter("page", page.toString()) + + builder.build().toString() + } + !query.isNullOrEmpty() -> { + // Simple search + "https://$domain/search?page=$page&name=${query.urlEncoded()}" + } + order == SortOrder.UPDATED -> { + "https://$domain/latest/$page.htm" + } + else -> { + // Directory browse + buildString { + append("https://$domain/directory/") + append("0-") + append( + if (filter.tags.isNotEmpty()) filter.tags.oneOrThrowIfMany()?.key ?: "0" + else "0" + ) + append("-0-") + append( + if (filter.states.isNotEmpty()) { + filter.states.oneOrThrowIfMany()?.let { + when (it) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + else -> "0" + } + } ?: "0" + } else "0" + ) + append("-0-0/") + append(page).append(".htm") + append( + when (order) { + SortOrder.POPULARITY -> "" + SortOrder.ALPHABETICAL -> "?name.az" + SortOrder.RATING -> "?rating.za" + else -> "?last_chapter_time.za" + } + ) + } + } + } + + val doc = webClient.httpGet(url, getRequestHeaders()).parseHtml() + val root = doc.body().selectFirst("ul.manga_pic_list") ?: return emptyList() + val manga = root.select("li") + if (manga.isEmpty()) return emptyList() + + return manga.mapNotNull { li -> + val a = li.selectFirst("a.manga_cover") ?: return@mapNotNull null + val href = a.attrAsRelativeUrl("href") + val views = li.select("p.view") + val status = views.firstNotNullOfOrNull { it.ownText().takeIf { x -> x.startsWith("Status:") } } + ?.substringAfter(':')?.trim()?.lowercase(Locale.ROOT) + val author = views.firstNotNullOfOrNull { it.text().takeIf { x -> x.startsWith("Author:") } } + ?.substringAfter(':') + ?.trim() + Manga( + id = generateUid(href), + title = a.attr("title"), + coverUrl = a.selectFirst("img")?.attrAsAbsoluteUrlOrNull("src"), + source = source, + altTitles = emptySet(), + rating = li.selectFirst("p.score")?.selectFirst("b") + ?.ownText()?.toFloatOrNull()?.div(5f) ?: RATING_UNKNOWN, + authors = setOfNotNull(author), + state = when (status) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + else -> null + }, + tags = li.selectFirst("p.keyWord")?.select("a")?.mapToSet tags@{ x -> + MangaTag( + title = x.attr("title").toTitleCase(), + key = x.attr("href").substringAfter("/directory/0-").substringBefore("-0-"), + source = source, + ) + }.orEmpty(), + url = href, + contentRating = null, + publicUrl = href.toAbsoluteUrl(a.host ?: domain), + ) + } + } + + + override suspend fun getDetails(manga: Manga): Manga { + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain), getRequestHeaders()).parseHtml() + val root = doc.body().selectFirst("section.main") + ?.selectFirst("div.article_content") ?: return manga + val info = root.selectFirst("div.detail_info")?.selectFirst("ul") + val chaptersList = root.selectFirst("div.chapter_content") + ?.selectFirst("ul.chapter_list")?.select("li")?.asReversed() + val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US) + + if (root.select("div.chapter_content:contains(has been licensed)").isNotEmpty()) { + return manga.copy(state = MangaState.ABANDONED, chapters = bypassLicensedChapters(manga)) + } + + return manga.copy( + tags = manga.tags + (info?.select("li")?.find { x -> + x.selectFirst("b")?.ownText() == "Genre(s):" + }?.select("a")?.mapNotNull { a -> + MangaTag( + title = a.attr("title").toTitleCase(), + key = a.attr("href").substringAfter("/directory/0-").substringBefore("-0-"), + source = source, + ) + }.orEmpty()), + description = info?.getElementById("show")?.ownText(), + chapters = chaptersList?.mapChapters { i, li -> + val linkEl = li.selectFirst("a") ?: return@mapChapters null + val href = linkEl.attrAsRelativeUrl("href") + val name = buildString { + append(linkEl.text()) + li.select("span").filter { span -> + !span.hasClass("time") && !span.hasClass("new") + }.forEach { span -> + if (isNotEmpty()) append(" ") + append(span.text()) + } + } + MangaChapter( + id = generateUid(href), + url = href, + source = source, + number = i + 1f, + volume = 0, + uploadDate = parseChapterDate( + dateFormat, + li.selectFirst("span.time")?.text(), + ), + title = name.nullIfEmpty(), + scanlator = null, + branch = null, + ) + } ?: emptyList(), + ) + } + + + private suspend fun bypassLicensedChapters(manga: Manga): List { + val subdomain = "m." + domain.removePrefix("www.") + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(subdomain), getRequestHeaders()).parseHtml() + val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList() + val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US) + return list.select("li").asReversed().mapIndexedNotNull { i, li -> + val a = li.selectFirst("a") ?: return@mapIndexedNotNull null + val href = a.attrAsRelativeUrl("href") + val name = a.selectFirst("span.vol")?.text().orEmpty().ifEmpty { a.ownText() } + MangaChapter( + id = generateUid(href), + url = href, + source = source, + number = i + 1f, + volume = 0, + uploadDate = parseChapterDate( + dateFormat, + li.selectFirst("span.time")?.text(), + ), + title = name.nullIfEmpty(), + scanlator = null, + branch = null, + ) + } + } + + + private fun parseChapterDate(dateFormat: SimpleDateFormat, date: String?): Long { + return when { + date.isNullOrEmpty() -> 0L + date.contains("Today") -> Calendar.getInstance().timeInMillis + date.contains("Yesterday") -> Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) }.timeInMillis + else -> dateFormat.parseSafe(date) + } + } + + + private suspend fun fetchAvailableTags(): Set { + val doc = webClient.httpGet("/directory/".toAbsoluteUrl(domain), getRequestHeaders()).parseHtml() + val root = doc.body().selectFirst("aside.right") + ?.getElementsContainingOwnText("Genres") + ?.first() + ?.nextElementSibling() ?: doc.parseFailed("Root not found") + return root.select("li").mapNotNullToSet { li -> + val a = li.selectFirst("a") ?: return@mapNotNullToSet null + val key = a.attr("href").substringAfter("/directory/0-").substringBefore("-0-") + MangaTag( + source = source, + key = key, + title = a.text().toTitleCase(), + ) + } + } + + + override suspend fun getPages(chapter: MangaChapter): List { + val fullUrl = chapter.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl, getRequestHeaders()).parseHtml() + val pageSelect = doc.body().selectFirst("div.page_select select") ?: return emptyList() + val options = pageSelect.select("option").filterNot { it.attr("value").endsWith("featured.html") } + if (options.isNotEmpty()) { + return options.mapIndexed { _, option -> + val href = option.attrAsRelativeUrl("value") + MangaPage( + id = generateUid(href), + url = href, + preview = null, + source = source, + ) + } + } + val imgElements = doc.select("div#viewer img") + return imgElements.map { img -> + val src = img.attrAsAbsoluteUrl("src") + MangaPage( + id = generateUid(src), + url = src, + preview = null, + source = source, + ) + } + } + + + override suspend fun getPageUrl(page: MangaPage): String { + if (page.url.startsWith("http")) return page.url + val doc = webClient.httpGet(page.url.toAbsoluteUrl(domain), getRequestHeaders()).parseHtml() + return doc.selectFirst("div#viewer img")?.attrAsAbsoluteUrl("src") + ?: throw Exception("Could not find image") + } + + + private fun String.nullIfEmpty(): String? = ifEmpty { null } +} From ffd1e7866bea78ad9e9eec3c3dd3ff332cedc44a Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:19:18 +0200 Subject: [PATCH 054/109] Update Mangaball.kt --- .../parsers/site/kotatsu/all/Mangaball.kt | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Mangaball.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Mangaball.kt index 31abb075..ef37595b 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Mangaball.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/all/Mangaball.kt @@ -134,6 +134,8 @@ internal abstract class MangaBallParser( } override suspend fun getDetails(manga: Manga): Manga { + detailsCache[manga.url]?.let { return it } + ensureAdultCookie() val doc = client.httpGet(getMangaUrl(manga.url)).parseHtml() updateCsrf(doc) @@ -165,7 +167,7 @@ internal abstract class MangaBallParser( else -> manga.contentRating } - return manga.copy( + val result = manga.copy( title = title, altTitles = if (altTitles.isEmpty()) manga.altTitles else altTitles, publicUrl = getMangaUrl(manga.url), @@ -178,6 +180,8 @@ internal abstract class MangaBallParser( chapters = getChapterList(manga.url), contentRating = contentRating, ) + detailsCache[manga.url] = result + return result } override suspend fun getPages(chapter: MangaChapter): List { @@ -543,21 +547,32 @@ internal abstract class MangaBallParser( private fun getCsrf(): String { if (csrfToken.isNullOrEmpty()) { refreshCsrf() + if (csrfToken.isNullOrEmpty()) { + refreshCsrf() + } + if (csrfToken.isNullOrEmpty()) { + error("CSRF token not found after two attempts") + } } - return csrfToken ?: error("CSRF token not found") + return csrfToken!! } @Synchronized private fun refreshCsrf(document: Document? = null) { ensureAdultCookie() - updateCsrf( - document ?: rawHttpClient.newCall( - Request.Builder() - .url(BASE_URL) - .headers(getHeadersWithReferer()) - .build(), - ).execute().parseHtml(), - ) + if (document != null) { + updateCsrf(document) + if (!csrfToken.isNullOrEmpty()) return + } + + val request = Request.Builder() + .url(BASE_URL) + .headers(getHeadersWithReferer()) + .header("Cache-Control", "no-cache") + .build() + val response = rawHttpClient.newCall(request).execute() + updateCsrf(response.parseHtml()) + response.close() } @Synchronized @@ -567,6 +582,11 @@ internal abstract class MangaBallParser( } } + @get:Synchronized + private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 5 + } + private fun MutableList.addEncoded(key: String, value: String) { add("${key.urlEncoded()}=${value.urlEncoded()}") } @@ -889,4 +909,4 @@ internal abstract class MangaBallParser( MangaParserSource.MANGABALL_ZH, setOf("zh", "zh-cn", "zh-hk", "zh-mo", "zh-sg", "zh-tw"), ) -} \ No newline at end of file +} From 876cf31dd501d9ddb75f0af3bda55fb2650780ff Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:40:11 +0200 Subject: [PATCH 055/109] Create HiperParser.kt --- .../kotatsu/parsers/parsers/HiperParser.kt | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/HiperParser.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/HiperParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/HiperParser.kt new file mode 100644 index 00000000..3fa36066 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/HiperParser.kt @@ -0,0 +1,304 @@ +package org.koitharu.kotatsu.parsers.parsers + +import okhttp3.Headers +import org.json.JSONArray +import org.json.JSONObject +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.* + +// TODO +// add filter by genres / type / status / rating +// cant be called via API json + +abstract class HiperParser( + context: MangaLoaderContext, + source: MangaParserSource, + private val domainName: String, +) : PagedMangaParser(context, source, pageSize = 30) { + + protected open val mangaPath = "manga" + + override val configKeyDomain = ConfigKey.Domain(domainName) + override val availableSortOrders: Set = EnumSet.of( + SortOrder.POPULARITY, SortOrder.UPDATED, SortOrder.NEWEST, + SortOrder.NEWEST_ASC, SortOrder.RATING, SortOrder.ALPHABETICAL, SortOrder.RELEVANCE + ) + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + ) + + private suspend fun executeAuthenticatedRequest(url: String): okhttp3.Response { + val res = webClient.httpGet(url, getRequestHeaders()) + if (res.code == 401) { + res.close() + val acceptHeaders = Headers.Builder() + .set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .build() + webClient.httpGet("https://$domainName/", acceptHeaders).close() + return webClient.httpGet(url, getRequestHeaders()) + } + return res + } + + private fun apiUrl(trpcPath: String, input: String) = + "https://$domainName/api/trpc/$trpcPath?batch=1&input=${input.urlEncoded()}" + + private fun searchPayload(query: String, sort: String, limit: Int, offset: Int): String { + return JSONObject().apply { + put("0", JSONObject().apply { + put("json", JSONObject().apply { + put("q", query) + put("sort", sort) + put("filters", JSONObject()) // empty {} + put("limit", limit) + put("offset", offset) + put("maxRating", "pornographic") + }) + put("meta", JSONObject().apply { + put("values", JSONObject().apply { + put("filters.genres", JSONArray().put("undefined")) + put("filters.type", JSONArray().put("undefined")) + put("filters.status", JSONArray().put("undefined")) + put("filters.contentRating", JSONArray().put("undefined")) + put("filters.author", JSONArray().put("undefined")) + put("filters.artist", JSONArray().put("undefined")) + put("filters.year", JSONArray().put("undefined")) + }) + }) + }) + }.toString() + } + + private fun detailsPayload(slug: String): String { + return JSONObject().apply { + put("0", JSONObject().apply { + // "json": null omitted + put("meta", JSONObject().apply { + put("values", JSONArray().put("undefined")) + }) + }) + put("1", JSONObject().apply { + put("json", JSONObject().apply { + put("slug", slug) + }) + }) + }.toString() + } + + private fun chaptersPayload(mangaId: Long): String { + return JSONObject().apply { + put("0", JSONObject().apply { + put("json", JSONObject().apply { + put("values", JSONArray().put("undefined")) + }) + }) + put("1", JSONObject().apply { + put("json", JSONObject().apply { + put("seriesId", mangaId) + put("sort", "best") + put("page", 1) + put("limit", 200) + }) + put("meta", JSONObject().apply { + put("values", JSONObject().apply { + put("chapterId", JSONArray().put("undefined")) + }) + }) + }) + put("2", JSONObject().apply { + put("json", JSONObject().apply { + put("seriesId", mangaId) + }) + }) + }.toString() + } + + private fun pagesPayload(slug: String, number: Float): String { + return JSONObject().apply { + put("0", JSONObject().apply { + put("meta", JSONObject().apply { + put("values", JSONArray().put("undefined")) + }) + }) + put("1", JSONObject().apply { + put("json", JSONObject().apply { + put("slug", slug) + }) + }) + put("2", JSONObject().apply { + put("json", JSONObject().apply { + put("seriesSlug", slug) + put("chapterNumber", number.toDouble()) + }) + }) + put("3", JSONObject().apply { + put("json", JSONObject().apply { + put("position", "footer_bottom") + }) + }) + }.toString() + } + + private fun parseLastItemJsonArray(body: String): JSONArray? = try { + val root = JSONArray(body) + root.optJSONObject(root.length() - 1) + ?.optJSONObject("result") + ?.optJSONObject("data") + ?.optJSONArray("json") + } catch (_: Exception) { null } + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = emptySet(), + ) + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val q = filter.query?.trim().orEmpty() + val sort = when (order) { + SortOrder.POPULARITY -> "popular" + SortOrder.UPDATED -> "recent" + SortOrder.NEWEST -> "newest" + SortOrder.NEWEST_ASC -> "oldest" + SortOrder.RATING -> "score" + SortOrder.ALPHABETICAL -> "alphabetical" + SortOrder.RELEVANCE -> "relevance" + else -> "newest" + } + val input = searchPayload(q, sort, pageSize, (page - 1) * pageSize) + val res = executeAuthenticatedRequest(apiUrl("search.query", input)) + val body = res.body?.string() ?: return emptyList() + val root = JSONArray(body) + val first = root.optJSONObject(0) ?: return emptyList() + val hits = first.optJSONObject("result") + ?.optJSONObject("data") + ?.optJSONObject("json") + ?.optJSONArray("hits") ?: return emptyList() + return (0 until hits.length()).mapNotNull { i -> + hits.optJSONObject(i)?.let { obj -> + val id = obj.optLong("id", -1).takeIf { it > 0 } ?: return@mapNotNull null + val slug = obj.optString("slug", "").takeIf { it.isNotEmpty() } ?: return@mapNotNull null + val title = obj.optString("title", "") + val cover = obj.optString("coverUrl", "") + Manga( + id = generateUid("/$mangaPath/$slug#$id"), + url = "/$mangaPath/$slug#$id", + publicUrl = "https://$domainName/$mangaPath/$slug#$id", + title = title, coverUrl = cover, + altTitles = emptySet(), authors = emptySet(), tags = emptySet(), + rating = RATING_UNKNOWN, state = null, + contentRating = ContentRating.ADULT, source = source + ) + } + } + } + + override suspend fun getDetails(manga: Manga): Manga { + val slug = manga.url.substringAfterLast("$mangaPath/").substringBefore("#") + .takeIf { it.isNotBlank() } ?: return manga + val res = executeAuthenticatedRequest(apiUrl("auth.me,series.bySlugWithGenres", detailsPayload(slug))) + val body = res.body?.string() ?: return manga + val detailJson = try { + val arr = JSONArray(body) + arr.getJSONObject(arr.length() - 1) + .optJSONObject("result")?.optJSONObject("data")?.optJSONObject("json") + } catch (_: Exception) { null } ?: return manga + + val title = detailJson.optString("title", manga.title) + val desc = detailJson.optString("synopsis", manga.description) + val cover = detailJson.optString("coverUrl", manga.coverUrl) + val authors = detailJson.optJSONArray("authors")?.let { arr -> + (0 until arr.length()).map { arr.getString(it) } + } ?: emptyList() + val genres = detailJson.optJSONArray("genres")?.let { arr -> + (0 until arr.length()).map { arr.getString(it) } + } ?: emptyList() + val status = detailJson.optString("status", null) + val chapters = loadChapters(manga) + + return manga.copy( + title = title, description = desc, coverUrl = cover, + authors = authors.toSet(), + tags = genres.map { MangaTag(it.lowercase(), it, source) }.toSet(), + state = when (status?.lowercase()) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "hiatus" -> MangaState.PAUSED + "cancelled" -> MangaState.ABANDONED + else -> null + }, + chapters = chapters, + ) + } + + private suspend fun loadChapters(manga: Manga): List { + val mangaId = manga.url.substringAfterLast("#").toLongOrNull() ?: return emptyList() + val slug = manga.url.substringAfterLast("$mangaPath/").substringBefore("#").ifBlank { return emptyList() } + val res = executeAuthenticatedRequest(apiUrl("auth.me,comments.list,series.chapters", chaptersPayload(mangaId))) + val body = res.body?.string() ?: return emptyList() + val chaptersArray = parseLastItemJsonArray(body) ?: return emptyList() + return (0 until chaptersArray.length()).mapNotNull { i -> + chaptersArray.optJSONObject(i)?.let { obj -> + val number = obj.optDouble("number", 0.0).toFloat() + val createdAt = obj.optString("createdAt", null) + val uploadDate = createdAt?.let { dateFormat.parse(it)?.time } ?: 0L + MangaChapter( + id = generateUid("/$mangaPath/$slug/$number"), + url = "/$mangaPath/$slug/$number", + title = "Chapter ${number.formatForTitle()}", + number = number, volume = 0, uploadDate = uploadDate, + scanlator = null, branch = null, source = source + ) + } + }.sortedBy { it.number } + } + + override suspend fun getPages(chapter: MangaChapter): List { + val path = chapter.url.removePrefix("/$mangaPath/") + val slug = path.substringBefore("/") + val num = path.substringAfter("/").toFloatOrNull() ?: return emptyList() + val input = pagesPayload(slug, num) + + var res = executeAuthenticatedRequest(apiUrl("auth.me,series.bySlug,reader.chapterPages", input)) + var body = res.body?.string() ?: return emptyList() + var pages = parsePages(body) + if (pages != null) return pages + + res.close() + val chapterUrl = "https://$domainName/$mangaPath/$slug/$num" + webClient.httpGet(chapterUrl, getRequestHeaders()).close() + res = executeAuthenticatedRequest(apiUrl("auth.me,series.bySlug,reader.chapterPages", input)) + body = res.body?.string() ?: return emptyList() + pages = parsePages(body) + return pages ?: emptyList() + } + + private fun parsePages(body: String): List? { + val elements = try { JSONArray(body) } catch (_: Exception) { return null } + for (i in 0 until elements.length()) { + if (elements.optJSONObject(i)?.has("error") == true) return null + } + val last = elements.optJSONObject(elements.length() - 1) ?: return null + val items = last.optJSONObject("result") + ?.optJSONObject("data") + ?.optJSONArray("json") ?: return null + return (0 until items.length()).mapNotNull { i -> + val page = items.optJSONObject(i) ?: return@mapNotNull null + val url = page.optString("webpUrl", "").ifEmpty { + page.optString("avifUrl", "") + }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + MangaPage(id = generateUid(url), url = url, preview = null, source = source) + } + } + + override suspend fun getPageUrl(page: MangaPage) = page.url + + private fun Float.formatForTitle() = if (this == toLong().toFloat()) toLong().toString() else toString() + + companion object { + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT) + } +} From 7728ca0f06951e3e2ab228c35c04b4ac0c1bd5d9 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:40:51 +0200 Subject: [PATCH 056/109] Update Hiperdex.kt --- .../site/kotatsu/en/hentais/Hiperdex.kt | 475 +----------------- 1 file changed, 7 insertions(+), 468 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Hiperdex.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Hiperdex.kt index 4ee41ec0..4ed00328 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Hiperdex.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Hiperdex.kt @@ -1,478 +1,17 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.en.hentais -import okhttp3.Interceptor -import okhttp3.Response -import org.jsoup.Jsoup -import org.jsoup.nodes.Document +import okhttp3.Headers import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser -import org.koitharu.kotatsu.parsers.config.ConfigKey -import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities -import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions -import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN -import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.util.generateUid -import java.text.SimpleDateFormat -import java.util.Locale -import org.koitharu.kotatsu.parsers.network.UserAgents +import org.koitharu.kotatsu.parsers.parsers.HiperParser @MangaSourceParser("HIPERDEX", "Hiperdex", "en", ContentType.HENTAI) -internal class Hiperdex(context: MangaLoaderContext) : PagedMangaParser( - context, - MangaParserSource.HIPERDEX, - 20 -) { +internal class Hiperdex(context: MangaLoaderContext) : + HiperParser(context, MangaParserSource.HIPERDEX, "hiperdex.com") { - override val configKeyDomain = ConfigKey.Domain("hiperdex.com") - override val userAgentKey = ConfigKey.UserAgent(UserAgents.KOTATSU) - - override fun onCreateConfig(keys: MutableCollection>) { - super.onCreateConfig(keys) - keys.add(userAgentKey) - } - - override fun intercept( - chain: Interceptor.Chain - ): Response { - val request = - chain.request() - .newBuilder() - .header( - "Referer", - "https://hiperdex.com/" - ) - .header( - "Origin", - "https://hiperdex.com" - ) - .build() - - return chain.proceed(request) - } - - override val availableSortOrders = - setOf( - SortOrder.UPDATED, - SortOrder.NEWEST, - SortOrder.POPULARITY, - SortOrder.RATING - ) - - - override val filterCapabilities = - MangaListFilterCapabilities( - isMultipleTagsSupported = true, - isTagsExclusionSupported = false, - isSearchSupported = true, - isSearchWithFiltersSupported = true, - isYearSupported = false, - isAuthorSearchSupported = false - ) - - private fun parseStatus(document: Document): MangaState? { - - val status = - document - .selectFirst( - "div.summary-heading:contains(Status) + div.summary-content" - ) - ?.text() - ?.lowercase() - ?: return null - return when { - "completed" in status || - "complete" in status || - "finished" in status -> - MangaState.FINISHED - - "ongoing" in status || - "publishing" in status -> - MangaState.ONGOING - - else -> - null - } - } - - - private fun parseDate(value: String): Long { - return runCatching { - SimpleDateFormat( - "dd/MM/yyyy", - Locale.US - ) - .parse(value) - ?.time - ?: 0L - }.getOrDefault(0L) - } - - override suspend fun getListPage( - page: Int, - order: SortOrder, - filter: MangaListFilter - ): List { - val tags = - filter.tags - .map { it.key } - val statuses = - filter.states - .mapNotNull { - when (it) { - MangaState.ONGOING -> "on-going" - MangaState.FINISHED -> "end" - else -> null - } - } - val hasFilter = - tags.isNotEmpty() || - statuses.isNotEmpty() - val url = - buildString { - append("https://hiperdex.com/?") - append("s=") - append(filter.query ?: "") - append("&post_type=wp-manga") - append("&op=") - append("&author=") - append("&artist=") - append("&release=") - append("&adult=") - // Multiple genre filters - tags.forEach { tag -> - - append("&genre[]=") - append(tag) - } - - // Multiple state filters - statuses.forEach { status -> - - append("&status%5B%5D=") - append(status) - } - - // Sorting only when no filters are selected - if (!hasFilter) { - when (order) { - SortOrder.POPULARITY -> - append("&m_orderby=trending") - SortOrder.UPDATED -> - append("&m_orderby=latest") - SortOrder.NEWEST -> - append("&m_orderby=new-manga") - SortOrder.RATING -> - append("&m_orderby=rating") - else -> Unit - } - } - - if (page > 0) { - append("&paged=") - append(page + 1) - } - } - val response = - webClient.httpGet(url) - - val document = - Jsoup.parse( - response.body.string() - ) - - return document - .select("#loop-content div.page-listing-item") - .mapNotNull { item -> - val link = - item.selectFirst(".post-title a") - ?: return@mapNotNull null - val mangaUrl = - link.attr("href") - .trim() - if (mangaUrl.isBlank()) { - return@mapNotNull null - } - val cover = - item.selectFirst("img") - ?.let { - it.attr("data-src") - .ifBlank { - it.attr("src") - } - } - Manga( - id = generateUid(mangaUrl), - title = link.text() - .cleanTitleIfNeeded(), - url = mangaUrl, - publicUrl = mangaUrl, - coverUrl = cover, - altTitles = emptySet(), - rating = RATING_UNKNOWN, - tags = emptySet(), - authors = emptySet(), - state = null, - contentRating = null, - source = source - ) - } - } - - override suspend fun getDetails( - manga: Manga - ): Manga { - val response = - webClient.httpGet(manga.url) - - val document = - Jsoup.parse( - response.body.string() - ) - - val chapters = - document - .select("li.wp-manga-chapter") - .mapNotNull { item -> - val link = - item.selectFirst("a") - ?: return@mapNotNull null - val url = - link.attr("href") - .trim() - val name = - link.text() - .trim() - val number = - extractChapterNumber(name) - ?: return@mapNotNull null - MangaChapter( - id = generateUid(url), - title = name, - number = number.toFloat(), - volume = 0, - url = url, - scanlator = null, - uploadDate = - item.selectFirst(".chapter-release-date") - ?.text() - ?.let(::parseDate) - ?: 0L, - branch = null, - source = source - ) - } - .sortedBy { - it.number - } - - - return manga.copy( - title = - document - .selectFirst(".post-title h1") - ?.text() - ?.cleanTitleIfNeeded() - ?: manga.title, - - description = - document - .selectFirst(".summary__content") - ?.text() - ?: "", - - tags = document - .select(".genres-content a[href*='/genre/']") - .mapNotNull { - - val title = - it.text() - .trim() - - val key = - it.attr("href") - .substringAfter("/genre/") - .substringBefore("/") - .trim() - - - if ( - title.isBlank() || - key.isBlank() - ) { - null - } else { - MangaTag( - title = title, - key = key, - source = source - ) - } - } - .toSet(), - chapters = chapters, - state = parseStatus(document) - ) - } - - override suspend fun getPages( - chapter: MangaChapter - ): List { - - val response = - webClient.httpGet(chapter.url) - - val document = - Jsoup.parse( - response.body.string() - ) - - return document - .select( - "div.page-break:not([style*=display:none]) img" - ) - .mapNotNull { image -> - val url = - image.attr("src") - .ifBlank { - image.attr("data-src") - } - .trim() - if (url.isBlank()) { - return@mapNotNull null - } - MangaPage( - id = generateUid(url), - url = url, - preview = null, - source = source - ) - } - } - - override suspend fun getFilterOptions(): MangaListFilterOptions { - val tags = setOf( - MangaTag("Action", "action", source), - MangaTag("Adult", "adult", source), - MangaTag("Adventure", "adventure", source), - MangaTag("Age Gap", "age-gap", source), - MangaTag("Aliens", "aliens", source), - MangaTag("Anthology", "anthology", source), - MangaTag("Campus", "campus", source), - MangaTag("Childhood Friends", "childhood-friends", source), - MangaTag("Comedy", "comedy", source), - MangaTag("Crime", "crime", source), - MangaTag("Crossdressing", "crossdressing", source), - MangaTag("Dance", "dance", source), - MangaTag("Delinquents", "delinquents", source), - MangaTag("Demons", "demons", source), - MangaTag("Doujinshi", "doujinshi", source), - MangaTag("Drama", "drama", source), - MangaTag("Ecchi", "ecchi", source), - MangaTag("Fantasy", "fantasy", source), - MangaTag("Fetish", "fetish", source), - MangaTag("Furry", "furry", source), - MangaTag("Gender Bender", "gender-bender", source), - MangaTag("Ghosts", "ghosts", source), - MangaTag("Gore", "gore", source), - MangaTag("Gyaru", "gyaru", source), - MangaTag("Harem", "harem", source), - MangaTag("Hentai", "hentai", source), - MangaTag("Heroes", "heroes", source), - MangaTag("Historical", "historical", source), - MangaTag("Horror", "horror", source), - MangaTag("Isekai", "isekai", source), - MangaTag("Josei", "josei", source), - MangaTag("Mafia", "mafia", source), - MangaTag("Magic", "magic", source), - MangaTag("Manga", "manga", source), - MangaTag("Manhwa", "manhwa", source), - MangaTag("Martial Arts", "martial-arts", source), - MangaTag("Mature", "mature", source), - MangaTag("Mecha", "mecha", source), - MangaTag("Medical", "medical", source), - MangaTag("Military", "military", source), - MangaTag("Monster Girls", "monster-girls", source), - MangaTag("Murim", "murim", source), - MangaTag("Music", "music", source), - MangaTag("Mystery", "mystery", source), - MangaTag("Ninja", "ninja", source), - MangaTag("Office Workers", "office-workers", source), - MangaTag("Oneshot", "oneshot", source), - MangaTag("Police", "police", source), - MangaTag("Post-Apocalyptic", "post-apocalyptic", source), - MangaTag("Psychological", "psychological", source), - MangaTag("Regression", "regression", source), - MangaTag("Reincarnation", "reincarnation", source), - MangaTag("Revenge", "revenge", source), - MangaTag("Romance", "romance", source), - MangaTag("School Life", "school-life", source), - MangaTag("Sci-fi", "sci-fi", source), - MangaTag("Secret Relationship", "secret-relationship", source), - MangaTag("Seinen", "seinen", source), - MangaTag("Shoujo", "shoujo", source), - MangaTag("Shoujo Ai", "shoujo-ai", source), - MangaTag("Shounen", "shounen", source), - MangaTag("Slice of Life", "slice-of-life", source), - MangaTag("Smut", "smut", source), - MangaTag("Sports", "sports", source), - MangaTag("Supernatural", "supernatural", source), - MangaTag("Survival", "survival", source), - MangaTag("Suspense", "suspense", source), - MangaTag("Thriller", "thriller", source), - MangaTag("Time Travel", "time-travel", source), - MangaTag("Tower", "tower", source), - MangaTag("Tragedy", "tragedy", source), - MangaTag("Uncensored", "uncensored", source), - MangaTag("Video Games", "video-games", source), - MangaTag("Villainess", "villainess", source), - MangaTag("Violence", "violence", source), - MangaTag("Virtual Reality", "virtual-reality", source), - MangaTag("Wuxia", "wuxia", source), - MangaTag("Yaoi", "yaoi", source), - MangaTag("Yuri", "yuri", source), - ) - - return MangaListFilterOptions( - availableTags = tags, - availableStates = setOf( - MangaState.ONGOING, - MangaState.FINISHED, - ), - availableContentRating = emptySet() - ) - } - - private fun extractChapterNumber( - name: String - ): Double? { - return Regex( - """(?:chapter|chap|ch)\s*\.?\s*(\d+(?:\.\d+)?)""", - RegexOption.IGNORE_CASE - ) - .find(name) - ?.groupValues - ?.getOrNull(1) - ?.toDoubleOrNull() - } - - - private fun String.cleanTitleIfNeeded(): String { - return replace( - Regex( - """^(?:\s*(?:\([^()]*\)|\{[^{}]*\}|\[[^]]*])\s*)+""", - RegexOption.IGNORE_CASE - ), - "" - ) - .trim() - } + override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder() + .set("x-svc-gate", "f5pabmx7sdek") + .build() } From 200b73ef8c7cdfa3fd183f6f89de576358d59fe1 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:51:21 +0200 Subject: [PATCH 057/109] Add files via upload --- .../parsers/site/kotatsu/pt/HiperToon.kt | 10 ++++++++++ .../site/kotatsu/pt/hentais/LerHentais.kt | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/pt/HiperToon.kt create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/pt/hentais/LerHentais.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/pt/HiperToon.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/pt/HiperToon.kt new file mode 100644 index 00000000..b5dbcac1 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/pt/HiperToon.kt @@ -0,0 +1,10 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.pt + +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.parsers.HiperParser + +@MangaSourceParser("HIPERTOON", "Hipertoon", "pt") +internal class HiperToon(context: MangaLoaderContext) : + HiperParser(context, MangaParserSource.HIPERTOON, "hipertoon.com") \ No newline at end of file diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/pt/hentais/LerHentais.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/pt/hentais/LerHentais.kt new file mode 100644 index 00000000..492eea9b --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/pt/hentais/LerHentais.kt @@ -0,0 +1,17 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.pt.hentais + +import okhttp3.Headers +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.parsers.HiperParser + +@MangaSourceParser("LERHENTAIS", "LerHentais", "pt", ContentType.HENTAI) +internal class LerHentais(context: MangaLoaderContext) : + HiperParser(context, MangaParserSource.LERHENTAIS, "lerhentais.com") { + + override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder() + .set("x-flux-node", "G2ZsDdWhUwdU82Vw") + .build() +} \ No newline at end of file From db673f5e6bbc3b589fda60461b584f0737036011 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:56:50 +0200 Subject: [PATCH 058/109] Update Mangadotnet.kt --- .../kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt index 60165bda..b8e5d5ad 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Mangadotnet.kt @@ -93,10 +93,11 @@ internal class Mangadotnet(context: MangaLoaderContext) : ) override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.RATING, - SortOrder.ALPHABETICAL, + SortOrder.UPDATED, // latest + SortOrder.POPULARITY, // most viewed + SortOrder.RATING, // top rated + SortOrder.ALPHABETICAL, // a-z + SortOrder.RELEVANCE, // most tracked ) override suspend fun getFilterOptions(): MangaListFilterOptions { @@ -104,8 +105,8 @@ internal class Mangadotnet(context: MangaLoaderContext) : return MangaListFilterOptions( availableTags = tags, availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED), - availableContentTypes = setOf(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER), - availableDemographics = setOf(Demographic.JOSEI, Demographic.SEINEN, Demographic.SHOUJO, Demographic.SHOUNEN), + availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA, ContentType.OTHER), + availableDemographics = EnumSet.of(Demographic.JOSEI, Demographic.SEINEN, Demographic.SHOUJO, Demographic.SHOUNEN), ) } @@ -122,6 +123,7 @@ internal class Mangadotnet(context: MangaLoaderContext) : SortOrder.POPULARITY -> "views" SortOrder.RATING -> "rating" SortOrder.ALPHABETICAL -> "alphabetical" + SortOrder.RELEVANCE -> "tracked" else -> null } val url = "$baseUrl/view-all/latest-updates.data".toHttpUrl().newBuilder().apply { From 9dba466daa357617954adcf4c1ca64d4f76722eb Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:13:51 +0200 Subject: [PATCH 059/109] error fixxed issue `{"error":"Access denied: Unauthorized API access"}` --- .../parsers/parsers/DoujinDesuParser.kt | 99 +++++++++---------- 1 file changed, 46 insertions(+), 53 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt index c003cc6f..360f629f 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt @@ -19,8 +19,7 @@ internal abstract class DoujinDesuParser( protected abstract val defaultTypes: String protected abstract val availableContentTypes: Set - override val configKeyDomain: ConfigKey.Domain - get() = ConfigKey.Domain("doujin.desu.xxx") + override val configKeyDomain = ConfigKey.Domain("doujin.desu.xxx") override fun onCreateConfig(keys: MutableCollection>) { super.onCreateConfig(keys) @@ -35,19 +34,17 @@ internal abstract class DoujinDesuParser( override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 5 } - override val defaultSortOrder: SortOrder - get() = SortOrder.UPDATED + override val defaultSortOrder: SortOrder = SortOrder.UPDATED - override val availableSortOrders: Set - get() = EnumSet.of(SortOrder.UPDATED, SortOrder.NEWEST, SortOrder.ALPHABETICAL, SortOrder.POPULARITY) + override val availableSortOrders: Set = + EnumSet.of(SortOrder.UPDATED, SortOrder.NEWEST, SortOrder.ALPHABETICAL, SortOrder.POPULARITY) - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isMultipleTagsSupported = true, - isSearchSupported = true, - isSearchWithFiltersSupported = true, - isAuthorSearchSupported = false, - ) + override val filterCapabilities = MangaListFilterCapabilities( + isMultipleTagsSupported = true, + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isAuthorSearchSupported = false, + ) override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = getOrFetchGenres(), @@ -64,26 +61,38 @@ internal abstract class DoujinDesuParser( private suspend fun fetchAvailableTags(): Set { val url = "/api/terms?taxonomy=genre".toAbsoluteUrl(domain) - val jsonResponse = webClient.httpGet(url, extraHeaders = getRequestHeaders()).parseJson() + val jsonResponse = executeWithCloudflareRetry(url, getRequestHeaders()).parseJson() val encHex = jsonResponse.getString("_enc_resp_") val decrypted = decrypt(encHex) val array = JSONArray(decrypted) - val tags = mutableSetOf() - for (i in 0 until array.length()) { + return (0 until array.length()).mapTo(mutableSetOf()) { i -> val obj = array.getJSONObject(i) - val name = obj.getString("name") - val slug = obj.getString("slug") - tags.add(MangaTag(key = slug, title = name, source = source)) + MangaTag(key = obj.getString("slug"), title = obj.getString("name"), source = source) } - return tags } - override fun getRequestHeaders(): Headers = Headers.Builder() + override fun getRequestHeaders() = Headers.Builder() .add("X-Requested-With", "XMLHttpRequest") .add("Referer", "https://$domain/") .add("X-App-Secret", "dfdf72051dbfdc7d76889ebd31324e74") .build() + private suspend fun executeWithCloudflareRetry(url: String, headers: Headers): okhttp3.Response { + var response = webClient.httpGet(url, headers) + if (response.code == 403) { + val body = response.peekBody(1024).string() + if (body.contains("Unauthorized") || body.contains("Access denied")) { + response.close() + refreshCloudflare() + response = webClient.httpGet(url, headers) + } + } + return response + } + + private suspend fun refreshCloudflare() { + webClient.httpGet("https://$domain/", getRequestHeaders()).close() + } override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { val limit = pageSize @@ -92,13 +101,10 @@ internal abstract class DoujinDesuParser( val url = urlBuilder().apply { addPathSegment("api") addPathSegment("manga") - addQueryParameter("limit", limit.toString()) addQueryParameter("offset", offset.toString()) - filter.query?.let { - addQueryParameter("search", it) - } + filter.query?.let { addQueryParameter("search", it) } val sortParam = when (order) { SortOrder.POPULARITY -> "rating" @@ -110,26 +116,20 @@ internal abstract class DoujinDesuParser( addQueryParameter("sort", sortParam) filter.types.oneOrThrowIfMany()?.let { - val typeValue = when (it) { + when (it) { ContentType.MANGA -> "manga" ContentType.MANHWA -> "manhwa" ContentType.DOUJINSHI -> "doujinshi" else -> null - } - if (!typeValue.isNullOrEmpty()) { - addQueryParameter("type", typeValue) - } + }?.let { type -> addQueryParameter("type", type) } } filter.states.oneOrThrowIfMany()?.let { - val stateParam = when (it) { + when (it) { MangaState.ONGOING -> "ongoing" MangaState.FINISHED -> "completed" - else -> "" - } - if (stateParam.isNotEmpty()) { - addQueryParameter("status", stateParam) - } + else -> null + }?.let { status -> addQueryParameter("status", status) } } if (filter.tags.isNotEmpty()) { @@ -137,10 +137,9 @@ internal abstract class DoujinDesuParser( } }.build() - val jsonResponse = webClient.httpGet(url, extraHeaders = getRequestHeaders()).parseJson() + val jsonResponse = executeWithCloudflareRetry(url.toString(), getRequestHeaders()).parseJson() val encHex = jsonResponse.getString("_enc_resp_") val decrypted = decrypt(encHex) - val array = JSONArray(decrypted) return (0 until array.length()).map { i -> @@ -168,15 +167,13 @@ internal abstract class DoujinDesuParser( } } - override suspend fun getDetails(manga: Manga): Manga { - // Return cached details if available detailsCache[manga.url]?.let { return it } val slug = manga.url.removePrefix("/manga/").removeSuffix("/") val url = "/api/manga/$slug".toAbsoluteUrl(domain) - val jsonResponse = webClient.httpGet(url, extraHeaders = getRequestHeaders()).parseJson() + val jsonResponse = executeWithCloudflareRetry(url, getRequestHeaders()).parseJson() val encHex = jsonResponse.getString("_enc_resp_") val decrypted = decrypt(encHex) val obj = JSONObject(decrypted) @@ -198,7 +195,7 @@ internal abstract class DoujinDesuParser( MangaTag( key = genreObj.getString("slug"), title = genreObj.getString("name"), - source = source + source = source, ) ) } @@ -210,22 +207,20 @@ internal abstract class DoujinDesuParser( val chapObj = chaptersArray.getJSONObject(i) val chId = chapObj.getString("id") val chNum = chapObj.optDouble("chapter_number", 0.0).toFloat() - val chTitle = "Chapter $chNum" - val chUrl = "/reader/$chId" val createdAt = chapObj.optString("created_at") val uploadDate = runCatching { Instant.parse(createdAt).toEpochMilli() }.getOrDefault(0L) chapters.add( MangaChapter( - id = generateUid(chUrl), - title = chTitle, + id = generateUid("/reader/$chId"), + title = "Chapter $chNum", number = chNum, volume = 0, - url = chUrl, + url = "/reader/$chId", scanlator = null, uploadDate = uploadDate, branch = null, - source = source + source = source, ) ) } @@ -255,12 +250,11 @@ internal abstract class DoujinDesuParser( return fullManga } - override suspend fun getPages(chapter: MangaChapter): List { val chId = chapter.url.removePrefix("/reader/").removeSuffix("/") val url = "/api/chapters/$chId".toAbsoluteUrl(domain) - val jsonResponse = webClient.httpGet(url, extraHeaders = getRequestHeaders()).parseJson() + val jsonResponse = executeWithCloudflareRetry(url, getRequestHeaders()).parseJson() val encHex = jsonResponse.getString("_enc_resp_") val decrypted = decrypt(encHex) val obj = JSONObject(decrypted) @@ -275,7 +269,7 @@ internal abstract class DoujinDesuParser( id = generateUid(rawUrl), url = transformPageUrl(rawUrl), preview = null, - source = source + source = source, ) ) } @@ -290,7 +284,7 @@ internal abstract class DoujinDesuParser( id = generateUid(rawUrl), url = transformPageUrl(rawUrl), preview = null, - source = source + source = source, ) ) } @@ -300,7 +294,6 @@ internal abstract class DoujinDesuParser( return pagesList } - private fun generateKey(step: Long): String { val input = "doujindesu-scrapers-cannot-read-this-super-secret-salt-2026-v2_$step" var n = 0 From 64fd0fbaf5ad48415113c0ca7ab2d239858245de Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:34:40 +0200 Subject: [PATCH 060/109] Update DoujinDesuParser.kt --- .../parsers/parsers/DoujinDesuParser.kt | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt index 360f629f..bb423f7a 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt @@ -1,5 +1,7 @@ package org.koitharu.kotatsu.parsers.parsers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import okhttp3.Headers import org.json.JSONArray import org.json.JSONObject @@ -11,6 +13,10 @@ import org.koitharu.kotatsu.parsers.util.* import java.time.Instant import java.util.* +// cloudflare session is 15-60min +// after session expires we get "{"error":"Access denied: Unauthorized API access"}" error +// only current fix is to restart application to refresh the connection + internal abstract class DoujinDesuParser( context: MangaLoaderContext, source: MangaParserSource @@ -28,6 +34,7 @@ internal abstract class DoujinDesuParser( @Volatile private var genresCache: Set? = null + private val genresMutex = Mutex() @get:Synchronized private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { @@ -36,8 +43,9 @@ internal abstract class DoujinDesuParser( override val defaultSortOrder: SortOrder = SortOrder.UPDATED - override val availableSortOrders: Set = - EnumSet.of(SortOrder.UPDATED, SortOrder.NEWEST, SortOrder.ALPHABETICAL, SortOrder.POPULARITY) + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, SortOrder.NEWEST, SortOrder.ALPHABETICAL, SortOrder.POPULARITY + ) override val filterCapabilities = MangaListFilterCapabilities( isMultipleTagsSupported = true, @@ -54,9 +62,16 @@ internal abstract class DoujinDesuParser( private suspend fun getOrFetchGenres(): Set { genresCache?.let { return it } - val tags = fetchAvailableTags() - genresCache = tags - return tags + return genresMutex.withLock { + genresCache ?: run { + if (genresCache == null) { + refreshCloudflare() + } + val tags = fetchAvailableTags() + genresCache = tags + tags + } + } } private suspend fun fetchAvailableTags(): Set { @@ -80,13 +95,19 @@ internal abstract class DoujinDesuParser( private suspend fun executeWithCloudflareRetry(url: String, headers: Headers): okhttp3.Response { var response = webClient.httpGet(url, headers) if (response.code == 403) { - val body = response.peekBody(1024).string() - if (body.contains("Unauthorized") || body.contains("Access denied")) { + val bodyString = response.body?.string().orEmpty() + if ("Unauthorized" in bodyString || "Access denied" in bodyString) { response.close() refreshCloudflare() response = webClient.httpGet(url, headers) } } + // Also retry on server errors once + if (response.code in 500..599) { + response.close() + kotlinx.coroutines.delay(1000L) + response = webClient.httpGet(url, headers) + } return response } @@ -207,13 +228,18 @@ internal abstract class DoujinDesuParser( val chapObj = chaptersArray.getJSONObject(i) val chId = chapObj.getString("id") val chNum = chapObj.optDouble("chapter_number", 0.0).toFloat() + val chTitle = if (chNum == chNum.toLong().toFloat()) { + "Chapter ${chNum.toLong()}" + } else { + "Chapter $chNum" + } val createdAt = chapObj.optString("created_at") val uploadDate = runCatching { Instant.parse(createdAt).toEpochMilli() }.getOrDefault(0L) chapters.add( MangaChapter( id = generateUid("/reader/$chId"), - title = "Chapter $chNum", + title = chTitle, number = chNum, volume = 0, url = "/reader/$chId", @@ -227,8 +253,18 @@ internal abstract class DoujinDesuParser( val rawDesc = obj.optString("description").takeIf { it.isNotEmpty() && it != "null" } val cleanDesc = rawDesc?.let { html -> - org.jsoup.Jsoup.parseBodyFragment(html).text() - .replace(Regex("^Sinopsis:\\s*", RegexOption.IGNORE_CASE), "") + val document = org.jsoup.Jsoup.parseBodyFragment(html) + val synopsisParagraph = document.selectFirst("p, .rich-text-content") + val fullText = synopsisParagraph?.text() ?: document.text() + + val cutoffRegex = Regex("""download\s+(batch|volume)""", RegexOption.IGNORE_CASE) + val cutoffIndex = cutoffRegex.find(fullText)?.range?.first + val textBeforeDownload = if (cutoffIndex != null) fullText.substring(0, cutoffIndex) else fullText + + textBeforeDownload + .replace(Regex("""^Sinopsis:\s*""", RegexOption.IGNORE_CASE), "") + .replace(Regex("""https?://\S+"""), "") + .replace(Regex("""\[url=.*?]|\[/url]"""), "") .trim() } From c01584b4b1c5596d36a75a1dc94473da9800d299 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:54:41 +0200 Subject: [PATCH 061/109] Update ZinManga.kt --- .../kotatsu/parsers/site/kotatsu/en/ZinManga.kt | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ZinManga.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ZinManga.kt index ad7f3c66..98e8a4e3 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ZinManga.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/ZinManga.kt @@ -17,19 +17,12 @@ internal class Zinmanga(context: MangaLoaderContext) : override val datePattern = "yyyy-MM-dd" override val withoutAjax = true - // The reader page carries a ubiquitous `
+> [!NOTE] +> - The plugin will not update automatically unless you enable this feature (see screenshot). +> - You can manually check anytime in `Manage Plugins` for an `Install` button to update. + +
+❓ Where? + +![Screenshot][img4] + +
+ 1. Open up `Explore` @@ -81,17 +92,6 @@ InvalidDavid/UMA And that's it! Select your new sources in the catalog and have fun reading. -> **Note:** The plugin will not update automatically unless you enable this feature (see screenshot). -> You can manually check anytime in `Manage Plugins` for an `Install` button to update. - -
-❓ Where? - -![Screenshot][img4] - -
- - @@ -176,6 +176,13 @@ chmod +x gradlew && ./gradlew buildJar --- +# FAQ + +* What does UMA stands for? +It means **U**nified **M**anga **A**pp, I know bad name lol. + +--- + # Credits * Thanks to [KotatsuApp](https://github.com/KotatsuApp) for providing some parsers and the core library. From 932a200b83d3f3904d8cf551adc566dde6046e6f Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:32:32 +0200 Subject: [PATCH 082/109] Update README.md --- README.md | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6085219c..7d8386fa 100644 --- a/README.md +++ b/README.md @@ -50,18 +50,18 @@ There are actually two ways, one which you manually update the Plugin from time
📷 Image Tutorial -![Screenshot][img] +![Screenshot][img_automatic_tutorial]
-> [!NOTE] -> - The plugin will not update automatically unless you enable this feature (see screenshot). +> [!IMPORTANT] +> - The plugin will not update automatically unless you enable this feature (see screenshot). > - You can manually check anytime in `Manage Plugins` for an `Install` button to update.
❓ Where? -![Screenshot][img4] +![Screenshot][img_activate_auto]
@@ -105,18 +105,18 @@ And that's it! Select your new sources in the catalog and have fun reading.
📥 How to download .jar File -![Screenshot][img3] +![Screenshot][img_how_to_download]
📷 Image Turoial -![Screenshot][img5] +![Screenshot][img_manual_tutorial]
1. Download the latest version of the Plugin from the [Releases](https://github.com/InvalidDavid/UMA/releases) page. -Click on the blue Name or press the three dots on the right side and press Download. + Click on the blue Name or press the three dots on the right side and press Download. 2. Go back to the App and open up `Explore` @@ -179,7 +179,7 @@ chmod +x gradlew && ./gradlew buildJar # FAQ * What does UMA stands for? -It means **U**nified **M**anga **A**pp, I know bad name lol. + It means **U**nified **M**anga **A**pp, I know bad name lol. --- @@ -212,9 +212,7 @@ You may copy, distribute and modify the software as long as you track changes/da [telegram-shield]: https://img.shields.io/badge/-Telegram-black.svg?style=for-the-badge&logo=telegram&colorB=555 [telegram-url]: https://t.me/invalidxdavid -[img]: https://cdn.discordapp.com/attachments/1518283800830939176/1519723800327880826/image.png?ex=6a3e9895&is=6a3d4715&hm=818321ffb9a46f31c9148b5f4a234cac44b5a1ca559dc77c84206d451937ab80& -[img1]: https://cdn.discordapp.com/attachments/1518283800830939176/1519672301883363409/image.png?ex=6a3e689e&is=6a3d171e&hm=fca40dfdf33ea99b07e7237b4d9a44afea1991be7b59f750a6bc9a906146c53e& -[img2]: https://cdn.discordapp.com/attachments/1518283800830939176/1519672539532628068/image.png?ex=6a3e68d7&is=6a3d1757&hm=1551bf57fde5d3e8c7cb53c182347bfcb3b2ff58fa297c88148338e88c2b5fe1& -[img3]: https://cdn.discordapp.com/attachments/1518283800830939176/1519673238316126268/image.png?ex=6a3e697e&is=6a3d17fe&hm=ed8e12ca85af9b8aa461463bf8095d958e46614e5478b97f95363875a81ab62b& -[img4]: https://cdn.discordapp.com/attachments/1518283800830939176/1519673924554588321/image.png?ex=6a3e6a21&is=6a3d18a1&hm=2321b2d2a632080101312427741a790be7b1c3c15fd32cb82e5e3ba86fcfbe39& -[img5]: https://cdn.discordapp.com/attachments/1518283800830939176/1519813607251116074/image.png?ex=6a3eec38&is=6a3d9ab8&hm=3cc4e454a7afd4233000cd0cf8efb8aa423841312b7399573e6ac5dbc90abdfd& +[img_automatic_tutorial]: https://github.com/user-attachments/assets/1e4059f4-9c13-4bf9-9962-a9db28d2736d +[img_manual_tutorial]: https://github.com/user-attachments/assets/a587ebfa-1cd8-4ce7-a437-108c0a5340cd +[img_how_to_download]: https://github.com/user-attachments/assets/3539fb68-f417-4aac-a6d7-f7fdd7f5b7bb +[img_activate_auto]: https://github.com/user-attachments/assets/69d807be-3e2f-44ef-9cef-329b520f1437 From 741424d55ea988d76149fcd52097a6054f8fe8fc Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:46:11 +0200 Subject: [PATCH 083/109] Update README.md --- README.md | 44 ++++++++++++++++++-------------------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 7d8386fa..50bdf39a 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,17 @@ [![Issues][issues-shield]][issues-url] [![License][license-shield]][license-url] ### Please give the repo a ⭐ +It motivates me. =^..^= # Sources Requests -Open up an Issue with these contents: - -- URL -- Name -- language? -- NSFW yes/no - -Or join the Support Server or contact me over Telegram (see Contact Info) +> [!NOTE] +> Open up an [Issue](https://github.com/InvalidDavid/UMA/issues/new) with these contents: +> Title Source Name +> - URL +> - language? +> - NSFW yes/no +> +> Or via Contact Info (see below) --- @@ -31,16 +32,19 @@ Or join the Support Server or contact me over Telegram (see Contact Info) # Tutorial -
-How to add a Plugin in Usagi +> [!TIP] +> Two ways how to add the plugin: +> 1. Automatically (recommended) +> 2. Manually after every new release. -
-There are actually two ways, one which you manually update the Plugin from time to time **or** let it do automatically. - -
+> [!IMPORTANT] +> The plugin will not update automatically unless you enable this feature (see `❓ Where?` in automatic category). +> You can manually check anytime in `Manage Plugins` for an `Install` button to update. +
+How to add a Plugin in Usagi
Automatically (recommended) @@ -53,11 +57,6 @@ There are actually two ways, one which you manually update the Plugin from time ![Screenshot][img_automatic_tutorial]
- -> [!IMPORTANT] -> - The plugin will not update automatically unless you enable this feature (see screenshot). -> - You can manually check anytime in `Manage Plugins` for an `Install` button to update. -
❓ Where? @@ -176,13 +175,6 @@ chmod +x gradlew && ./gradlew buildJar --- -# FAQ - -* What does UMA stands for? - It means **U**nified **M**anga **A**pp, I know bad name lol. - ---- - # Credits * Thanks to [KotatsuApp](https://github.com/KotatsuApp) for providing some parsers and the core library. From 85a8e82bae65d61eb55b6848d152b1e921c63c4e Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:11:08 +0200 Subject: [PATCH 084/109] Update Batcave.kt --- .../parsers/site/kotatsu/en/Batcave.kt | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Batcave.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Batcave.kt index 46119385..7699d15d 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Batcave.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/Batcave.kt @@ -19,7 +19,7 @@ import java.text.SimpleDateFormat import java.util.* @MangaSourceParser("BATCAVE", "Batcave", "en", ContentType.COMICS) -internal class BatCave(context: MangaLoaderContext) : +internal class Batcave(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.BATCAVE, 20) { override val configKeyDomain = ConfigKey.Domain("batcave.biz") @@ -78,27 +78,8 @@ internal class BatCave(context: MangaLoaderContext) : ) override suspend fun getFilterOptions(): MangaListFilterOptions { - if (publishers.isEmpty() && genres.isEmpty() && !filterParseFailed) { - try { - val doc = apiClient.httpGet("/comix/".toAbsoluteUrl(domain)).parseHtml() - val script = doc.selectFirst("script:containsData(window.__XFILTER__)")?.data() - if (script != null) { - val data = script - .substringAfter("window.__XFILTER__ = ") - .substringBeforeLast(";") - .trim() - val json = JSONObject(data) - val filterItems = json.getJSONObject("filter_items") - publishers = parseFilterValues(filterItems, "p") - genres = parseFilterValues(filterItems, "g") - } - } catch (_: Exception) { - filterParseFailed = true - } - } return MangaListFilterOptions( - availableTags = genres.map { (name, id) -> MangaTag(name, id.toString(), source) }.toSet(), - availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), + availableTags = emptySet(), ) } From f12e27ab4161b23cfc9b8a21eb781c0b3bdb5918 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:28:48 +0200 Subject: [PATCH 085/109] Update DoujinDesuParser.kt --- .../parsers/parsers/DoujinDesuParser.kt | 139 ++++++++---------- 1 file changed, 63 insertions(+), 76 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt index 609a5c41..d5be5b35 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.parsers.parsers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import okhttp3.Headers import org.json.JSONArray import org.json.JSONObject @@ -10,17 +8,16 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.* +import java.net.URLDecoder import java.time.Instant import java.util.* - -// cloudflare session is 15-60min -// after session expires we get "{"error":"Access denied: Unauthorized API access"}" error -// only current fix is to restart application to refresh the connection +import kotlin.math.abs +import kotlin.time.Duration.Companion.milliseconds internal abstract class DoujinDesuParser( context: MangaLoaderContext, source: MangaParserSource -) : PagedMangaParser(context, source, pageSize = 18) { +): PagedMangaParser(context, source, pageSize = 18) { protected abstract val defaultTypes: String protected abstract val availableContentTypes: Set @@ -34,7 +31,6 @@ internal abstract class DoujinDesuParser( @Volatile private var genresCache: Set? = null - private val genresMutex = Mutex() @get:Synchronized private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { @@ -62,23 +58,14 @@ internal abstract class DoujinDesuParser( private suspend fun getOrFetchGenres(): Set { genresCache?.let { return it } - return genresMutex.withLock { - genresCache ?: run { - if (genresCache == null) { - refreshCloudflare() - } - val tags = fetchAvailableTags() - genresCache = tags - tags - } - } + genresCache = fetchAvailableTags() + return genresCache!! } private suspend fun fetchAvailableTags(): Set { val url = "/api/terms?taxonomy=genre".toAbsoluteUrl(domain) val jsonResponse = executeWithCloudflareRetry(url, getRequestHeaders()).parseJson() - val encHex = jsonResponse.getString("_enc_resp_") - val decrypted = decrypt(encHex) + val decrypted = decrypt(jsonResponse.getString("_enc_resp_")) val array = JSONArray(decrypted) return (0 until array.length()).mapTo(mutableSetOf()) { i -> val obj = array.getJSONObject(i) @@ -102,10 +89,9 @@ internal abstract class DoujinDesuParser( response = webClient.httpGet(url, headers) } } - // Also retry on server errors once if (response.code in 500..599) { response.close() - kotlinx.coroutines.delay(1000L) + kotlinx.coroutines.delay(1000L.milliseconds) response = webClient.httpGet(url, headers) } return response @@ -159,8 +145,7 @@ internal abstract class DoujinDesuParser( }.build() val jsonResponse = executeWithCloudflareRetry(url.toString(), getRequestHeaders()).parseJson() - val encHex = jsonResponse.getString("_enc_resp_") - val decrypted = decrypt(encHex) + val decrypted = decrypt(jsonResponse.getString("_enc_resp_")) val array = JSONArray(decrypted) return (0 until array.length()).map { i -> @@ -195,8 +180,7 @@ internal abstract class DoujinDesuParser( val url = "/api/manga/$slug".toAbsoluteUrl(domain) val jsonResponse = executeWithCloudflareRetry(url, getRequestHeaders()).parseJson() - val encHex = jsonResponse.getString("_enc_resp_") - val decrypted = decrypt(encHex) + val decrypted = decrypt(jsonResponse.getString("_enc_resp_")) val obj = JSONObject(decrypted) val state = when (obj.optString("status")) { @@ -223,28 +207,24 @@ internal abstract class DoujinDesuParser( } val chaptersArray = obj.getJSONArray("chapters") - val chapters = mutableListOf() + val chapters = ArrayList(chaptersArray.length()) for (i in 0 until chaptersArray.length()) { val chapObj = chaptersArray.getJSONObject(i) - val chId = chapObj.getString("id") val chNum = chapObj.optDouble("chapter_number", 0.0).toFloat() val chTitle = if (chNum == chNum.toLong().toFloat()) { "Chapter ${chNum.toLong()}" } else { "Chapter $chNum" } - val createdAt = chapObj.optString("created_at") - val uploadDate = runCatching { Instant.parse(createdAt).toEpochMilli() }.getOrDefault(0L) - chapters.add( MangaChapter( - id = generateUid("/reader/$chId"), + id = generateUid("/reader/${chapObj.getString("id")}"), title = chTitle, number = chNum, volume = 0, - url = "/reader/$chId", + url = "/reader/${chapObj.getString("id")}", scanlator = null, - uploadDate = uploadDate, + uploadDate = runCatching { Instant.parse(chapObj.optString("created_at")).toEpochMilli() }.getOrDefault(0L), branch = null, source = source, ) @@ -254,13 +234,10 @@ internal abstract class DoujinDesuParser( val rawDesc = obj.optString("description").takeIf { it.isNotEmpty() && it != "null" } val cleanDesc = rawDesc?.let { html -> val document = org.jsoup.Jsoup.parseBodyFragment(html) - val synopsisParagraph = document.selectFirst("p, .rich-text-content") - val fullText = synopsisParagraph?.text() ?: document.text() - + val fullText = document.text() val cutoffRegex = Regex("""download\s+(batch|volume)""", RegexOption.IGNORE_CASE) val cutoffIndex = cutoffRegex.find(fullText)?.range?.first val textBeforeDownload = if (cutoffIndex != null) fullText.substring(0, cutoffIndex) else fullText - textBeforeDownload .replace(Regex("""^Sinopsis:\s*""", RegexOption.IGNORE_CASE), "") .replace(Regex("""https?://\S+"""), "") @@ -291,8 +268,7 @@ internal abstract class DoujinDesuParser( val url = "/api/chapters/$chId".toAbsoluteUrl(domain) val jsonResponse = executeWithCloudflareRetry(url, getRequestHeaders()).parseJson() - val encHex = jsonResponse.getString("_enc_resp_") - val decrypted = decrypt(encHex) + val decrypted = decrypt(jsonResponse.getString("_enc_resp_")) val obj = JSONObject(decrypted) val pagesList = mutableListOf() @@ -330,51 +306,57 @@ internal abstract class DoujinDesuParser( return pagesList } - private fun generateKey(step: Long): String { - val input = "doujindesu-scrapers-cannot-read-this-super-secret-salt-2026-v2_$step" - var n = 0 - for (i in input.indices) { - n = (n shl 5) - n + input[i].code + private fun toSigned32(x: Long): Int { + var y = x and 0xFFFFFFFFL + if (y >= 0x80000000L) y -= 0x100000000L + return y.toInt() + } + + private fun wH(e: Int): String { + val t = SALT + "_" + e + var a = 0 + for (ch in t) { + a = (a shl 5) - a + ch.code + a = toSigned32(a.toLong()) } - var seed = if (n == 0) 123456789L else kotlin.math.abs(n.toLong()) - val keyBuilder = StringBuilder() - for (i in 0 until 32) { - seed = (seed * 1664525L + 1013904223L) and 0xFFFFFFFFL - val charCode = 33 + (seed % 93).toInt() - keyBuilder.append(charCode.toChar()) + var l = if (a != 0) abs(a).toLong() else 123456789L + return buildString { + repeat(32) { + l = (l * 1664525L + 1013904223L) % 4294967296L + append((33 + (l % 93)).toInt().toChar()) + } } - return keyBuilder.toString() } - private fun decrypt(encHex: String): String { - val timeStep = 3600000L + private fun lU(): List { val now = System.currentTimeMillis() - val currentStep = now / timeStep + val t = now / HOUR_MS + return listOf(wH(t.toInt()), wH((t - 1).toInt()), wH((t + 1).toInt())) + } + + private fun yre(encryptedHex: String, key: String): String { + val bytes = encryptedHex.chunked(2).mapNotNull { it.toIntOrNull(16) } + var d = 42 + val keyLen = key.length + return buildString { + for ((idx, byteVal) in bytes.withIndex()) { + val keyChar = key[idx % keyLen].code + val k = byteVal xor keyChar xor (idx * 13) xor d + append((k and 0xFF).toChar()) + d = (d + byteVal) % 256 + } + } + } - var lastError: Exception? = null - for (offset in intArrayOf(0, -1, 1)) { - val step = currentStep + offset - val key = generateKey(step) + private fun decrypt(encryptedHex: String): String { + for (key in lU()) { try { - val encBytes = ByteArray(encHex.length / 2) { i -> - encHex.substring(i * 2, i * 2 + 2).toInt(16).toByte() - } - val decBytes = ByteArray(encBytes.size) - var c = 42 - for (i in encBytes.indices) { - val p = encBytes[i].toInt() and 0xFF - val f = key[i % key.length].code - val k = p xor f xor (i * 13) xor c - decBytes[i] = (k and 0xFF).toByte() - c = (c + p) % 256 - } - val decoded = String(decBytes, Charsets.UTF_8) - return java.net.URLDecoder.decode(decoded, "UTF-8") - } catch (e: Exception) { - lastError = e + val decoded = yre(encryptedHex, key) + return URLDecoder.decode(decoded, "UTF-8") + } catch (_: Exception) { } } - throw lastError ?: RuntimeException("Decryption failed") + throw RuntimeException("Decryption failed") } private fun transformPageUrl(page: String): String = when { @@ -384,4 +366,9 @@ internal abstract class DoujinDesuParser( page.replace("/upload/", "/storage/upload/") else -> page } + + companion object { + private const val SALT = "doujindesu-scrapers-cannot-read-this-super-secret-salt-2026-v2" + private const val HOUR_MS = 3600000L + } } From d5cffd095dffff4f4706f242498baf2cddf6acd7 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:38:13 +0200 Subject: [PATCH 086/109] Update DoujinDesuParser.kt --- .../parsers/parsers/DoujinDesuParser.kt | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt index d5be5b35..cabf4992 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/DoujinDesuParser.kt @@ -1,5 +1,7 @@ package org.koitharu.kotatsu.parsers.parsers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import okhttp3.Headers import org.json.JSONArray import org.json.JSONObject @@ -11,7 +13,6 @@ import org.koitharu.kotatsu.parsers.util.* import java.net.URLDecoder import java.time.Instant import java.util.* -import kotlin.math.abs import kotlin.time.Duration.Companion.milliseconds internal abstract class DoujinDesuParser( @@ -31,6 +32,7 @@ internal abstract class DoujinDesuParser( @Volatile private var genresCache: Set? = null + private val genresMutex = Mutex() @get:Synchronized private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { @@ -58,8 +60,12 @@ internal abstract class DoujinDesuParser( private suspend fun getOrFetchGenres(): Set { genresCache?.let { return it } - genresCache = fetchAvailableTags() - return genresCache!! + return genresMutex.withLock { + genresCache ?: run { + genresCache = fetchAvailableTags() + genresCache!! + } + } } private suspend fun fetchAvailableTags(): Set { @@ -275,11 +281,10 @@ internal abstract class DoujinDesuParser( val contentUrls = obj.optJSONArray("content_urls") if (contentUrls != null && contentUrls.length() > 0) { for (i in 0 until contentUrls.length()) { - val rawUrl = contentUrls.getString(i) pagesList.add( MangaPage( - id = generateUid(rawUrl), - url = transformPageUrl(rawUrl), + id = generateUid(contentUrls.getString(i)), + url = transformPageUrl(contentUrls.getString(i)), preview = null, source = source, ) @@ -290,11 +295,10 @@ internal abstract class DoujinDesuParser( if (signedUrlsStr.isNotEmpty()) { val signedUrls = JSONArray(signedUrlsStr) for (i in 0 until signedUrls.length()) { - val rawUrl = signedUrls.getString(i) pagesList.add( MangaPage( - id = generateUid(rawUrl), - url = transformPageUrl(rawUrl), + id = generateUid(signedUrls.getString(i)), + url = transformPageUrl(signedUrls.getString(i)), preview = null, source = source, ) @@ -313,13 +317,13 @@ internal abstract class DoujinDesuParser( } private fun wH(e: Int): String { - val t = SALT + "_" + e + val t = "doujindesu-scrapers-cannot-read-this-super-secret-salt-2026-v2_$e" var a = 0 for (ch in t) { a = (a shl 5) - a + ch.code a = toSigned32(a.toLong()) } - var l = if (a != 0) abs(a).toLong() else 123456789L + var l = if (a != 0) kotlin.math.abs(a).toLong() else 123456789L return buildString { repeat(32) { l = (l * 1664525L + 1013904223L) % 4294967296L @@ -330,7 +334,7 @@ internal abstract class DoujinDesuParser( private fun lU(): List { val now = System.currentTimeMillis() - val t = now / HOUR_MS + val t = now / 3600000L return listOf(wH(t.toInt()), wH((t - 1).toInt()), wH((t + 1).toInt())) } @@ -366,9 +370,4 @@ internal abstract class DoujinDesuParser( page.replace("/upload/", "/storage/upload/") else -> page } - - companion object { - private const val SALT = "doujindesu-scrapers-cannot-read-this-super-secret-salt-2026-v2" - private const val HOUR_MS = 3600000L - } } From 2ea8c9e8083efd218583dc2c0f0417c5d5af40a8 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:33:44 +0200 Subject: [PATCH 087/109] Update Manhwa18.kt --- .../site/kotatsu/en/hentais/Manhwa18.kt | 373 ++++++++---------- 1 file changed, 165 insertions(+), 208 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18.kt index 3fab46cc..627304fb 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18.kt @@ -1,133 +1,120 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.en.hentais -import androidx.collection.ArrayMap +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.Headers import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser -import org.koitharu.kotatsu.parsers.model.ContentRating -import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.parsers.model.Favicon -import org.koitharu.kotatsu.parsers.model.Favicons -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities -import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN -import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.util.attrAsAbsoluteUrl -import org.koitharu.kotatsu.parsers.util.attrAsAbsoluteUrlOrNull -import org.koitharu.kotatsu.parsers.util.attrAsRelativeUrlOrNull -import org.koitharu.kotatsu.parsers.util.generateUid -import org.koitharu.kotatsu.parsers.util.mapChapters -import org.koitharu.kotatsu.parsers.util.mapNotNullToSet -import org.koitharu.kotatsu.parsers.util.nullIfEmpty -import org.koitharu.kotatsu.parsers.util.oneOrThrowIfMany -import org.koitharu.kotatsu.parsers.util.ownTextOrNull -import org.koitharu.kotatsu.parsers.util.parseHtml -import org.koitharu.kotatsu.parsers.util.requireElementById -import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow -import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy -import org.koitharu.kotatsu.parsers.util.textOrNull -import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl -import org.koitharu.kotatsu.parsers.util.toRelativeUrl -import org.koitharu.kotatsu.parsers.util.toTitleCase -import org.koitharu.kotatsu.parsers.util.urlEncoded -import java.util.Calendar -import java.util.EnumSet -import java.util.Locale +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.* @MangaSourceParser("MANHWA18", "Manhwa18", "en", type = ContentType.HENTAI) internal class Manhwa18(context: MangaLoaderContext) : - PagedMangaParser(context, MangaParserSource.MANHWA18, pageSize = 18, searchPageSize = 18) { + PagedMangaParser(context, MangaParserSource.MANHWA18, pageSize = 18) { - override val configKeyDomain = ConfigKey.Domain ("manhwa18.com") + override val configKeyDomain = ConfigKey.Domain("manhwa18.com") override fun onCreateConfig(keys: MutableCollection>) { super.onCreateConfig(keys) keys.add(userAgentKey) } - override val availableSortOrders: Set - get() = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.ALPHABETICAL, - SortOrder.NEWEST, - SortOrder.RATING, - ) + override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder() + .add("Referer", "https://$domain/") + .build() - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isMultipleTagsSupported = true, - isTagsExclusionSupported = true, - isSearchSupported = true, - isSearchWithFiltersSupported = true, - ) + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.ALPHABETICAL, + SortOrder.NEWEST, SortOrder.RATING, + ) + + override val filterCapabilities = MangaListFilterCapabilities( + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + isSearchSupported = true, + isSearchWithFiltersSupported = true, + ) + + private val chapterDateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.ENGLISH) + + private val chapterNumberRegex = Regex("""(?:chapter|ch|ep)\s*\.?\s*(\d+(?:\.\d+)?)""", RegexOption.IGNORE_CASE) + private val numericRegex = Regex("""\d+""") + + @Volatile + private var tagsCache: Set? = null + private val tagsMutex = Mutex() override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = tagsMap.get().values.toSet(), - availableStates = EnumSet.of( - MangaState.ONGOING, - MangaState.FINISHED, - MangaState.PAUSED, - ), + availableTags = getOrFetchTags(), + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED), ) - override suspend fun getFavicons(): Favicons { - return Favicons( - listOf( - Favicon("https://$domain/uploads/logos/logo-mini.png", 92, null), - ), - domain, - ) + private suspend fun getOrFetchTags(): Set { + tagsCache?.let { return it } + return tagsMutex.withLock { + tagsCache ?: fetchTags().also { tagsCache = it } + } + } + + private suspend fun fetchTags(): Set { + val doc = webClient.httpGet("https://$domain/tim-kiem?q=").parseHtml() + val list = doc.select("[data-genre-id]") + if (list.isEmpty()) return emptySet() + return list.mapNotNull { item -> + val id = item.attr("data-genre-id") + val name = item.text().trim() + if (id.isNotEmpty() && name.isNotEmpty()) { + MangaTag(name.toTitleCase(Locale.ENGLISH), id, source) + } else null + }.toSet() + } + + private fun extractCoverUrl(doc: org.jsoup.nodes.Document, fallback: String = ""): String { + doc.selectFirst("meta[property=og:image]")?.attrAsAbsoluteUrl("content")?.let { return it } + doc.selectFirst(".lazy-bg")?.attrAsAbsoluteUrl("data-bg")?.let { return it } + val styleUrl = doc.selectFirst(".img-in-ratio")?.attr("style") + ?: doc.selectFirst(".au-cover-art")?.attr("style") + if (styleUrl != null) { + val url = styleUrl.substringAfter("url('").substringBefore("')") + .ifEmpty { styleUrl.substringAfter("url(\"").substringBefore("\"") } + if (url.isNotEmpty()) { + return if (url.startsWith("http")) url else url.toAbsoluteUrl(domain) + } + } + return fallback } override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val query = filter.query + val query = filter.query?.trim()?.takeIf { it.isNotEmpty() } val url = buildString { - append("https://") - append(domain) - append("/tim-kiem?page=") - append(page.toString()) - - query?.let { + append("https://$domain/tim-kiem?page=$page") + if (query != null) { append("&q=") append(query.urlEncoded()) } - append("&accept_genres=") if (filter.tags.isNotEmpty()) { - append( - filter.tags.joinToString(",") { it.key }, - ) + append(filter.tags.joinToString(",") { it.key }) } - append("&reject_genres=") if (filter.tagsExclude.isNotEmpty()) { - append( - filter.tagsExclude.joinToString(",") { it.key }, - ) + append(filter.tagsExclude.joinToString(",") { it.key }) } - append("&sort=") append( when (order) { SortOrder.ALPHABETICAL -> "az" - SortOrder.ALPHABETICAL_DESC -> "za" SortOrder.POPULARITY -> "top" SortOrder.UPDATED -> "update" SortOrder.NEWEST -> "new" SortOrder.RATING -> "like" else -> "update" - }, + } ) - filter.states.oneOrThrowIfMany()?.let { append("&status=") append( @@ -136,148 +123,118 @@ internal class Manhwa18(context: MangaLoaderContext) : MangaState.FINISHED -> "3" MangaState.PAUSED -> "2" else -> "" - }, + } ) } - - // Support author - // filter.author.let{ - // the - // append("&artist=") - // append(filter.author) - // } - } - val docs = webClient.httpGet(url).parseHtml() + val doc = webClient.httpGet(url).parseHtml() + return doc.select(".thumb-item-flow").map { element -> + val a = element.selectFirst("a") ?: return@map null + val absUrl = a.attrAsAbsoluteUrl("href") + val title = element.selectFirst(".series-title a")?.text() ?: a.text() + val cover = element.selectFirst(".lazy-bg")?.attrAsAbsoluteUrl("data-bg") ?: "" + Manga( + id = generateUid(absUrl.toRelativeUrl(domain)), + title = title, + altTitles = emptySet(), + url = absUrl.toRelativeUrl(domain), + publicUrl = absUrl, + rating = RATING_UNKNOWN, + contentRating = ContentRating.ADULT, + coverUrl = cover, + tags = emptySet(), + state = null, + authors = emptySet(), + source = source, + ) + }.filterNotNull() + } - return docs.select(".card-body .thumb-item-flow") - .map { - val titleElement = it.selectFirstOrThrow(".thumb_attr.series-title > a") - val absUrl = titleElement.attrAsAbsoluteUrl("href") - Manga( - id = generateUid(absUrl.toRelativeUrl(domain)), - title = titleElement.text(), - altTitles = emptySet(), - url = absUrl.toRelativeUrl(domain), - publicUrl = absUrl, - rating = RATING_UNKNOWN, - contentRating = ContentRating.ADULT, - coverUrl = it.selectFirst("div.img-in-ratio")?.attrAsAbsoluteUrl("data-bg"), - tags = emptySet(), - state = null, - authors = emptySet(), - largeCoverUrl = null, - description = null, - source = MangaParserSource.MANHWA18, - ) - } + @get:Synchronized + private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 10 } override suspend fun getDetails(manga: Manga): Manga { - val docs = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() - val cardInfoElement = docs.selectFirst("div.series-information") - val author = cardInfoElement?.selectFirst(".info-name:contains(Author)")?.parent() - ?.select("a") - ?.joinToString(", ") { it.text() } - ?.nullIfEmpty() - val availableTags = tagsMap.get() - val tags = cardInfoElement?.selectFirst(".info-name:contains(Genre)")?.parent() - ?.select("a") - ?.mapNotNullToSet { availableTags[it.text().lowercase(Locale.ENGLISH)] } - val state = cardInfoElement?.selectFirst(".info-name:contains(Status)")?.parent() - ?.selectFirst("a") - ?.let { - when (it.text().lowercase()) { - "on going" -> MangaState.ONGOING - "completed" -> MangaState.FINISHED - "on hold" -> MangaState.PAUSED - else -> null - } - } + detailsCache[manga.url]?.let { return it } + + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + + val title = doc.selectFirst("h1.au-info-title")?.text()?.trim() ?: manga.title + val coverUrl = extractCoverUrl(doc, manga.coverUrl.orEmpty()) + val description = doc.selectFirst(".au-syn-body.summary-content")?.text()?.trim() + + val statusText = doc.select(".au-stat") + .find { it.selectFirst(".k")?.text()?.equals("Status", ignoreCase = true) == true } + ?.selectFirst(".v")?.text()?.trim() + val state = when (statusText?.lowercase()) { + "ongoing" -> MangaState.ONGOING + "completed" -> MangaState.FINISHED + "on hold" -> MangaState.PAUSED + else -> null + } + + val tags = doc.select(".au-genres a.au-genre").mapNotNull { a -> + val slug = a.attr("href").substringAfter("/genre/").substringBefore("/").ifBlank { null } + ?: return@mapNotNull null + MangaTag(a.text().trim(), slug, source) + }.toSet() + + val chapters = parseChapters(doc) - return manga.copy( - altTitles = setOfNotNull( - cardInfoElement?.selectFirst("b:contains(Other names)")?.parent()?.ownTextOrNull() - ?.removePrefix(": "), - ), - authors = setOfNotNull(author), - description = docs.selectFirst(".series-summary .summary-content")?.html(), - tags = tags.orEmpty(), + val fullManga = manga.copy( + title = title, + coverUrl = coverUrl, + description = description, state = state, - chapters = docs.select(".card-body > .list-chapters > a").mapChapters(reversed = true) { index, element -> - val chapterUrl = element.attrAsAbsoluteUrlOrNull("href")?.toRelativeUrl(domain) - ?: return@mapChapters null - val uploadDate = parseUploadDate(element.selectFirst(".chapter-time")?.text()) - MangaChapter( - id = generateUid(chapterUrl), - title = element.selectFirst(".chapter-name")?.textOrNull(), - number = index + 1f, - volume = 0, - url = chapterUrl, - scanlator = null, - uploadDate = uploadDate, - branch = null, - source = MangaParserSource.MANHWA18, - ) - }, + tags = tags, + authors = emptySet(), + chapters = chapters, ) + + detailsCache[manga.url] = fullManga + return fullManga } - private fun parseUploadDate(timeStr: String?): Long { - timeStr ?: return 0 - val timeWords = timeStr.split(' ') - if (timeWords.size != 3) return 0 - val timeWord = timeWords[1] - val timeAmount = timeWords[0].toIntOrNull() ?: return 0 - val timeUnit = when (timeWord) { - "minute", "minutes" -> Calendar.MINUTE - "hour", "hours" -> Calendar.HOUR - "day", "days" -> Calendar.DAY_OF_YEAR - "week", "weeks" -> Calendar.WEEK_OF_YEAR - "month", "months" -> Calendar.MONTH - "year", "years" -> Calendar.YEAR - else -> return 0 - } - val cal = Calendar.getInstance() - cal.add(timeUnit, -timeAmount) - return cal.time.time + private fun parseChapters(doc: org.jsoup.nodes.Document): List { + return doc.select(".au-chgrid a.au-chtile").mapNotNull { a -> + val chUrl = a.attrAsAbsoluteUrl("href").toRelativeUrl(domain) + val chTitle = a.selectFirst(".au-chtile-num")?.text()?.trim() ?: return@mapNotNull null + val dateStr = a.selectFirst(".au-chtile-date")?.text()?.substringAfter("· ")?.trim() + val uploadDate = dateStr?.let { + runCatching { chapterDateFormat.parse(it)?.time ?: 0L }.getOrDefault(0L) + } ?: 0L + val number = chapterNumberRegex.find(chTitle)?.groupValues?.getOrNull(1)?.toFloatOrNull() + ?: numericRegex.find(chUrl)?.value?.toFloatOrNull() + ?: 0f + MangaChapter( + id = generateUid(chUrl), + title = chTitle, + number = number, + volume = 0, + url = chUrl, + scanlator = null, + uploadDate = uploadDate, + branch = null, + source = source, + ) + }.sortedBy { it.number } } override suspend fun getPages(chapter: MangaChapter): List { - val chapterUrl = chapter.url.toAbsoluteUrl(domain) - val doc = webClient.httpGet(chapterUrl).parseHtml() - return doc.requireElementById("chapter-content").select("img").mapNotNull { - val url = it.attrAsRelativeUrlOrNull("data-src") - ?: it.attrAsRelativeUrlOrNull("src") + val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() + return doc.select("#chapter-content img.lazy").mapNotNull { img -> + val src = img.attrAsAbsoluteUrl("data-src") + .takeIf { it.isNotEmpty() } + ?: img.attrAsAbsoluteUrl("src").takeIf { it.isNotEmpty() } ?: return@mapNotNull null MangaPage( - id = generateUid(url), - url = url, + id = generateUid(src), + url = src, preview = null, - source = MangaParserSource.MANHWA18, - ) - } - } - - private val tagsMap = suspendLazy(initializer = ::parseTags) - - private suspend fun parseTags(): Map { - val doc = webClient.httpGet("https://$domain/tim-kiem?q=").parseHtml() - val list = doc.getElementsByAttribute("data-genre-id") - if (list.isEmpty()) { - return emptyMap() - } - val result = ArrayMap(list.size) - for (item in list) { - val id = item.attr("data-genre-id") - val name = item.text() - result[name.lowercase(Locale.ENGLISH)] = MangaTag( - title = name.toTitleCase(Locale.ENGLISH), - key = id, source = source, ) } - return result } -} \ No newline at end of file +} From 5c661c02bc20b396c706a9bc8696a589ac88bd2a Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:41:07 +0200 Subject: [PATCH 088/109] Add Manhwa18Com.kt parser for hentai site --- .../site/kotatsu/en/hentais/{Manhwa18.kt => Manhwa18Com.kt} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/{Manhwa18.kt => Manhwa18Com.kt} (96%) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18Com.kt similarity index 96% rename from src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18.kt rename to src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18Com.kt index 627304fb..c8ac5f66 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18Com.kt @@ -12,8 +12,8 @@ import org.koitharu.kotatsu.parsers.util.* import java.text.SimpleDateFormat import java.util.* -@MangaSourceParser("MANHWA18", "Manhwa18", "en", type = ContentType.HENTAI) -internal class Manhwa18(context: MangaLoaderContext) : +@MangaSourceParser("MANHWA18", "Manhwa18.com", "en", type = ContentType.HENTAI) +internal class Manhwa18Com(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.MANHWA18, pageSize = 18) { override val configKeyDomain = ConfigKey.Domain("manhwa18.com") From 0a8fcf97f15cf59f36a1f6de6b9c7677bdfe1b8a Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:41:32 +0200 Subject: [PATCH 089/109] Update Manhwa18CC.kt --- .../site/kotatsu/en/hentais/Manhwa18CC.kt | 66 +++++++++++-------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18CC.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18CC.kt index ccebce3e..c2b4e8ae 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18CC.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa18CC.kt @@ -1,5 +1,8 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.en.hentais +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.Headers import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.model.ContentRating @@ -31,6 +34,7 @@ import java.util.Locale @MangaSourceParser("MANHWA18CC", "Manhwa18.cc", "en", ContentType.HENTAI) internal class Manhwa18CC(context: MangaLoaderContext) : MadaraParser(context, MangaParserSource.MANHWA18CC, "manhwa18.cc", 24) { + override val datePattern = "dd MMM yyyy" override val sourceLocale: Locale = Locale.ENGLISH override val listUrl = "webtoons/" @@ -46,6 +50,41 @@ internal class Manhwa18CC(context: MangaLoaderContext) : searchPaginator.firstPage = 1 } + override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder() + .add("Referer", "https://$domain/") + .build() + + @Volatile + private var tagsCache: Set? = null + private val tagsMutex = Mutex() + + override suspend fun fetchAvailableTags(): Set { + tagsCache?.let { return it } + return tagsMutex.withLock { + tagsCache ?: fetchTagsFromSite().also { tagsCache = it } + } + } + + private suspend fun fetchTagsFromSite(): Set { + val doc = webClient.httpGet("https://$domain/$listUrl").parseHtml() + val list = doc.body().selectFirstOrThrow("div.sub-menu").select("ul li") + val keySet = HashSet(list.size) + return list.mapNotNullToSet { li -> + val a = li.selectFirst("a") ?: return@mapNotNullToSet null + val href = a.attr("href").removeSuffix("/").substringAfterLast(tagPrefix, "") + if (href.isEmpty() || !keySet.add(href)) { + return@mapNotNullToSet null + } + MangaTag( + key = href, + title = a.ownText().ifEmpty { + a.selectFirst(".menu-image-title")?.text() ?: return@mapNotNullToSet null + }.toTitleCase(), + source = source, + ) + } + } + override suspend fun getFilterOptions() = super.getFilterOptions().copy( availableStates = emptySet(), availableContentRating = emptySet(), @@ -58,16 +97,13 @@ internal class Manhwa18CC(context: MangaLoaderContext) : append(domain) when { - !query.isNullOrEmpty() -> { append("/search?q=") append(query.urlEncoded()) append("&page=") append(page.toString()) } - else -> { - val tag = filter.tags.oneOrThrowIfMany() if (filter.tags.isNotEmpty()) { append("/$tagPrefix") @@ -75,11 +111,9 @@ internal class Manhwa18CC(context: MangaLoaderContext) : } else { append("/$listUrl") } - if (page > 1) { append(page.toString()) } - append("?orderby=") when (order) { SortOrder.POPULARITY -> append("trending") @@ -111,26 +145,6 @@ internal class Manhwa18CC(context: MangaLoaderContext) : } } - override suspend fun fetchAvailableTags(): Set { - val doc = webClient.httpGet("https://$domain/$listUrl").parseHtml() - val list = doc.body().selectFirstOrThrow("div.sub-menu").select("ul li") - val keySet = HashSet(list.size) - return list.mapNotNullToSet { li -> - val a = li.selectFirst("a") ?: return@mapNotNullToSet null - val href = a.attr("href").removeSuffix("/").substringAfterLast(tagPrefix, "") - if (href.isEmpty() || !keySet.add(href)) { - return@mapNotNullToSet null - } - MangaTag( - key = href, - title = a.ownText().ifEmpty { - a.selectFirst(".menu-image-title")?.text() ?: return@mapNotNullToSet null - }.toTitleCase(), - source = source, - ) - } - } - override suspend fun getPages(chapter: MangaChapter): List { val fullUrl = chapter.url.toAbsoluteUrl(domain) val doc = webClient.httpGet(fullUrl).parseHtml() @@ -145,4 +159,4 @@ internal class Manhwa18CC(context: MangaLoaderContext) : ) } } -} \ No newline at end of file +} From fc85d0f8f40d96310aba542baf192919d35b4a94 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:49:41 +0200 Subject: [PATCH 090/109] Update Manhwa210.kt --- .../site/kotatsu/en/hentais/Manhwa210.kt | 194 +++++++++--------- 1 file changed, 100 insertions(+), 94 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa210.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa210.kt index e227c8af..89aaa5e3 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa210.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manhwa210.kt @@ -1,5 +1,8 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.en.hentais +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.Headers import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.config.ConfigKey @@ -20,7 +23,6 @@ import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.attrAsRelativeUrl import org.koitharu.kotatsu.parsers.util.attrOrNull import org.koitharu.kotatsu.parsers.util.generateUid -import org.koitharu.kotatsu.parsers.util.mapChapters import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.parseFailed import org.koitharu.kotatsu.parsers.util.parseHtml @@ -30,11 +32,13 @@ import org.koitharu.kotatsu.parsers.util.selectLast import org.koitharu.kotatsu.parsers.util.textOrNull import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl import org.koitharu.kotatsu.parsers.util.urlEncoded -import java.util.Calendar +import java.text.SimpleDateFormat import java.util.EnumSet +import java.util.Locale @MangaSourceParser("MANHWA210", "Manhwa210", "en", type = ContentType.HENTAI) -internal class Manhwa210(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.MANHWA210, 60) { +internal class Manhwa210(context: MangaLoaderContext) : + PagedMangaParser(context, MangaParserSource.MANHWA210, 60) { override val configKeyDomain = ConfigKey.Domain("manhwa210.com") @@ -43,6 +47,10 @@ internal class Manhwa210(context: MangaLoaderContext) : PagedMangaParser(context keys.add(userAgentKey) } + override fun getRequestHeaders(): Headers = super.getRequestHeaders().newBuilder() + .add("Referer", "https://$domain/") + .build() + override val availableSortOrders: Set = EnumSet.of( SortOrder.ALPHABETICAL, SortOrder.ALPHABETICAL_DESC, @@ -56,28 +64,112 @@ internal class Manhwa210(context: MangaLoaderContext) : PagedMangaParser(context isSearchSupported = true, ) + @Volatile + private var tagsCache: Set? = null + private val tagsMutex = Mutex() + override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = availableTags(), + availableTags = getOrFetchTags(), availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), ) + private suspend fun getOrFetchTags(): Set { + tagsCache?.let { return it } + return tagsMutex.withLock { + tagsCache ?: fetchTagsFromSite().also { tagsCache = it } + } + } + + private suspend fun fetchTagsFromSite(): Set { + val doc = webClient.httpGet("https://$domain").parseHtml() + return doc.select("ul.grid.grid-cols-2 a").mapToSet { a -> + MangaTag( + key = a.attr("href").removeSuffix('/').substringAfterLast('/'), + title = a.text(), + source = source, + ) + } + } + + @get:Synchronized + private val detailsCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 10 + } + + private val chapterDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH) + + override suspend fun getDetails(manga: Manga): Manga { + detailsCache[manga.url]?.let { return it } + + val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + val author = root.selectFirst("div.mt-2:contains(Artist) span a")?.textOrNull() + + val details = manga.copy( + altTitles = setOfNotNull(root.selectLast("div.grow div:contains(Alt name) span")?.textOrNull()), + state = when (root.selectFirst("div.mt-2:contains(Status) span.text-blue-500")?.text()) { + "Ongoing" -> MangaState.ONGOING + "Completed" -> MangaState.FINISHED + else -> null + }, + tags = root.select("div.mt-2:contains(Genres) a.bg-gray-500").mapToSet { a -> + MangaTag( + key = a.attr("href").removeSuffix('/').substringAfterLast('/'), + title = a.text(), + source = source, + ) + }, + authors = setOfNotNull(author), + description = root.selectFirst("meta[name=description]")?.attrOrNull("content"), + chapters = root.select("div.justify-between ul.overflow-y-auto.overflow-x-hidden a") + .mapNotNull { a -> + val href = a.attrAsRelativeUrl("href") + val name = a.selectFirst("span.text-ellipsis")?.text().orEmpty() + val dateText = a.parent()?.selectFirst("span.timeago")?.attr("datetime").orEmpty() + val number = extractChapterNumber(name) + MangaChapter( + id = generateUid(href), + title = name, + number = number, + volume = 0, + url = href, + scanlator = null, + uploadDate = runCatching { chapterDateFormat.parse(dateText)?.time ?: 0L }.getOrDefault(0L), + branch = null, + source = source, + ) + } + .sortedBy { it.number } + ) + + detailsCache[manga.url] = details + return details + } + + private fun extractChapterNumber(title: String): Float { + val regex = Regex("""(?i)(?:chapter|ch\.?)\s*(\d+(?:\.\d+)?)""") + regex.find(title)?.let { match -> + return match.groupValues[1].toFloatOrNull() ?: 0f + } + Regex("""(\d+(?:\.\d+)?)""").find(title)?.let { + return it.value.toFloatOrNull() ?: 0f + } + return 0f + } + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { val url = buildString { append("https://") append(domain) val query = filter.query when { - !query.isNullOrEmpty() -> { append("/search") append("?filter[name]=") append(query.urlEncoded()) - if (page > 1) { append("&page=") append(page) } - append("&sort=") append( when (order) { @@ -90,16 +182,13 @@ internal class Manhwa210(context: MangaLoaderContext) : PagedMangaParser(context }, ) } - filter.tags.isNotEmpty() -> { val tag = filter.tags.first() append("/genre/") append(tag.key) - append("?page=") append(page) } - else -> { append("/list") append("?sort=") @@ -117,19 +206,6 @@ internal class Manhwa210(context: MangaLoaderContext) : PagedMangaParser(context append(page) } } - - if (filter.query.isNullOrEmpty()) { - append("&sort=") - when (order) { - SortOrder.POPULARITY -> append("-views") - SortOrder.UPDATED -> append("-updated_at") - SortOrder.NEWEST -> append("-created_at") - SortOrder.ALPHABETICAL -> append("name") - SortOrder.ALPHABETICAL_DESC -> append("-name") - else -> append("-updated_at") - } - } - if (filter.states.isNotEmpty()) { append("&filter[status]=") filter.states.forEach { @@ -145,13 +221,11 @@ internal class Manhwa210(context: MangaLoaderContext) : PagedMangaParser(context } val doc = webClient.httpGet(url).parseHtml() - return doc.select("div.grid div.relative").map { div -> val href = div.selectFirst("a[href^=/manga/]")?.attrOrNull("href") ?: div.parseFailed("Cant find manga image!") val coverUrl = div.selectFirst("div.cover")?.attr("style") ?.substringAfter("url('")?.substringBefore("')") - Manga( id = generateUid(href), title = div.select("div.p-2 a.text-ellipsis").text(), @@ -169,46 +243,6 @@ internal class Manhwa210(context: MangaLoaderContext) : PagedMangaParser(context } } - override suspend fun getDetails(manga: Manga): Manga { - val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() - val author = root.selectFirst("div.mt-2:contains(Artist) span a")?.textOrNull() - - return manga.copy( - altTitles = setOfNotNull(root.selectLast("div.grow div:contains(Alt name) span")?.textOrNull()), - state = when (root.selectFirst("div.mt-2:contains(Status) span.text-blue-500")?.text()) { - "Ongoing" -> MangaState.ONGOING - "Completed" -> MangaState.FINISHED - else -> null - }, - tags = root.select("div.mt-2:contains(Genres) a.bg-gray-500").mapToSet { a -> - MangaTag( - key = a.attr("href").removeSuffix('/').substringAfterLast('/'), - title = a.text(), - source = source, - ) - }, - authors = setOfNotNull(author), - description = root.selectFirst("meta[name=description]")?.attrOrNull("content"), - chapters = root.select("div.justify-between ul.overflow-y-auto.overflow-x-hidden a") - .mapChapters(reversed = true) { i, a -> - val href = a.attrAsRelativeUrl("href") - val name = a.selectFirst("span.text-ellipsis")?.text().orEmpty() - val dateText = a.parent()?.selectFirst("span.timeago")?.attr("datetime").orEmpty() - MangaChapter( - id = generateUid(href), - title = name, - number = i.toFloat(), - volume = 0, - url = href, - scanlator = null, - uploadDate = parseDateTime(dateText), - branch = null, - source = source, - ) - }, - ) - } - override suspend fun getPages(chapter: MangaChapter): List { val fullUrl = chapter.url.toAbsoluteUrl(domain) val doc = webClient.httpGet(fullUrl).parseHtml() @@ -222,32 +256,4 @@ internal class Manhwa210(context: MangaLoaderContext) : PagedMangaParser(context ) } } - - private fun parseDateTime(dateStr: String): Long = runCatching { - val parts = dateStr.split(' ') - val dateParts = parts[0].split('-') - val timeParts = parts[1].split(':') - - val calendar = Calendar.getInstance() - calendar.set( - dateParts[0].toInt(), - dateParts[1].toInt() - 1, - dateParts[2].toInt(), - timeParts[0].toInt(), - timeParts[1].toInt(), - timeParts[2].toInt(), - ) - calendar.timeInMillis - }.getOrDefault(0L) - - private suspend fun availableTags(): Set { - val doc = webClient.httpGet("https://$domain").parseHtml() - return doc.select("ul.grid.grid-cols-2 a").mapToSet { a -> - MangaTag( - key = a.attr("href").removeSuffix('/').substringAfterLast('/'), - title = a.text(), - source = source, - ) - } - } -} \ No newline at end of file +} From dee8c341f68169e35fced2c29ae6543e56c38454 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:13:47 +0200 Subject: [PATCH 091/109] site dead --- .../site/kotatsu/en/hentais/Hentalk.kt | 335 ------------------ 1 file changed, 335 deletions(-) delete mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Hentalk.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Hentalk.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Hentalk.kt deleted file mode 100644 index 5d10fbc2..00000000 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Hentalk.kt +++ /dev/null @@ -1,335 +0,0 @@ -package org.koitharu.kotatsu.parsers.site.kotatsu.en.hentais - -import androidx.collection.ArraySet -import androidx.collection.MutableIntList -import androidx.collection.MutableIntObjectMap -import org.json.JSONObject -import org.jsoup.HttpStatusException -import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.MangaSourceParser -import org.koitharu.kotatsu.parsers.config.ConfigKey -import org.koitharu.kotatsu.parsers.core.PagedMangaParser -import org.koitharu.kotatsu.parsers.exception.ParseException -import org.koitharu.kotatsu.parsers.model.* -import org.koitharu.kotatsu.parsers.network.UserAgents -import org.koitharu.kotatsu.parsers.util.* -import java.net.HttpURLConnection -import java.text.SimpleDateFormat -import java.util.* - -private const val SERVER_DATA_SAVER = "?type=" -private const val SERVER_DATA = "" - -@MangaSourceParser("HENTALK", "Hentalk", "en", type = ContentType.HENTAI) -internal class Hentalk(context: MangaLoaderContext) : - PagedMangaParser(context, MangaParserSource.HENTALK, 24) { - - override val configKeyDomain = ConfigKey.Domain("hentalk.pw", "fakku.cc") - override val userAgentKey = ConfigKey.UserAgent(UserAgents.KOTATSU) - - private val preferredServerKey = ConfigKey.PreferredImageServer( - presetValues = mapOf( - SERVER_DATA to "Original quality", - SERVER_DATA_SAVER to "Compressed quality", - ), - defaultValue = SERVER_DATA, - ) - - override fun onCreateConfig(keys: MutableCollection>) { - super.onCreateConfig(keys) - keys.add(userAgentKey) - keys.add(preferredServerKey) - } - - override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.NEWEST, - SortOrder.NEWEST_ASC, - SortOrder.ALPHABETICAL, - SortOrder.ALPHABETICAL_DESC, - ) - - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isSearchSupported = true, - isMultipleTagsSupported = true, - isSearchWithFiltersSupported = true, - isAuthorSearchSupported = true, - ) - - override suspend fun getFilterOptions() = MangaListFilterOptions() // not found any URLs for it - - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val url = buildString { - append("https://") - append(domain) - append("/__data.json?x-sveltekit-trailing-slash=1&x-sveltekit-invalidated=001") - - when { - !filter.query.isNullOrEmpty() || filter.tags.isNotEmpty() || !filter.author.isNullOrEmpty() -> { - append("&q=") - - if (!filter.author.isNullOrEmpty()) { - val author = filter.author - author?.let { append("artist:\"${space2plus(it)}\"") } - append('+') - } - - if (filter.tags.isNotEmpty()) { - filter.tags.forEach { tag -> - append("tag:\"${space2plus(tag.key)}\"") - append('+') - } - } - - if (!filter.query.isNullOrEmpty()) { - val query = filter.query - query?.let { append(space2plus(it)) } - } else { - append('+') - } - } - } - - when (order) { - SortOrder.UPDATED -> append("&sort=released_at") - SortOrder.NEWEST_ASC -> append("&sort=created_at&order=asc") - SortOrder.NEWEST -> append("&sort=created_at&order=desc") - SortOrder.ALPHABETICAL -> append("&sort=title&order=asc") - SortOrder.ALPHABETICAL_DESC -> append("&sort=title&order=desc") - else -> {} - } - - if (page > 1) { - append("&page=") - append(page) - } - } - - val json = try { - webClient.httpGet(url).parseJson() - } catch (e: HttpStatusException) { - if (e.statusCode == HttpURLConnection.HTTP_INTERNAL_ERROR) { - return emptyList() - } else { - throw ParseException("Can't get data from source!", url) - } - } - - val dataArray = json.getJSONArray("nodes") - .optJSONObject(2) - ?.optJSONArray("data") - ?: return emptyList() - - val dataValues = MutableIntObjectMap(dataArray.length()) - for (i in 0 until dataArray.length()) { - dataValues[i] = dataArray.get(i) - } - - val archiveH = MutableIntList(dataArray.length()) - for (i in 0 until dataArray.length()) { - val item = dataArray.opt(i) - if (item is JSONObject && item.has("id") && item.has("hash") && - item.has("title") && item.has("thumbnail") && item.has("tags") - ) { - archiveH.add(i) - } - } - - val mangaList = ArrayList() - archiveH.forEach { tempIndex -> - val temp = dataArray.getJSONObject(tempIndex) - val idRef = temp.getInt("id") - val hashRef = temp.getInt("hash") - val titleRef = temp.getInt("title") - val thumbnailRef = temp.getInt("thumbnail") - val tagsRef = temp.getInt("tags") - - val mangaId = dataArray.getLong(idRef) - - val key = dataArray.getString(hashRef) - val title = dataArray.getString(titleRef) - val idThumbnail = dataArray.getInt(thumbnailRef) - - val tagsList = dataArray.optJSONArray(tagsRef) - val tags = ArraySet() - var author: String? = null - - if (tagsList != null) { - var i = 0 - while (i < tagsList.length()) { - val tagRefIndex = tagsList.getInt(i) - - if (dataValues.containsKey(tagRefIndex) && - dataValues[tagRefIndex] is JSONObject && - (dataValues[tagRefIndex] as JSONObject).has("namespace") - ) { - - val nsObj = dataValues[tagRefIndex] as JSONObject - val nsIndex = nsObj.getInt("namespace") - val nameIndex = nsObj.getInt("name") - - val nsValue = if (dataValues.containsKey(nsIndex)) dataValues[nsIndex].toString() else null - val nameValue = - if (dataValues.containsKey(nameIndex)) dataValues[nameIndex].toString() else null - - if (nsValue == "artist") { - author = nameValue?.nullIfEmpty() - } else if (nsValue == "tag" && nameValue != null) { - tags.add( - MangaTag( - key = nameValue, - title = nameValue, - source = source, - ), - ) - } - } - i++ - } - } - - mangaList.add( - Manga( - id = generateUid(mangaId), - url = "/g/$mangaId/__data.json?x-sveltekit-invalidated=001", - publicUrl = "https://$domain/g/$mangaId", - title = title, - altTitles = emptySet(), - coverUrl = "https://$domain/image/$key/$idThumbnail?type=cover", - largeCoverUrl = null, - authors = setOfNotNull(author), - tags = tags, - state = null, - description = null, - contentRating = ContentRating.ADULT, - source = source, - rating = RATING_UNKNOWN, - ), - ) - } - - return mangaList - } - - override suspend fun getDetails(manga: Manga): Manga { - val json = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseJson() - val mangaId = manga.url.substringAfter("/g/").substringBefore('/') - - val dataArray = json.getJSONArray("nodes") - .optJSONObject(2) - ?.optJSONArray("data") - ?: return manga - - var createdAt = "" - - for (i in 0 until dataArray.length()) { - val item = dataArray.opt(i) - if (item is JSONObject && item.has("createdAt")) { - val addedAt = item.getInt("createdAt") - if (dataArray.length() > addedAt) { - createdAt = dataArray.optString(addedAt, "") - break - } - } - } - - val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) - val parseTime = dateFormat.parseSafe(createdAt) - val chapter = MangaChapter( - id = generateUid("/g/$mangaId/read/1"), - url = "/g/$mangaId/read/1/__data.json?x-sveltekit-invalidated=011", - title = "Oneshot", // for all, just has 1 chapter - number = 0f, - uploadDate = parseTime, - volume = 0, - branch = null, - scanlator = null, - source = source, - ) - - return manga.copy( - chapters = listOf(chapter), - ) - } - - override suspend fun getPages(chapter: MangaChapter): List { - val json = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseJson() - val dataArray = json.getJSONArray("nodes") - .optJSONObject(2) - ?.optJSONArray("data") - ?: return emptyList() - - var compressID = "" - for (i in 0 until dataArray.length()) { - val item = dataArray.opt(i) - if (item is JSONObject && item.has("hash")) { - if (i < 20) { - val hashValue = dataArray.getString(item.getInt("hash")) - if (hashValue.length == 8) { - compressID = hashValue - break - } - } - } - } - - var hashID = "" - for (i in 0 until dataArray.length()) { - val item = dataArray.opt(i) - if (item is JSONObject && item.has("hash") && item.has("id")) { - val hashIndex = item.getInt("hash") - hashID = dataArray.getString(hashIndex) - break - } - } - - if (hashID.isEmpty()) { - for (i in 0 until dataArray.length()) { - val item = dataArray.opt(i) - if (item is JSONObject && item.has("gallery")) { - val galleryIndex = item.getInt("gallery") - val galleryTemplate = dataArray.optJSONObject(galleryIndex) - if (galleryTemplate != null && galleryTemplate.has("hash")) { - val hashIndex = galleryTemplate.getInt("hash") - hashID = dataArray.getString(hashIndex) - break - } - } - } - } - - val imgList = ArrayList(dataArray.length()) - for (i in 0 until dataArray.length()) { - val item = dataArray.opt(i) - if (item is JSONObject && item.has("filename")) { - val filenameIndex = item.getInt("filename") - if (dataArray.length() > filenameIndex) { - val filename = dataArray.optString(filenameIndex, "") - if (filename.isNotEmpty()) { - imgList.add(filename) - } - } - } - } - - val server = config[preferredServerKey] ?: SERVER_DATA - return imgList.map { imgEx -> - val baseUrl = "https://$domain/image/$hashID/$imgEx" - val imageUrl = when (server) { - SERVER_DATA -> baseUrl - SERVER_DATA_SAVER -> baseUrl + SERVER_DATA_SAVER + compressID - else -> baseUrl - } - - MangaPage( - id = generateUid(imageUrl), - url = imageUrl, - preview = null, - source = source, - ) - } - } - - private fun space2plus(input: String): String = input.replace(' ', '+') -} \ No newline at end of file From 4fc1a42ccb8d691f06f4f5f32092f2cb84bde5f3 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:31:05 +0200 Subject: [PATCH 092/109] Update MangaFox.kt --- .../parsers/site/kotatsu/en/MangaFox.kt | 263 ++++++++---------- 1 file changed, 121 insertions(+), 142 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaFox.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaFox.kt index f86ca381..8af146a5 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaFox.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaFox.kt @@ -1,60 +1,89 @@ package org.koitharu.kotatsu.parsers.site.kotatsu.en +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.jsoup.nodes.Document import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.network.OkHttpWebClient import org.koitharu.kotatsu.parsers.util.* import java.text.SimpleDateFormat import java.util.* @MangaSourceParser("MANGAFOX", "MangaFox", "en") -internal class MangaFox(context: MangaLoaderContext): +internal class MangaFox(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.MANGAFOX, pageSize = 24) { private val baseUrl = "fanfox.net" private val mobileUrl = "https://m.fanfox.net" private val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.ENGLISH) - override val configKeyDomain: ConfigKey.Domain = - ConfigKey.Domain(baseUrl) + override val configKeyDomain = ConfigKey.Domain(baseUrl) + + private val apiClient: OkHttpWebClient by lazy { + val httpClient = context.httpClient.newBuilder() + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .header("Referer", "https://$baseUrl/") + .build() + chain.proceed(request) + } + .build() + OkHttpWebClient(httpClient, source) + } - override val availableSortOrders: Set = - EnumSet.of(SortOrder.POPULARITY, SortOrder.UPDATED) + override val availableSortOrders: Set = EnumSet.of(SortOrder.POPULARITY, SortOrder.UPDATED) - override val filterCapabilities = - MangaListFilterCapabilities(isSearchSupported = true) + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true + ) - override suspend fun getListPage( - page: Int, - order: SortOrder, - filter: MangaListFilter, - ): List { + @Volatile + private var tagsCache: Set? = null + private val tagsMutex = Mutex() - val url = buildUrl(page, filter, order) - val doc = webClient.httpGet(url).parseHtml() + override suspend fun getFilterOptions(): MangaListFilterOptions { + return MangaListFilterOptions( + availableTags = getOrFetchTags() + ) + } + private suspend fun getOrFetchTags(): Set { + tagsCache?.let { return it } + return tagsMutex.withLock { + tagsCache ?: fetchTags().also { tagsCache = it } + } + } + + private fun fetchTags(): Set { + return listOf( + "action", "adventure", "comedy", "drama", "fantasy", + "harem", "romance", "school-life", "shounen", "shoujo", + "seinen", "sci-fi", "slice-of-life", "supernatural", + "ecchi", "mystery", "psychological", "historical", + "martial-arts", "mecha", "sports" + ).map { + MangaTag(key = it, title = it.replace("-", " "), source = source) + }.toSet() + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val url = buildUrl(page, filter, order) + val doc = apiClient.httpGet(url).parseHtml() return parseMangaList(doc) } private fun buildUrl(page: Int, filter: MangaListFilter, order: SortOrder): String { - val query = filter.query?.trim().orEmpty() val tag = filter.tags.firstOrNull()?.key - return when { - - // TAG SEARCH (Fanfox mobile endpoint) !tag.isNullOrBlank() && query.isEmpty() -> "https://m.fanfox.net/search/cate/$tag" - - // TEXT SEARCH query.isNotEmpty() -> "https://m.fanfox.net/search?k=${query.urlEncoded()}" - - // DEFAULT DIRECTORY else -> buildString { append("https://$baseUrl/directory/") @@ -65,71 +94,77 @@ internal class MangaFox(context: MangaLoaderContext): } private fun parseMangaList(doc: Document): List { - - val nodes = when { - doc.select("ul.manga-list-1-list li").isNotEmpty() -> - doc.select("ul.manga-list-1-list li") - - doc.select("ul.manga-list-4-list li").isNotEmpty() -> - doc.select("ul.manga-list-4-list li") - - doc.select("ul.manga-list-2-list li").isNotEmpty() -> - doc.select("ul.manga-list-2-list li") - - else -> - doc.select("li") + val searchItems = doc.select(".post-list li") + if (searchItems.isNotEmpty()) { + return searchItems.mapNotNull { el -> + val a = el.selectFirst("a") ?: return@mapNotNull null + val href = a.attrAsRelativeUrl("href") + Manga( + id = generateUid(href), + title = a.attr("title").ifBlank { a.selectFirst(".cover-info .title")?.text() ?: a.text() }, + url = href, + publicUrl = a.absUrl("href"), + coverUrl = el.selectFirst("img")?.attr("abs:src").orEmpty(), + rating = RATING_UNKNOWN, + contentRating = null, + tags = emptySet(), + authors = emptySet(), + state = null, + altTitles = emptySet(), + largeCoverUrl = null, + description = null, + source = source + ) + } } - return nodes.mapNotNull { el -> - val a = el.selectFirst("a") ?: return@mapNotNull null - val href = a.attrAsRelativeUrl("href") - - Manga( - id = generateUid(href), - title = a.attr("title").ifBlank { a.text() }, - url = href, - publicUrl = a.absUrl("href"), - coverUrl = el.selectFirst("img")?.attr("abs:src"), - - rating = RATING_UNKNOWN, - contentRating = null, - - tags = emptySet(), - authors = emptySet(), - - state = null, - - altTitles = emptySet(), - largeCoverUrl = null, - description = null, - - source = source - ) + val dirSelectors = listOf( + "ul.manga-list-1-list li", + "ul.manga-list-4-list li", + "ul.manga-list-2-list li" + ) + for (sel in dirSelectors) { + val items = doc.select(sel) + if (items.isNotEmpty()) { + return items.mapNotNull { el -> + val a = el.selectFirst("a") ?: return@mapNotNull null + val href = a.attrAsRelativeUrl("href") + Manga( + id = generateUid(href), + title = a.attr("title").ifBlank { a.text() }, + url = href, + publicUrl = a.absUrl("href"), + coverUrl = el.selectFirst("img")?.attr("abs:src").orEmpty(), + rating = RATING_UNKNOWN, + contentRating = null, + tags = emptySet(), + authors = emptySet(), + state = null, + altTitles = emptySet(), + largeCoverUrl = null, + description = null, + source = source + ) + } + } } + return emptyList() } - override suspend fun getDetails(manga: Manga): Manga { - val doc = webClient.httpGet(manga.url.toAbsoluteUrl(baseUrl)).parseHtml() + val doc = apiClient.httpGet(manga.url.toAbsoluteUrl(baseUrl)).parseHtml() val info = doc.selectFirst(".detail-info-right")!! val chapters = doc.select("ul.detail-main-list li").mapIndexedNotNull { i, el -> - val a = el.selectFirst("a") ?: return@mapIndexedNotNull null val href = a.attrAsRelativeUrl("href") - - val name = a.selectFirst("p")?.text() - ?: a.ownText() - + val name = a.selectFirst("p")?.text() ?: a.ownText() val rawDate = el.text() .let { text -> Regex("""(Today|Yesterday|\w{3}\s\d{1,2},\s\d{4}|\d{4}-\d{2}-\d{2})""") - .find(text) - ?.value + .find(text)?.value } - val parsedDate = parseChapterDate(rawDate) - MangaChapter( id = generateUid(href), title = name, @@ -146,35 +181,21 @@ internal class MangaFox(context: MangaLoaderContext): return manga.copy( title = doc.selectFirst("h1")?.text() ?: manga.title, - - authors = info.select(".detail-info-right-say a") - .mapToSet { it.text() }, - - tags = info.select(".detail-info-right-tag-list a") - .mapToSet { - MangaTag( - key = it.text().lowercase(), - title = it.text(), - source = source - ) - }, - + authors = info.select(".detail-info-right-say a").mapToSet { it.text() }, + tags = info.select(".detail-info-right-tag-list a").mapToSet { + MangaTag(key = it.text().lowercase(), title = it.text(), source = source) + }, description = info.selectFirst("p.fullcontent")?.text(), - state = parseStatus(info.selectFirst(".detail-info-right-title-tip")?.text().orEmpty()), - - coverUrl = doc.selectFirst(".detail-info-cover-img")?.attr("abs:src"), - + coverUrl = doc.selectFirst(".detail-info-cover-img")?.attr("abs:src").orEmpty(), chapters = chapters ) } - private fun parseStatus(status: String): MangaState? { - return when { - status.contains("Ongoing", true) -> MangaState.ONGOING - status.contains("Completed", true) -> MangaState.FINISHED - else -> null - } + private fun parseStatus(status: String): MangaState? = when { + status.contains("Ongoing", true) -> MangaState.ONGOING + status.contains("Completed", true) -> MangaState.FINISHED + else -> null } private fun extractChapterNumber(name: String): Float { @@ -188,70 +209,28 @@ internal class MangaFox(context: MangaLoaderContext): private fun parseChapterDate(date: String?): Long { if (date.isNullOrBlank()) return 0L - val clean = date.trim() - return when { "Today" in clean -> Calendar.getInstance().timeInMillis - - "Yesterday" in clean -> Calendar.getInstance().apply { - add(Calendar.DATE, -1) - }.timeInMillis - + "Yesterday" in clean -> Calendar.getInstance().apply { add(Calendar.DATE, -1) }.timeInMillis clean.matches(Regex("""\d{4}-\d{2}-\d{2}""")) -> runCatching { val parts = clean.split("-") - Calendar.getInstance().apply { - set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt()) - }.timeInMillis - }.getOrDefault(0L) - - else -> runCatching { - dateFormat.parse(clean)?.time ?: 0L + Calendar.getInstance().apply { set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt()) }.timeInMillis }.getOrDefault(0L) + else -> runCatching { dateFormat.parse(clean)?.time ?: 0L }.getOrDefault(0L) } } - override suspend fun getFilterOptions(): MangaListFilterOptions { - return MangaListFilterOptions( - availableTags = getTags() - ) - } - - private fun getTags(): Set { - return listOf( - "action", "adventure", "comedy", "drama", "fantasy", - "harem", "romance", "school-life", "shounen", "shoujo", - "seinen", "sci-fi", "slice-of-life", "supernatural", - "ecchi", "mystery", "psychological", "historical", - "martial-arts", "mecha", "sports" - ).map { - MangaTag( - key = it, - title = it.replace("-", " "), - source = source - ) - }.toSet() - } - - override suspend fun getRelatedManga(seed: Manga): List = emptyList() - override suspend fun getPages(chapter: MangaChapter): List { val mobilePath = chapter.url.replace("/manga/", "/roll_manga/") val url = "$mobileUrl$mobilePath" - - val doc = webClient.httpGet(url).parseHtml() - + val doc = apiClient.httpGet(url).parseHtml() return doc.select("#viewer img").map { val imgUrl = it.attr("abs:data-original") - - MangaPage( - id = generateUid(imgUrl), - url = imgUrl, - preview = null, - source = source - ) + MangaPage(id = generateUid(imgUrl), url = imgUrl, preview = null, source = source) } } override suspend fun getPageUrl(page: MangaPage): String = page.url + override suspend fun getRelatedManga(seed: Manga): List = emptyList() } From 9372a3cd2f29b95ce386ed6bc02ed6d379ffb7d6 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:31:53 +0200 Subject: [PATCH 093/109] Update and rename src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaBat.kt to src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/mangabox/MangaBats.kt --- .../site/kotatsu/en/{MangaBat.kt => mangabox/MangaBats.kt} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/{MangaBat.kt => mangabox/MangaBats.kt} (95%) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaBat.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/mangabox/MangaBats.kt similarity index 95% rename from src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaBat.kt rename to src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/mangabox/MangaBats.kt index 67449927..0236ac51 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/MangaBat.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/mangabox/MangaBats.kt @@ -1,6 +1,6 @@ @file:Suppress("DEPRECATION") -package org.koitharu.kotatsu.parsers.site.kotatsu.en +package org.koitharu.kotatsu.parsers.site.kotatsu.en.mangabox import okhttp3.Interceptor import okhttp3.Response @@ -42,8 +42,8 @@ import kotlinx.coroutines.coroutineScope import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl import org.koitharu.kotatsu.parsers.util.mapToSet -@MangaSourceParser("MANGABAT", "MangaBat", "en") -internal class Mangabat(context: MangaLoaderContext) : +@MangaSourceParser("MANGABAT", "MangaBats", "en") +internal class Mangabats(context: MangaLoaderContext) : MangaboxParser(context, MangaParserSource.MANGABAT) { override val configKeyDomain = ConfigKey.Domain("mangabats.com") From db4510266aee721a76d68f37621020fc2c7a01e5 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:32:09 +0200 Subject: [PATCH 094/109] Create ManganatoGG --- .../site/kotatsu/en/mangabox/ManganatoGG | 392 ++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/mangabox/ManganatoGG diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/mangabox/ManganatoGG b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/mangabox/ManganatoGG new file mode 100644 index 00000000..186cd712 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/mangabox/ManganatoGG @@ -0,0 +1,392 @@ +@file:Suppress("DEPRECATION") + +package org.koitharu.kotatsu.parsers.site.kotatsu.en.mangabox + +import okhttp3.Interceptor +import okhttp3.Response +import org.json.JSONObject +import org.jsoup.nodes.Document +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery +import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities +import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Include +import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.Match +import org.koitharu.kotatsu.parsers.model.search.SearchCapability +import org.koitharu.kotatsu.parsers.model.search.SearchableField.STATE +import org.koitharu.kotatsu.parsers.model.search.SearchableField.TAG +import org.koitharu.kotatsu.parsers.model.search.SearchableField.TITLE_NAME +import org.koitharu.kotatsu.parsers.parsers.MangaboxParser +import org.koitharu.kotatsu.parsers.util.generateUid +import org.koitharu.kotatsu.parsers.util.nullIfEmpty +import org.koitharu.kotatsu.parsers.util.parseHtml +import org.koitharu.kotatsu.parsers.util.parseJson +import org.koitharu.kotatsu.parsers.util.parseSafe +import org.koitharu.kotatsu.parsers.util.toTitleCase +import java.text.SimpleDateFormat +import java.util.EnumSet +import java.util.LinkedHashSet +import java.util.Locale +import java.util.TimeZone +import org.koitharu.kotatsu.parsers.network.OkHttpWebClient +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl +import org.koitharu.kotatsu.parsers.util.mapToSet + +@MangaSourceParser("MANGANATOGG", "Manganato.gg", "en") +internal class ManganatoGG(context: MangaLoaderContext) : + MangaboxParser(context, MangaParserSource.MANGANATOGG) { + + override val configKeyDomain = ConfigKey.Domain("manganato.gg") + + override val webClient = OkHttpWebClient( + context.httpClient.newBuilder() + .build(), + source, + ) + + + override val listUrl = "/genre/all" + override val searchUrl = "/search/story" + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.NEWEST, + ) + override suspend fun getDetails(manga: Manga): Manga = coroutineScope { + val fullUrl = manga.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl).parseHtml() + + val chaptersDeferred = async { getChapters(doc) } + + val stateText = doc.findInfo("Status")?.lowercase() + + val state = when { + stateText?.contains("ongoing") == true -> MangaState.ONGOING + stateText?.contains("completed") == true || + stateText?.contains("finish") == true -> MangaState.FINISHED + else -> null + } + + val alt = doc.findInfo("Alternative") + + manga.copy( + tags = doc.body() + .select(selectTag) + .mapToSet { a -> + val href = a.attr("href") + + MangaTag( + key = href.substringAfterLast("category=") + .substringBefore("&") + .takeIf { it != href } + ?: href.removeSuffix("/") + .substringAfterLast("/"), + + title = a.text().toTitleCase(), + source = source, + ) + }, + + description = "If empty Chapters, reload Manga!", + altTitles = setOfNotNull(alt), + state = state, + chapters = chaptersDeferred.await() + ) + } + + private fun Document.findInfo(label: String): String? { + return select("ul.manga-info-text li") + .firstOrNull { it.text().contains(label, ignoreCase = true) } + ?.text() + ?.substringAfter(":") + ?.trim() + ?.nullIfEmpty() + } + + @Deprecated("Use availableSortOrders instead", ReplaceWith("availableSortOrders")) + override val searchQueryCapabilities: MangaSearchQueryCapabilities + get() = MangaSearchQueryCapabilities( + SearchCapability( + field = TAG, + criteriaTypes = setOf(Include::class), + isMultiple = false, + ), + SearchCapability( + field = TITLE_NAME, + criteriaTypes = setOf(Match::class), + isMultiple = false, + isExclusive = true, + ), + SearchCapability( + field = STATE, + criteriaTypes = setOf(Include::class), + isMultiple = false, + ), + ) + + override suspend fun getListPage( + query: MangaSearchQuery, + page: Int + ): List { + + val titleQuery = query.criteria.filterIsInstance>() + .firstOrNull { it.field == TITLE_NAME } + ?.value as? String + + val tagCriterion = query.criteria.filterIsInstance>() + .firstOrNull { it.field == TAG } + + val tagKey = (tagCriterion?.values?.firstOrNull() as? MangaTag)?.key + ?: tagCriterion?.values?.firstOrNull()?.toString() + + val state = query.criteria.filterIsInstance>() + .firstOrNull { it.field == STATE } + ?.values + ?.firstOrNull() as? MangaState + + val url = if (!titleQuery.isNullOrBlank()) { + + "https://$domain/search/story/${ + normalizeSearchQuery(titleQuery).replace('_', '-') + }?page=$page" + + } else { + + val base = if (!tagKey.isNullOrBlank()) { + "https://$domain/genre/$tagKey" + } else { + "https://$domain/genre/all" + } + + val filter = when { + state == MangaState.ONGOING && query.order == SortOrder.NEWEST -> 3 + state == MangaState.ONGOING && query.order == SortOrder.UPDATED -> 6 + + state == MangaState.FINISHED && query.order == SortOrder.NEWEST -> 2 + state == MangaState.FINISHED && query.order == SortOrder.UPDATED -> 5 + + query.order == SortOrder.POPULARITY -> 7 + query.order == SortOrder.NEWEST -> 1 + + else -> 4 // all + latest + } + + "$base?filter=$filter&page=$page" + } + + val doc = webClient.httpGet(url).parseHtml() + return parseSearchResults(doc) + } + + override suspend fun fetchAvailableTags(): Set { + val doc = webClient.httpGet("https://$domain").parseHtml() + val tags = doc.select("td a[href*='/genre/'], div.panel-genres-list a[href*='/genre/']") + .drop(1) + + val result = LinkedHashSet() + + for (a in tags) { + val key = a.attr("href") + .removeSuffix("/") + .substringAfter("/genre/") + .substringBefore("?") + .nullIfEmpty() + ?: continue + + if (result.any { it.key == key }) { + continue + } + + val title = a.text() + .replace(" Manga", "") + .toTitleCase(sourceLocale) + + result.add( + MangaTag( + key = key, + title = title, + source = source, + ), + ) + } + + return result + } + + override suspend fun getChapters(doc: Document): List { + val slug = doc.location() + .substringAfter("/manga/", "") + .substringBefore("/") + .trim() + .ifEmpty { return super.getChapters(doc) } + + val apiChapters = runCatching { + fetchChaptersApi(slug) + }.getOrDefault(emptyList()) + + return apiChapters.ifEmpty { + super.getChapters(doc) + } + } + + private suspend fun fetchChaptersApi(slug: String): List { + + suspend fun request(offset: Int): JSONObject { + val apiUrl = + "https://$domain/api/manga/$slug/chapters" + + "?limit=$CHAPTER_LIST_TAKE&offset=$offset" + + return webClient.httpGet(apiUrl).parseJson() + } + + fun extractChapters(json: JSONObject): List { + val array = json + .optJSONObject("data") + ?.optJSONArray("chapters") + ?: return emptyList() + + return buildList { + for (i in 0 until array.length()) { + array.optJSONObject(i)?.let(::add) + } + } + } + // first request + val first = request(0) + + val chapters = mutableListOf() + chapters += extractChapters(first) + + val pagination = + first.optJSONObject("data") + ?.optJSONObject("pagination") + + val hasMore = + pagination?.optBoolean("has_more", false) ?: false + + if (!hasMore) { + return mapChapters(slug, chapters) + } + + val total = + pagination.optInt("total", 0) + + val offsets = + (CHAPTER_LIST_TAKE until total step CHAPTER_LIST_TAKE) + .toList() + // parallel loading + coroutineScope { + val results = offsets.map { offset -> + async { + request(offset) + } + }.awaitAll() + results.forEach { + chapters += extractChapters(it) + } + } + return mapChapters(slug, chapters) + } + + private fun mapChapters( + slug: String, + chapters: List + ): List { + + return chapters.mapNotNull { chapter -> + val chapterSlug = + chapter.optString("chapter_slug") + .nullIfEmpty() + ?: return@mapNotNull null + + val chapterName = + chapter.optString("chapter_name") + .nullIfEmpty() + ?: "Chapter" + + val number = + chapter.optString("chapter_num") + .toFloatOrNull() + ?: 0f + + val url = "/manga/$slug/$chapterSlug" + + MangaChapter( + id = generateUid(url), + title = chapterName, + number = number, + volume = 0, + url = url, + uploadDate = parseApiDate( + chapter.optString("updated_at") + ), + source = source, + scanlator = null, + branch = null, + ) + }.sortedBy { + it.number + } + } + + + private fun parseApiDate(date: String?): Long { + if (date.isNullOrBlank()) { + return 0L + } + + val formats = listOf( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.US), + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US), + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US), + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US), + ).onEach { + it.timeZone = TimeZone.getTimeZone("UTC") + } + + for (format in formats) { + val parsed = format.parseSafe(date) + if (parsed != 0L) { + return parsed + } + } + + return 0L + } + + @Deprecated("Use getRequestHeaders instead", ReplaceWith("getRequestHeaders()")) + override fun getRequestHeaders() = super.getRequestHeaders().newBuilder() + .set("Referer", "https://www.mangabats.com/") + .build() + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + if (request.method == "GET" || request.method == "POST") { + val newRequest = request.newBuilder() + .removeHeader("Content-Encoding") + .removeHeader("Accept-Encoding") + .build() + + return chain.proceed(newRequest) + } + + return chain.proceed(request) + } + + companion object { + + private const val CHAPTER_LIST_TAKE = 1000 + } +} From 06b2f220dea3f25e980a74e70a9f5d49ea38721d Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:32:28 +0200 Subject: [PATCH 095/109] Rename ManganatoGG to ManganatoGG.kt --- .../site/kotatsu/en/mangabox/{ManganatoGG => ManganatoGG.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/mangabox/{ManganatoGG => ManganatoGG.kt} (100%) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/mangabox/ManganatoGG b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/mangabox/ManganatoGG.kt similarity index 100% rename from src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/mangabox/ManganatoGG rename to src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/mangabox/ManganatoGG.kt From 45c41ed6442d2a0a61ebcd50a9f9651f899c4419 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:48:02 +0200 Subject: [PATCH 096/109] Update MangaThemesia.kt --- .../org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt index 9ba954a6..4dd53632 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt @@ -16,7 +16,7 @@ import java.util.* abstract class MangaThemesia( context: MangaLoaderContext, source: MangaParserSource, - domain: String): + domain: String): PagedMangaParser(context, source, 20) { private val domainName = domain @@ -36,7 +36,7 @@ abstract class MangaThemesia( isMultipleTagsSupported = true, isAuthorSearchSupported = true, ) - + protected open val mangaDirectory = "manga" protected open val relatedSelector = ".related-posts .bsx, .bixbox .bsx, .related-manga .related-reading-wrap" @@ -401,7 +401,7 @@ abstract class MangaThemesia( ):String { return page.url } - + protected open fun parseState(value:String?):MangaState? { val text = value @@ -570,5 +570,5 @@ abstract class MangaThemesia( ?.toFloatOrNull() } - abstract val pageSelector: String + open val pageSelector = "div#readerarea img" } From 5ffa56de46c0c8e98e3cc86fba05c479dbe16d23 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:50:54 +0200 Subject: [PATCH 097/109] Update and rename KingofShojo to KingofShojo.kt --- .../kotatsu/en/{KingofShojo => KingofShojo.kt} | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) rename src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/{KingofShojo => KingofShojo.kt} (62%) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt similarity index 62% rename from src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo rename to src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt index a8cf1485..3360ce74 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt @@ -6,16 +6,6 @@ import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.parsers.MangaThemesia -@MangaSourceParser("KINGOFSHOJO", "King of Shojo", "en",) +@MangaSourceParser("KINGOFSHOJO", "King of Shojo", "en") internal class KingofShojo(context: MangaLoaderContext) : - MangaThemesia(context, MangaParserSource.KINGOFSHOJO, "kingofshojo.com",) { - - override val mangaDirectory = - "manga" - - override val searchSelector = - ".listupd .bsx" - - override val pageSelector = - "#readerarea img" -} + MangaThemesia(context, MangaParserSource.KINGOFSHOJO, "kingofshojo.com") From ef7e230500529e29312f882f3f9834fecb0e2455 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:27:12 +0200 Subject: [PATCH 098/109] Update MangaThemesia.kt --- .../kotatsu/parsers/parsers/MangaThemesia.kt | 803 +++++++----------- 1 file changed, 311 insertions(+), 492 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt index 4dd53632..da004729 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt @@ -1,10 +1,10 @@ package org.koitharu.kotatsu.parsers.parsers -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonPrimitive +import kotlinx.coroutines.sync.withLock +import org.json.JSONArray import org.jsoup.nodes.Document import org.jsoup.nodes.Element +import org.jsoup.select.Elements import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser @@ -16,559 +16,378 @@ import java.util.* abstract class MangaThemesia( context: MangaLoaderContext, source: MangaParserSource, - domain: String): - PagedMangaParser(context, source, 20) { + domain: String, + pageSize: Int = 20, +) : PagedMangaParser(context, source, pageSize) { - private val domainName = domain + override val configKeyDomain = ConfigKey.Domain(domain) - override val configKeyDomain = ConfigKey.Domain(domainName) + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override fun getRequestHeaders() = super.getRequestHeaders().newBuilder() + .set("Referer", "https://$domain/") + .build() + protected open val mangaDirectory = "manga" protected open val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US) + protected open val withoutAjax = false + protected open val searchSelector = ".utao .uta .imgu, .listupd .bs .bsx, .listo .bs .bsx" + protected open val relatedSelector = ".related-posts .bsx, .bixbox .bsx, .related-manga .related-reading-wrap" + protected open val chapterListSelector = "div.bxcl li, div.cl li, #chapterlist li, ul li:has(div.chbox):has(div.eph-num), div.bxcl li:not(a[data-bs-target='#lockedChapterModal'])" + protected open val pageSelector = "div#readerarea img" - override val availableSortOrders = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.NEWEST, - )!! + override val availableSortOrders: EnumSet = EnumSet.of( + SortOrder.ALPHABETICAL, + SortOrder.ALPHABETICAL_DESC, + SortOrder.UPDATED, + SortOrder.ADDED, + SortOrder.POPULARITY, + ) override val filterCapabilities = MangaListFilterCapabilities( - isSearchSupported = true, - isMultipleTagsSupported = true, - isAuthorSearchSupported = true, - ) - - protected open val mangaDirectory = "manga" - - protected open val relatedSelector = ".related-posts .bsx, .bixbox .bsx, .related-manga .related-reading-wrap" + isSearchSupported = true, + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + isAuthorSearchSupported = true, + ) + + @Volatile + private var genreCache: Set? = null + private val genreMutex = kotlinx.coroutines.sync.Mutex() + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = getOrFetchGenres(), + availableStates = EnumSet.of( + MangaState.ONGOING, + MangaState.FINISHED, + MangaState.PAUSED, + MangaState.ABANDONED, + ), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ContentType.COMICS, + ContentType.NOVEL, + ), + ) + + private suspend fun getOrFetchGenres(): Set { + genreCache?.let { return it } + return genreMutex.withLock { + genreCache ?: fetchGenres().also { genreCache = it } + } + } - protected open val searchSelector = ".utao .uta .imgu, .listupd .bs .bsx, .listo .bs .bsx" + protected open suspend fun fetchGenres(): Set { + val doc = webClient.httpGet("https://$domain/$mangaDirectory/").parseHtml() + val genrez = doc.selectFirst("ul.genrez") ?: return emptySet() + return genrez.select("li").mapNotNull { li -> + val label = li.selectFirst("label")?.text()?.trim() ?: return@mapNotNull null + val input = li.selectFirst("input[type=checkbox]") ?: return@mapNotNull null + val value = input.attr("value").trim() + if (label.isBlank() || value.isBlank()) null + else MangaTag(key = value, title = label, source = source) + }.toSet() + } override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val query = filter.query + val query = filter.query?.trim().orEmpty() val url = buildString { - append("https://") - append(domainName) - if(query?.isNotBlank() == true) { - append("/?s=") - append(query.urlEncoded()) + append("https://$domain/$mangaDirectory/") + if (query.isNotEmpty()) { + append("?s=${query.urlEncoded()}") } else { - append("/") - append(mangaDirectory) - append("?page=") - append(page) + append("?page=$page") } - filter.author - ?.takeIf { it.isNotBlank() } - ?.let { - append("&author=") - append(it.urlEncoded()) - } - - if (filter.year != -1) { - append("&yearx=") - append(filter.year) - } - + filter.author?.takeIf { it.isNotBlank() }?.let { append("&author=${it.urlEncoded()}") } + if (filter.year != -1) append("&yearx=${filter.year}") filter.states.firstOrNull()?.let { append("&status=") - append( - when(it) { - MangaState.ONGOING -> "ongoing" - MangaState.FINISHED -> "completed" - MangaState.PAUSED -> "hiatus" - MangaState.ABANDONED -> "dropped" - else -> "" - } - ) + append(when (it) { + MangaState.ONGOING -> "ongoing" + MangaState.FINISHED -> "completed" + MangaState.PAUSED -> "hiatus" + MangaState.ABANDONED -> "dropped" + else -> "" + }) } - filter.types.firstOrNull()?.let { append("&type=") - append( - when(it) { - ContentType.MANGA -> "Manga" - ContentType.MANHWA -> "Manhwa" - ContentType.MANHUA -> "Manhua" - else -> "" - } - ) - } - filter.tags.forEach { - append("&genre[]=") - append(it.key.urlEncoded()) - } - filter.tagsExclude.forEach { - append("&genre[]=-") - append(it.key.urlEncoded()) - } - - when(order) { - SortOrder.UPDATED -> - append("&order=update") - SortOrder.POPULARITY -> - append("&order=popular") - SortOrder.NEWEST -> - append("&order=latest") - else -> {} + append(when (it) { + ContentType.MANGA -> "manga" + ContentType.MANHWA -> "manhwa" + ContentType.MANHUA -> "manhua" + ContentType.COMICS -> "comic" + ContentType.NOVEL -> "novel" + else -> "" + }) } + filter.tags.forEach { append("&genre[]=${it.key.urlEncoded()}") } + filter.tagsExclude.forEach { append("&genre[]=-${it.key.urlEncoded()}") } + append("&order=") + append(when (order) { + SortOrder.UPDATED -> "update" + SortOrder.POPULARITY -> "popular" + SortOrder.ADDED -> "latest" + SortOrder.ALPHABETICAL -> "title" + SortOrder.ALPHABETICAL_DESC -> "titlereverse" + else -> "update" + }) } - val document = webClient - .httpGet( - url, - ) - .parseHtml() - - - return document - .select(searchSelector) - .mapNotNull { - runCatching { - parseManga(it) - }.getOrNull() - - } + val doc = webClient.httpGet(url).parseHtml() + return parseMangaList(doc) } - - protected open fun parseManga(element: Element): Manga { - val link = element.selectFirst("a") - ?: return Manga( - id = generateUid("empty"), - url = "", - publicUrl = "", - title = "", - altTitles = emptySet(), - authors = emptySet(), - tags = emptySet(), - coverUrl = null, - rating = RATING_UNKNOWN, - state = null, - contentRating = - if(isNsfwSource) - ContentRating.ADULT - else - null, - source = source, - ) - - val url = link.attr("href") - .toRelativeUrl(domainName) - - return Manga( - id = generateUid(url), - url = url, - publicUrl = url.toAbsoluteUrl(domainName), - title = link.attr("title") - .ifBlank { - link.text() - }, - altTitles = emptySet(), - authors = emptySet(), - coverUrl = element - .selectFirst("img, source") - ?.imgAttr(), - tags = emptySet(), - rating = RATING_UNKNOWN, - state = null, - contentRating = - if(isNsfwSource) - ContentRating.ADULT - else - null, - source = source, - ) + protected open fun parseMangaList(doc: Document): List { + return doc.select(searchSelector).mapNotNull { element -> + parseMangaElement(element) + } } + override suspend fun getDetails(manga: Manga): Manga { + val fullUrl = manga.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl).parseHtml() + + val title = doc.selectFirst("h1.entry-title, .ts-breadcrumb li:last-child span, .infomanga h1, .animefull h1") + ?.text() ?: manga.title + + val description = doc.selectFirst( + ".desc, .entry-content[itemprop=description], .summary__content, .sinopsis, .entry-content p" + )?.text()?.trim().orEmpty() + + val coverUrl = doc.selectFirst( + ".thumb img, .infomanga img, .summary_image img, .cover img, img.wp-post-image" + )?.imgAttr() ?: manga.coverUrl + + val table = doc.selectFirst("table.infotable") + val hasTable = table != null + + val authors = if (hasTable) { + val authorCell = table.selectFirst("tr:has(td:contains(Author)) td:last-child") + val artistCell = table.selectFirst("tr:has(td:contains(Artist)) td:last-child") + listOfNotNull( + authorCell?.text()?.trim()?.takeIf { it.isNotBlank() && it != "n/a" && it != "N/A" }, + artistCell?.text()?.trim()?.takeIf { it.isNotBlank() && it != "n/a" && it != "N/A" } + ).toSet() + } else { + doc.select(".author, .artist, .fmed span, .tsinfo .imptdt:contains(Author) i, .spe span:contains(Author) a") + .map { it.text().trim() } + .filter { it.isNotBlank() } + .toSet() + } - override suspend fun getRelatedManga(seed: Manga): List { - val document = webClient - .httpGet( - seed.url.toAbsoluteUrl(domainName), - ) - .parseHtml() - return document - .select(relatedSelector) - .mapNotNull { - runCatching { - val link = - it.selectFirst("a") - ?: return@runCatching null - val url = - link - .attr("href") - .toRelativeUrl(domainName) - Manga( - id = generateUid(url), - url = url, - publicUrl = url.toAbsoluteUrl(domainName), - title = link.attr("title") - .ifBlank { - link.text() - }, - altTitles = emptySet(), - authors = emptySet(), - coverUrl = it - .selectFirst("img") - ?.imgAttr(), - tags = emptySet(), - rating = RATING_UNKNOWN, - state = null, - contentRating = - if(isNsfwSource) - ContentRating.ADULT - else - null, - source = source, - ) + val tags = doc.select(".mgen a, .gnr a, .seriestugenre a, .genres-content a") + .mapNotNull { a -> + val text = a.text().trim() + if (text.isNotBlank()) MangaTag(key = text.lowercase(), title = text, source = source) else null + }.toSet() + + val statusText = if (hasTable) { + table.selectFirst("tr:has(td:contains(Status)) td:last-child")?.text() + } else { + doc.select(".imptdt:contains(Status) i, .tsinfo .imptdt:contains(Status) a, .fmed b:contains(Status)+span span") + .text() + .ifEmpty { + doc.selectFirst("div.post-content_item:contains(Status) div.summary-content")?.text() } - .getOrNull() - } - } + } + val state = parseStatus(statusText) + val rating = doc.selectFirst(".num[itemprop=ratingValue]")?.attr("content")?.toFloatOrNull() + ?: doc.selectFirst(".num")?.text()?.toFloatOrNull() + val normalizedRating = if (rating != null && rating > 0) rating / 10f else RATING_UNKNOWN + + val chapters = loadChapters(doc, manga.url) - override suspend fun getDetails(manga: Manga): Manga { - val document = webClient - .httpGet( - manga.url.toAbsoluteUrl(domainName), - ) - .parseHtml() - - val chapters = loadChapters( - document, - manga, - ) return manga.copy( - title = document - .selectFirst("h1.entry-title, .ts-breadcrumb li:last-child span") - ?.text() - ?: manga.title, - description = document - .selectFirst(".desc, .entry-content[itemprop=description]") - ?.text() - ?.trim() - .orEmpty(), - coverUrl = document - .selectFirst(".thumb img, .infomanga img, .summary_image img, .cover img, img.wp-post-image") - ?.imgAttr() - ?: manga.coverUrl, - authors = document - .select(".author, .artist, .fmed span") - .map { - it.text() - } - .filter { - it.isNotBlank() - } - .toSet(), - tags = document - .select(".mgen a, .gnr a, .seriestugenre a") - .map { - MangaTag( - key = it.text().lowercase(), - title = it.text(), - source = source, - ) - - } - .toSet(), - state = parseState(document - .select( - ".imptdt, .status" - ) - .text() - ), + title = title, + description = description, + coverUrl = coverUrl, + authors = authors, + tags = tags, + state = state, + rating = normalizedRating, chapters = chapters, ) } - - protected open suspend fun loadChapters(document: Document, manga: Manga): List { - return document - .select( - "div.bxcl li, div.cl li, #chapterlist li, ul li:has(div.chbox)" - ) - .mapNotNull { element -> - val url = element - .selectFirst("a") - ?.attr("href") - ?: return@mapNotNull null - val title = cleanChapterTitle( - element - .selectFirst("a") - ?.text() - .orEmpty() - ) - val chapterNumber = extractChapterNumber(title) - ?: return@mapNotNull null - MangaChapter( - id = generateUid(url), - url = url.toRelativeUrl(domainName), - title = title, - number = chapterNumber, - volume = 0, - branch = null, - uploadDate = - element - .selectFirst(".chapterdate") - ?.text() - ?.let { - parseDate(it) - } - ?: 0, - scanlator = null, - source = source, - ) - } - .sortedBy { it.number } + protected open fun parseStatus(text: String?): MangaState? { + val status = text?.lowercase() ?: return null + return when (status) { + in ongoing -> MangaState.ONGOING + in finished -> MangaState.FINISHED + in paused -> MangaState.PAUSED + in abandoned -> MangaState.ABANDONED + else -> null + } } - private fun parseDate( - value:String, - ):Long { - return try { - dateFormat - .parse(value) - ?.time - ?: 0 - } catch(_:Exception){ - 0 + protected open suspend fun loadChapters(doc: Document, mangaUrl: String): List { + val chapterElements = doc.select(chapterListSelector) + if (chapterElements.isEmpty() && !withoutAjax) { + return loadChaptersAjax(mangaUrl) } + return parseChapters(chapterElements) } + protected open suspend fun loadChaptersAjax(mangaUrl: String): List { + val ajaxUrl = mangaUrl.toAbsoluteUrl(domain).removeSuffix("/") + "/ajax/chapters/" + val doc = webClient.httpPost(ajaxUrl, emptyMap()).parseHtml() + return parseChapters(doc.select(chapterListSelector)) + } - override suspend fun getPages(chapter: MangaChapter): List { - val document = - webClient - .httpGet( - chapter.url.toAbsoluteUrl(domainName), - ) - .parseHtml() - - val images = - Regex( - "\"images\"\\s*:\\s*(\\[.*?])", - setOf( - RegexOption.DOT_MATCHES_ALL - ) + private fun parseChapters(elements: Elements): List { + return elements.mapChapters(reversed = true) { i, element -> + val a = element.selectFirst("a") ?: return@mapChapters null + val href = a.attrAsRelativeUrl("href") + val name = a.selectFirst(".chapternum")?.text() ?: a.ownText() + val dateStr = element.selectFirst(".chapterdate")?.text() + val number = extractChapterNumber(name) ?: (i + 1).toFloat() + MangaChapter( + id = generateUid(href), + url = href, + title = name, + number = number, + volume = 0, + uploadDate = parseChapterDate(dateStr), + scanlator = null, + branch = null, + source = source, ) - .find(document.html()) - ?.groupValues - ?.getOrNull(1) - - if(!images.isNullOrBlank()) { - val pages = - runCatching { - Json - .parseToJsonElement(images) - .jsonArray - .map { - val url = - it.jsonPrimitive.content - MangaPage( - id = generateUid(url), - url = url, - preview = null, - source = source, - ) - } - } - .getOrNull() - if(!pages.isNullOrEmpty()) - return pages } - - return document - .select(pageSelector) - .mapNotNull { - val url = - it.imgAttr() - if(url.isBlank()) - null - else - MangaPage( - id = generateUid(url), - url = url, - preview = null, - source = source, - ) - } } - - override suspend fun getPageUrl( - page:MangaPage, - ):String { - return page.url + private fun extractChapterNumber(title: String): Float? { + return Regex("""(?i)(?:chapter|ch\.?)\s*([0-9]+(?:\.[0-9]+)?)""") + .find(title) + ?.groupValues + ?.getOrNull(1) + ?.toFloatOrNull() } - protected open fun parseState(value:String?):MangaState? { - val text = - value - ?.lowercase() - ?: return null - return when{ - listOf( - "ongoing", - "on going", - "publishing", - "updating", - ) - .any { - text.contains(it) - } -> - MangaState.ONGOING - - listOf( - "completed", - "complete", - "finished", - ) - .any { - text.contains(it) - } -> - MangaState.FINISHED - - listOf( - "hiatus", - "paused", - "on hold", - ) - .any { - text.contains(it) - } -> - MangaState.PAUSED - - listOf( - "dropped", - "cancelled", - "canceled", - ) - .any { - text.contains(it) - } -> - MangaState.ABANDONED - else -> - null + protected open fun parseChapterDate(date: String?): Long { + if (date.isNullOrBlank()) return 0L + val clean = date.trim() + return when { + WordSet(" ago", "atrás", " hace", " назад", " önce", " trước", "مضت", "قبل").endsWith(clean) -> parseRelativeDate(clean) + WordSet("há ", "منذ", "il y a", "hace", "giờ", "phút").startsWith(clean) -> parseRelativeDate(clean) + WordSet("yesterday", "يوم واحد").startsWith(clean) -> yesterday() + WordSet("today").startsWith(clean) -> today() + WordSet("يومين").startsWith(clean) -> dayBeforeYesterday() + else -> dateFormat.parseSafe(clean) } } - - override suspend fun getFilterOptions(): MangaListFilterOptions { - val tags = - listOf( - "Action", - "Adventure", - "Comedy", - "Drama", - "Fantasy", - "Historical", - "Horror", - "Isekai", - "Romance", - "School Life", - "Sci-Fi", - "Shoujo", - "Slice of Life", - "Supernatural", - "Thriller", - ) - .map { - MangaTag( - key = it.lowercase(), - title = it, - source = source, - ) - } - .toSet() - - return MangaListFilterOptions( - availableTags = tags, - availableStates = EnumSet.of( - MangaState.ONGOING, - MangaState.FINISHED, - MangaState.PAUSED, - MangaState.ABANDONED, - ), - availableContentTypes = EnumSet.of( - ContentType.MANGA, - ContentType.MANHWA, - ContentType.MANHUA, - ), - ) + private fun parseRelativeDate(text: String): Long { + val number = Regex("""(\d+)""").find(text)?.value?.toIntOrNull() ?: return 0L + val cal = Calendar.getInstance() + return when { + WordSet("detik", "segundo", "second", "วินาที", "giây", "ثوان").anyWordIn(text) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis + WordSet("menit", "dakika", "min", "minute", "minuto", "mins", "นาที", "دقائق", "phút", "минут").anyWordIn(text) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis + WordSet("jam", "saat", "heure", "hora", "hour", "hours", "ชั่วโมง", "giờ", "ore", "ساعة", "小时").anyWordIn(text) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis + WordSet("hari", "gün", "jour", "día", "dia", "day", "days", "días", "วัน", "ngày", "giorni", "أيام", "天", "день").anyWordIn(text) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis + WordSet("week", "semana", "tuần", "أسابيع", "أسبوع").anyWordIn(text) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis + WordSet("month", "months", "mes", "meses", "tháng", "أشهر", "mois").anyWordIn(text) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis + WordSet("year", "año", "năm").anyWordIn(text) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis + else -> 0L + } } - protected fun Element.imgAttr(): String { - val attributes = listOf( - "data-src", - "data-lazy-src", - "data-original", - "data-cfsrc", - "data-image", - "data-bg", - "src" - ) + private fun yesterday() = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) }.timeInMillis + private fun today() = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0) }.timeInMillis + private fun dayBeforeYesterday() = Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -2) }.timeInMillis - for(attribute in attributes) { - val value = attr(attribute) - if(value.isNotBlank()) { - return fixImageExtension( - value.toAbsoluteUrl(domainName) - ) + override suspend fun getPages(chapter: MangaChapter): List { + val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() + + // json image list + val jsonRegex = Regex(""""images"\s*:\s*(\[.*?])""", RegexOption.DOT_MATCHES_ALL) + val jsonMatch = jsonRegex.find(doc.html()) + if (jsonMatch != null) { + val jsonArray = JSONArray(jsonMatch.groupValues[1]) + return (0 until jsonArray.length()).map { i -> + val url = jsonArray.getString(i) + MangaPage(id = generateUid(url), url = url, preview = null, source = source) } } - - val srcset = attr("srcset") - if(srcset.isNotBlank()) { - return fixImageExtension( - srcset - .split(",") - .last() - .trim() - .split(" ") - .first() - .toAbsoluteUrl(domainName) - ) + // direct + return doc.select(pageSelector).mapNotNull { img -> + val url = img.imgAttr().takeIf { it.isNotBlank() } ?: return@mapNotNull null + MangaPage(id = generateUid(url), url = url, preview = null, source = source) } - return "" } - private fun fixImageExtension(url: String): String { - var result = url - // Remove WordPress image proxy - result = result.replace( - Regex( - "https://i[0-3]\\.wp\\.com/" - ), - "https://" - ) - // Remove WordPress resize params if they exist - result = result.substringBefore("?") - return result + override suspend fun getRelatedManga(seed: Manga): List { + val doc = webClient.httpGet(seed.url.toAbsoluteUrl(domain)).parseHtml() + return doc.select(relatedSelector).mapNotNull { element -> + parseMangaElement(element) + } } + protected open fun parseMangaElement(element: Element): Manga? { + val a = element.selectFirst("a") ?: return null + val href = a.attrAsRelativeUrl("href") + val title = a.attr("title").ifBlank { a.text() } + val coverUrl = element.selectFirst("img")?.imgAttr() + return makeManga(href, title, coverUrl) + } + private fun makeManga(href: String, title: String, coverUrl: String?): Manga { + return Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + title = title, + altTitles = emptySet(), + authors = emptySet(), + coverUrl = coverUrl, + rating = RATING_UNKNOWN, + tags = emptySet(), + state = null, + contentRating = if (isNsfwSource) ContentRating.ADULT else null, + source = source, + ) + } - private fun cleanChapterTitle( - value: String, - ): String { - return value - .replace(Regex("\\s+(January|February|March|April|May|June|July|August|September|October|November|December).*", RegexOption.IGNORE_CASE), "") - .replace(Regex("\\s+\\d{1,2}[./-]\\d{1,2}[./-]\\d{2,4}.*"), "") - .trim() + protected fun Element.imgAttr(): String { + for (attr in listOf("data-src", "data-lazy-src", "data-original", "data-cfsrc", "data-image", "src")) { + val value = attr(attr).trim().takeIf { it.isNotEmpty() } ?: continue + return value.toAbsoluteUrl(domain).substringBefore("?") // remove WP resize params + } + val srcset = attr("srcset") + if (srcset.isNotBlank()) { + return srcset.split(",").last().trim().split(" ").first().toAbsoluteUrl(domain) + } + return "" } - private fun extractChapterNumber(title: String): Float? { - return Regex( - """(?:chapter|ch\.?)\s*([0-9]+(?:\.[0-9]+)?)""", - RegexOption.IGNORE_CASE, + private fun SimpleDateFormat.parseSafe(date: String): Long = + runCatching { parse(date)?.time ?: 0L }.getOrDefault(0L) + + companion object { + val ongoing = setOf( + "ongoing", "on going", "publishing", "updating", "en curso", "ativo", "en cours", + "đang tiến hành", "em lançamento", "devam ediyor", "in corso", "güncel", "berjalan", + "продолжается", "lançando", "in arrivo", "连载中", "devam etmekte", "مستمرة", + ) + val finished = setOf( + "completed", "complete", "finished", "finalizado", "terminé", "tamamlandı", + "đã hoàn thành", "hoàn thành", "مكتملة", "завершено", "completato", "one-shot", + "bitti", "tamat", "concluído", "已完结", "bitmiş", "achevé", + ) + val paused = setOf( + "hiatus", "on hold", "pausado", "en espera", "en pause", "en attente", + "durduruldu", "beklemede", "đang chờ", "متوقف", "заморожено", + ) + val abandoned = setOf( + "canceled", "cancelled", "cancelado", "cancellato", "dropped", "discontinued", + "abandonné", "iptal edildi", "đã hủy", "ملغي", "заброшено", ) - .find(title) - ?.groupValues - ?.getOrNull(1) - ?.toFloatOrNull() } - - open val pageSelector = "div#readerarea img" } From 05dc12ff46b7bb91305b6b0fed7f20a0d4459f1c Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:27:32 +0200 Subject: [PATCH 099/109] Fix formatting in KingofShojo.kt --- .../org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt index 3360ce74..7e31556c 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/KingofShojo.kt @@ -5,7 +5,6 @@ import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.parsers.MangaThemesia - @MangaSourceParser("KINGOFSHOJO", "King of Shojo", "en") internal class KingofShojo(context: MangaLoaderContext) : MangaThemesia(context, MangaParserSource.KINGOFSHOJO, "kingofshojo.com") From 9ee754b9c4a727ecec9b8465653aadbede49c8ca Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:46:45 +0200 Subject: [PATCH 100/109] Create RavenScans.kt --- .../kotatsu/parsers/site/kotatsu/en/RavenScans.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/RavenScans.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/RavenScans.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/RavenScans.kt new file mode 100644 index 00000000..dae4112e --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/RavenScans.kt @@ -0,0 +1,10 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.en + +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.parsers.MangaThemesia + +@MangaSourceParser("RAVENSCANS", "RavenScans", "en") +internal class RavenScans(context: MangaLoaderContext) : + MangaThemesia(context, MangaParserSource.RAVENSCANS, "ravenscans.org") From 7d1059321b3ed88a9bbf950feefd9f417cf2d641 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:55:01 +0200 Subject: [PATCH 101/109] Delete src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/RavenScans.kt --- .../kotatsu/parsers/site/kotatsu/en/RavenScans.kt | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/RavenScans.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/RavenScans.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/RavenScans.kt deleted file mode 100644 index dae4112e..00000000 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/RavenScans.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.koitharu.kotatsu.parsers.site.kotatsu.en - -import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.MangaSourceParser -import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.parsers.MangaThemesia - -@MangaSourceParser("RAVENSCANS", "RavenScans", "en") -internal class RavenScans(context: MangaLoaderContext) : - MangaThemesia(context, MangaParserSource.RAVENSCANS, "ravenscans.org") From fa9ec4fcf846125e19a5f6b28a604cd2aa2f463f Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:56:11 +0200 Subject: [PATCH 102/109] Update README.md --- README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 50bdf39a..00eea566 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,6 @@ [![License][license-shield]][license-url] ### Please give the repo a ⭐ It motivates me. =^..^= -# Sources Requests - -> [!NOTE] -> Open up an [Issue](https://github.com/InvalidDavid/UMA/issues/new) with these contents: -> Title Source Name -> - URL -> - language? -> - NSFW yes/no -> -> Or via Contact Info (see below) --- @@ -173,6 +163,20 @@ chmod +x gradlew && ./gradlew buildJar
+--- + +# Sources Requests + +> [!NOTE] +> Open up an [Issue](https://github.com/InvalidDavid/UMA/issues/new) with these contents: +> Title Source Name +> - URL +> - language? +> - NSFW yes/no +> +> Or via Contact Info (see above) + + --- # Credits From e924f365d16b731042379c2d7d55709b3e4c39f4 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:00:13 +0200 Subject: [PATCH 103/109] Update MangaThemesia.kt --- .../org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt index da004729..50156919 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.parsers.parsers +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.json.JSONArray import org.jsoup.nodes.Document @@ -56,7 +57,7 @@ abstract class MangaThemesia( @Volatile private var genreCache: Set? = null - private val genreMutex = kotlinx.coroutines.sync.Mutex() + private val genreMutex = Mutex() override suspend fun getFilterOptions() = MangaListFilterOptions( availableTags = getOrFetchGenres(), From f8c5c957bfdb77df7175b69e9e85401b6571a1ff Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:00:57 +0200 Subject: [PATCH 104/109] Reorder genresMutex declaration and fix formatting --- .../org/koitharu/kotatsu/parsers/site/kotatsu/id/AinzScans.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/AinzScans.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/AinzScans.kt index feb6b026..cf97ba11 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/AinzScans.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/id/AinzScans.kt @@ -45,10 +45,10 @@ internal class AinzScans(context: MangaLoaderContext) : availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED), availableContentTypes = EnumSet.of(ContentType.MANGA, ContentType.MANHWA, ContentType.MANHUA), ) - - private val genresMutex = Mutex() + @Volatile private var genresCache: Map? = null + private val genresMutex = Mutex() private suspend fun getOrFetchGenres(): Map { genresCache?.let { return it } From acfc688a006931647b51f26ce84e44bd563af59d Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:02:44 +0200 Subject: [PATCH 105/109] Add Star History section to README Added star history section with dynamic chart. --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 00eea566..f4b9f274 100644 --- a/README.md +++ b/README.md @@ -173,9 +173,20 @@ chmod +x gradlew && ./gradlew buildJar > - URL > - language? > - NSFW yes/no -> +> > Or via Contact Info (see above) +--- + +## Star History + + + + + + Star History Chart + + --- From 1dc1976047bf35d8ce51aecdc4328878a3029462 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:46:53 +0200 Subject: [PATCH 106/109] Update MangaThemesia.kt --- .../org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt index 50156919..86436d32 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/MangaThemesia.kt @@ -52,7 +52,6 @@ abstract class MangaThemesia( isSearchSupported = true, isMultipleTagsSupported = true, isTagsExclusionSupported = true, - isAuthorSearchSupported = true, ) @Volatile @@ -104,8 +103,6 @@ abstract class MangaThemesia( } else { append("?page=$page") } - filter.author?.takeIf { it.isNotBlank() }?.let { append("&author=${it.urlEncoded()}") } - if (filter.year != -1) append("&yearx=${filter.year}") filter.states.firstOrNull()?.let { append("&status=") append(when (it) { From a18835a7aa7d4e6d83e0ec160c73125994ea5fc1 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:09:34 +0200 Subject: [PATCH 107/109] Delete src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/ManhwaZ.kt --- .../kotatsu/parsers/parsers/ManhwaZ.kt | 287 ------------------ 1 file changed, 287 deletions(-) delete mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/ManhwaZ.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/ManhwaZ.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/ManhwaZ.kt deleted file mode 100644 index 5a6471fa..00000000 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/ManhwaZ.kt +++ /dev/null @@ -1,287 +0,0 @@ -package org.koitharu.kotatsu.parsers.parsers - -import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.config.ConfigKey -import org.koitharu.kotatsu.parsers.core.PagedMangaParser -import org.koitharu.kotatsu.parsers.model.ContentRating -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities -import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions -import org.koitharu.kotatsu.parsers.model.MangaPage -import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN -import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.network.CommonHeaders -import org.koitharu.kotatsu.parsers.util.attrAsRelativeUrl -import org.koitharu.kotatsu.parsers.util.generateUid -import org.koitharu.kotatsu.parsers.util.mapChapters -import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.parsers.util.parseHtml -import org.koitharu.kotatsu.parsers.util.parseSafe -import org.koitharu.kotatsu.parsers.util.selectOrThrow -import org.koitharu.kotatsu.parsers.util.src -import org.koitharu.kotatsu.parsers.util.textOrNull -import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl -import org.koitharu.kotatsu.parsers.util.toTitleCase -import org.koitharu.kotatsu.parsers.util.urlEncoded -import java.text.SimpleDateFormat -import java.util.EnumSet -import java.util.Locale - -internal abstract class ManhwaZ( - context: MangaLoaderContext, - source: MangaParserSource, - domain: String, - pageSize: Int = 20, -) : PagedMangaParser(context, source, pageSize) { - - override val configKeyDomain = ConfigKey.Domain(domain) - - override fun onCreateConfig(keys: MutableCollection>) { - super.onCreateConfig(keys) - keys.add(userAgentKey) - } - - override fun getRequestHeaders() = super.getRequestHeaders().newBuilder() - .add(CommonHeaders.ORIGIN, "https://$domain") - .add(CommonHeaders.REFERER, "https://$domain/") - .build() - - override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.ALPHABETICAL, - SortOrder.RATING, - ) - - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isSearchSupported = true, - ) - - override suspend fun getFilterOptions(): MangaListFilterOptions { - return MangaListFilterOptions( - availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), - availableTags = fetchTags(), - ) - } - - protected open val searchPath = "/search" - - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val url = buildString { - append("https://") - append(domain) - if (!filter.query.isNullOrEmpty()) { - append("$searchPath?s=") - append((filter.query?.urlEncoded() ?: "")) - append("&page=") - append(page.toString()) - } else { - if (filter.tags.isNotEmpty()) { - append("/genre/") - append(filter.tags.first().key) - append("/") - } else { - append("/") - } - append("?page=") - append(page.toString()) - val sortQuery = getSortOrderQuery(order, filter.tags.isNotEmpty()) - if (sortQuery.isNotEmpty()) { - append("&") - append(sortQuery) - } - } - } - - val doc = webClient.httpGet(url).parseHtml() - return doc.select(".page-item-detail").mapNotNull { element -> - val href = element.selectFirst(".item-summary a")?.attrAsRelativeUrl("href") ?: return@mapNotNull null - Manga( - id = generateUid(href), - url = href, - publicUrl = href.toAbsoluteUrl(domain), - title = element.selectFirst(".item-summary a")?.text().orEmpty(), - coverUrl = element.selectFirst(".item-thumb img")?.src().orEmpty(), - altTitles = emptySet(), - rating = RATING_UNKNOWN, - tags = emptySet(), - authors = emptySet(), - state = null, - source = source, - contentRating = if (isNsfwSource) ContentRating.ADULT else null, - ) - } - } - - protected open val selectAuthor = - "div.summary-heading:contains(Tác giả) + div.summary-content" - protected open val selectState = - "div.summary-heading:contains(Trạng thái) + div.summary-content" - - override suspend fun getDetails(manga: Manga): Manga { - val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() - val author = doc.selectFirst(selectAuthor)?.textOrNull() - return manga.copy( - altTitles = setOfNotNull(doc.selectFirst("h2.other-name")?.textOrNull()), - authors = setOfNotNull(author), - tags = doc.select("div.genres-content a[rel=tag]").mapToSet { a -> - MangaTag( - key = a.attr("href").substringAfterLast('/'), - title = a.text().toTitleCase(sourceLocale), - source = source, - ) - }, - description = doc.selectFirst("div.summary__content")?.html(), - state = when (doc.selectFirst(selectState)?.text()?.lowercase()) { - "đang ra" -> MangaState.ONGOING - "hoàn thành" -> MangaState.FINISHED - else -> null - }, - chapters = doc.select("li.wp-manga-chapter").mapChapters(reversed = true) { i, element -> - val a = element.selectFirst("a") ?: return@mapChapters null - MangaChapter( - id = generateUid(a.attrAsRelativeUrl("href")), - title = a.text(), - number = i + 1f, - url = a.attrAsRelativeUrl("href"), - uploadDate = parseChapterDate( - element.selectFirst("span.chapter-release-date")?.text() - ), - branch = null, - scanlator = null, - source = source, - volume = 0, - ) - }, - ) - } - - override suspend fun getPages(chapter: MangaChapter): List { - val fullUrl = chapter.url.toAbsoluteUrl(domain) - val doc = webClient.httpGet(fullUrl).parseHtml() - return doc.selectOrThrow("div.page-break img").map { img -> - val url = img.src().orEmpty() - MangaPage( - id = generateUid(url), - url = url, - preview = null, - source = source, - ) - } - } - - @JvmField - protected val secondAgo: Set = setOf( - "giây trước", - "second ago", - "seconds ago" - ) - - @JvmField - protected val minuteAgo: Set = setOf( - "phút trước", - "minute ago", - "minutes ago" - ) - - @JvmField - protected val hourAgo: Set = setOf( - "giờ trước", - "hour ago", - "hours ago" - ) - - @JvmField - protected val dayAgo: Set = setOf( - "ngày trước", - "day ago", - "days ago" - ) - - @JvmField - protected val weekAgo: Set = setOf( - "tuần trước", - "week ago", - "weeks ago" - ) - - @JvmField - protected val monthAgo: Set = setOf( - "tháng trước", - "month ago", - "months ago" - ) - - @JvmField - protected val yearAgo: Set = setOf( - "năm trước", - "year ago", - "years ago" - ) - - private fun parseChapterDate(date: String?): Long { - if (date == null) return 0L - - return when { - secondAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(secondAgo).toLong() * 1000 - - minuteAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(minuteAgo).toLong() * 60 * 1000 - - hourAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(hourAgo).toLong() * 60 * 60 * 1000 - - dayAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(dayAgo).toLong() * 24 * 60 * 60 * 1000 - - weekAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(weekAgo).toLong() * 7 * 24 * 60 * 60 * 1000 - - monthAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(monthAgo).toLong() * 30 * 24 * 60 * 60 * 1000 - - yearAgo.any { date.contains(it) } -> - System.currentTimeMillis() - date.removeSuffixes(yearAgo).toLong() * 365 * 24 * 60 * 60 * 1000 - - else -> - SimpleDateFormat("dd/MM/yyyy", Locale.US).parseSafe(date) - } - } - - private fun String.removeSuffixes(suffixes: Set): String = - suffixes.fold(this) { text, suffix -> - text.replace(suffix, "") - }.trim() - - private fun getSortOrderQuery(order: SortOrder, hasTags: Boolean): String { - if (!hasTags) return "" - return when (order) { - SortOrder.UPDATED -> "m_orderby=latest" - SortOrder.POPULARITY -> "m_orderby=views" - SortOrder.ALPHABETICAL -> "m_orderby=alphabet" - SortOrder.RATING -> "m_orderby=rating" - else -> "m_orderby=latest" - } - } - - protected open val tagPath = "genre" - - private suspend fun fetchTags(): Set = webClient.httpGet("https://$domain/$tagPath").parseHtml() - .select("ul.page-genres li a") - .mapToSet { a -> - val title = a.ownText().toTitleCase(sourceLocale) - MangaTag( - key = a.attr("href").substringAfterLast('/'), - title = title, - source = source, - ) - } -} - From 120e9f6fc99900206c688a673649517c863421f9 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:52:03 +0200 Subject: [PATCH 108/109] Create Manga18Parser.kt --- .../kotatsu/parsers/parsers/Manga18Parser.kt | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/Manga18Parser.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/Manga18Parser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/Manga18Parser.kt new file mode 100644 index 00000000..31033fd4 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/parsers/Manga18Parser.kt @@ -0,0 +1,248 @@ +package org.koitharu.kotatsu.parsers.parsers + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.Headers +import org.jsoup.nodes.Document +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.PagedMangaParser +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat +import java.util.* + +internal abstract class Manga18Parser( + context: MangaLoaderContext, + source: MangaParserSource, + domain: String, + pageSize: Int = 20, +) : PagedMangaParser(context, source, pageSize) { + + override val configKeyDomain = ConfigKey.Domain(domain) + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.ALPHABETICAL, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + isSearchWithFiltersSupported = true, + isMultipleTagsSupported = false, + ) + + @Volatile + private var genresCache: Set? = null + private val genresMutex = Mutex() + + private suspend fun getOrFetchGenres(): Set { + genresCache?.let { return it } + return genresMutex.withLock { + genresCache ?: run { + genresCache = fetchAvailableTags() + genresCache!! + } + } + } + + private suspend fun fetchAvailableTags(): Set { + val doc = webClient.httpGet("https://$domain/$listUrl/").parseHtml() + return doc.select("div.grid_cate li").mapNotNullToSet { li -> + val a = li.selectFirst("a") ?: return@mapNotNullToSet null + val href = a.attr("href").removeSuffix('/').substringAfterLast('/') + MangaTag( + key = href, + title = a.text(), + source = source, + ) + } + } + + override fun getRequestHeaders() = Headers.Builder() + .add("Referer", "https://$domain/") + .build() + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = getOrFetchGenres(), + ) + + init { + paginator.firstPage = 1 + searchPaginator.firstPage = 1 + } + + protected open val ongoing: Set = setOf("On Going") + protected open val finished: Set = setOf("Completed") + + protected open val listUrl = "list-manga/" + protected open val tagUrl = "manga-list/" + protected open val datePattern = "dd-MM-yyyy" + + protected open val selectDesc = "div.detail_reviewContent" + protected open val selectState = "div.item:contains(Status) div.info_value" + protected open val selectAlt = "div.item:contains(Other name) div.info_value" + protected open val selectTag = "div.info_value > a[href*=/manga-list/]" + protected open val selectAuthor = "div.info_label:contains(author) + div.info_value, div.info_label:contains(autor) + div.info_value" + protected open val selectArtist = "div.info_label:contains(artist) + div.info_value" + protected open val selectDate = "div.item p" + protected open val selectChapter = "div.chapter_box li" + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val query = filter.query?.trim().orEmpty() + val tag = filter.tags.firstOrNull() + + val url = buildString(120) { + append("https://") + append(domain) + append('/') + + when { + tag != null && query.isNotEmpty() -> { + throw IllegalArgumentException("Search is not supported with tags") + } + tag != null -> { + append(tagUrl) + append(tag.key) + append('/') + append(page) + } + query.isNotEmpty() -> { + append(listUrl) + append(page) + append("?search=") + append(query.urlEncoded()) + append("&order_by=latest") + } + else -> { + append(listUrl) + append(page) + } + } + if (query.isEmpty()) { + append("?order_by=") + when (order) { + SortOrder.POPULARITY -> append("views") + SortOrder.UPDATED -> append("latest") + SortOrder.ALPHABETICAL -> append("name") + else -> append("latest") + } + } + } + + val doc = webClient.httpGet(url).parseHtml() + return parseMangaList(doc) + } + + protected open fun parseMangaList(doc: Document): List { + return doc.select("div.story_item").map { div -> + val a = div.selectFirstOrThrow("a") + val href = a.attrAsRelativeUrl("href") + val title = div.selectFirst("div.mg_info") + ?.selectFirst("div.mg_name a") + ?.text() + ?: a.attr("title") + val cover = div.selectFirst("img")?.src() + + Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + coverUrl = cover, + title = title, + altTitles = emptySet(), + rating = RATING_UNKNOWN, + tags = emptySet(), + authors = emptySet(), + state = null, + source = source, + contentRating = if (isNsfwSource) ContentRating.ADULT else null, + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga = coroutineScope { + val fullUrl = manga.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl).parseHtml() + val info = doc.body().selectFirstOrThrow("div.detail_listInfo") + val chaptersDeferred = async { getChapters(doc) } + val desc = doc.selectFirst(selectDesc)?.html()?.nullIfEmpty() + val state = info.selectFirst(selectState)?.text()?.let { + when (it) { + in ongoing -> MangaState.ONGOING + in finished -> MangaState.FINISHED + else -> null + } + } + val alt = info.selectFirst(selectAlt)?.textOrNull()?.takeUnless { it == "Updating" } + val author = info.selectFirst(selectAuthor)?.textOrNull()?.takeUnless { it == "Updating" } + val artist = info.selectFirst(selectArtist)?.textOrNull()?.takeUnless { it == "Updating" } + val tags = doc.body().select(selectTag).mapToSet { a -> + val key = a.attr("href").removeSuffix('/').substringAfterLast('/') + MangaTag( + key = key, + title = a.text().toTitleCase(), + source = source, + ) + } + manga.copy( + tags = tags, + description = desc.nullIfEmpty(), + altTitles = setOfNotNull(alt), + authors = setOfNotNull(author, artist), + state = state, + chapters = chaptersDeferred.await(), + ) + } + + protected open suspend fun getChapters(doc: Document): List { + val dateFormat = SimpleDateFormat(datePattern, sourceLocale) + return doc.body().select(selectChapter).mapChapters(reversed = true) { i, li -> + val a = li.selectFirstOrThrow("a") + val href = a.attrAsRelativeUrl("href") + val dateText = li.selectFirst(selectDate)?.text() + MangaChapter( + id = generateUid(href), + title = a.textOrNull(), + number = i + 1f, + volume = 0, + url = href, + uploadDate = dateFormat.parseSafe(dateText), + source = source, + scanlator = null, + branch = null, + ) + } + } + + override suspend fun getPages(chapter: MangaChapter): List { + val fullUrl = chapter.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(fullUrl).parseHtml() + val script = doc.selectFirstOrThrow("script:containsData(slides_p_path)") + val encoded = script.data() + .substringAfter('[') + .substringBefore(",]") + .replace("\"", "") + .split(",") + + return encoded.map { encodedUrl -> + val decoded = context.decodeBase64(encodedUrl).toString(Charsets.UTF_8) + val absoluteUrl = decoded.toAbsoluteUrl(domain) + MangaPage( + id = generateUid(absoluteUrl), + url = absoluteUrl, + preview = null, + source = source, + ) + } + } +} From 6f8bbd78d477136f910fd0063eddc0f1ffb9e2c4 Mon Sep 17 00:00:00 2001 From: InvalidDavid <118831950+InvalidDavid@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:52:27 +0200 Subject: [PATCH 109/109] Create Manga18.kt --- .../parsers/site/kotatsu/en/hentais/Manga18.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manga18.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manga18.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manga18.kt new file mode 100644 index 00000000..b6ae5367 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/kotatsu/en/hentais/Manga18.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.parsers.site.kotatsu.en.hentais + +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.parsers.Manga18Parser + +@MangaSourceParser("MANGA18", "Manga18", "en", ContentType.HENTAI) +internal class Manga18(context: MangaLoaderContext) : + Manga18Parser(context, MangaParserSource.MANGA18, "manga18.club")