From fb6e96d921cf9ba77ab38d0de3f709ee94370e8b Mon Sep 17 00:00:00 2001 From: MuhamadSyabitHidayattulloh Date: Mon, 24 Nov 2025 07:38:27 +0700 Subject: [PATCH 1/2] Fix: Ikiru & Kiryuu Parser --- .../dokiteam/doki/parsers/site/id/Ikiru.kt | 14 +- .../doki/parsers/site/id/KiryuuParser.kt | 481 ++++++++++++++++++ .../site/mangareader/id/KiryuuParser.kt | 19 - 3 files changed, 486 insertions(+), 28 deletions(-) create mode 100644 src/main/kotlin/org/dokiteam/doki/parsers/site/id/KiryuuParser.kt delete mode 100644 src/main/kotlin/org/dokiteam/doki/parsers/site/mangareader/id/KiryuuParser.kt diff --git a/src/main/kotlin/org/dokiteam/doki/parsers/site/id/Ikiru.kt b/src/main/kotlin/org/dokiteam/doki/parsers/site/id/Ikiru.kt index 1591e3d..2d9fdb2 100644 --- a/src/main/kotlin/org/dokiteam/doki/parsers/site/id/Ikiru.kt +++ b/src/main/kotlin/org/dokiteam/doki/parsers/site/id/Ikiru.kt @@ -102,15 +102,15 @@ internal class Ikiru(context: MangaLoaderContext) : val formParts = mutableMapOf() formParts["nonce"] = getNonce() - formParts["inclusion"] = "AND" + formParts["inclusion"] = "OR" if (filter.tags.isNotEmpty()) { val genreArray = JSONArray(filter.tags.map { it.key }) formParts["genre"] = genreArray.toString() } else formParts["genre"] = "[]" - formParts["exclusion"] = "AND" + formParts["exclusion"] = "OR" if (filter.tagsExclude.isNotEmpty()) { - val exGenreArray = JSONArray(filter.tags.map { it.key }) + val exGenreArray = JSONArray(filter.tagsExclude.map { it.key }) formParts["genre_exclude"] = exGenreArray.toString() } else formParts["genre_exclude"] = "[]" @@ -151,9 +151,7 @@ internal class Ikiru(context: MangaLoaderContext) : else -> {} } } - if (statusArray.length() > 0) { - formParts["status"] = statusArray.toString() - } + formParts["status"] = statusArray.toString() } else { formParts["status"] = "[]" } @@ -168,9 +166,7 @@ internal class Ikiru(context: MangaLoaderContext) : } if (!filter.query.isNullOrEmpty()) { - filter.query.let { formParts["query"] = it } - } else { - formParts["query"] = "[]" + formParts["query"] = filter.query } val html = httpPost(url, formParts) diff --git a/src/main/kotlin/org/dokiteam/doki/parsers/site/id/KiryuuParser.kt b/src/main/kotlin/org/dokiteam/doki/parsers/site/id/KiryuuParser.kt new file mode 100644 index 0000000..ad6db99 --- /dev/null +++ b/src/main/kotlin/org/dokiteam/doki/parsers/site/id/KiryuuParser.kt @@ -0,0 +1,481 @@ +package org.dokiteam.doki.parsers.site.id + +import okhttp3.Headers +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.jsoup.nodes.Document +import org.dokiteam.doki.parsers.MangaLoaderContext +import org.dokiteam.doki.parsers.MangaSourceParser +import org.dokiteam.doki.parsers.config.ConfigKey +import org.dokiteam.doki.parsers.core.PagedMangaParser +import org.dokiteam.doki.parsers.model.ContentRating +import org.dokiteam.doki.parsers.model.ContentType +import org.dokiteam.doki.parsers.model.Manga +import org.dokiteam.doki.parsers.model.MangaChapter +import org.dokiteam.doki.parsers.model.MangaListFilter +import org.dokiteam.doki.parsers.model.MangaListFilterCapabilities +import org.dokiteam.doki.parsers.model.MangaListFilterOptions +import org.dokiteam.doki.parsers.model.MangaPage +import org.dokiteam.doki.parsers.model.MangaParserSource +import org.dokiteam.doki.parsers.model.MangaState +import org.dokiteam.doki.parsers.model.MangaTag +import org.dokiteam.doki.parsers.model.RATING_UNKNOWN +import org.dokiteam.doki.parsers.model.SortOrder +import org.dokiteam.doki.parsers.util.attrAsAbsoluteUrl +import org.dokiteam.doki.parsers.util.attrAsRelativeUrl +import org.dokiteam.doki.parsers.util.await +import org.dokiteam.doki.parsers.util.generateUid +import org.dokiteam.doki.parsers.util.mapNotNullToSet +import org.dokiteam.doki.parsers.util.parseHtml +import org.dokiteam.doki.parsers.util.requireSrc +import org.dokiteam.doki.parsers.util.src +import org.dokiteam.doki.parsers.util.toAbsoluteUrl +import org.dokiteam.doki.parsers.util.toRelativeUrl +import org.dokiteam.doki.parsers.util.toTitleCase +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.EnumSet +import java.util.Locale + +@MangaSourceParser("KIRYUU", "Kiryuu", "id") +internal class KiryuuParser(context: MangaLoaderContext) : + PagedMangaParser(context, MangaParserSource.KIRYUU, 24, 24) { + + override val configKeyDomain = ConfigKey.Domain("kiryuu03.com") + override val sourceLocale: Locale = Locale.ENGLISH + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } + + override fun getRequestHeaders() = super.getRequestHeaders().newBuilder() + .add("Referer", "https://$domain/") + .add("Origin", "https://$domain") + .build() + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.ALPHABETICAL, + SortOrder.RATING, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isMultipleTagsSupported = true, + isTagsExclusionSupported = true, + isSearchSupported = true, + isSearchWithFiltersSupported = true, + ) + + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = fetchAvailableTags(), + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED), + availableContentTypes = EnumSet.of( + ContentType.MANGA, + ContentType.MANHWA, + ContentType.MANHUA, + ContentType.COMICS, + ContentType.NOVEL, + ), + ) + + private var nonce: String? = null + + private suspend fun getNonce(): String { + if (nonce == null) { + val json = + webClient.httpGet("https://${domain}/wp-admin/admin-ajax.php?type=search_form&action=get_nonce") + val html = json.parseHtml() + val nonceValue = html.select("input[name=search_nonce]").attr("value") + nonce = nonceValue + } + return nonce!! + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val url = "https://${domain}/wp-admin/admin-ajax.php?action=advanced_search" + + val formParts = mutableMapOf() + formParts["nonce"] = getNonce() + + formParts["inclusion"] = "OR" + if (filter.tags.isNotEmpty()) { + val genreArray = JSONArray(filter.tags.map { it.key }) + formParts["genre"] = genreArray.toString() + } else formParts["genre"] = "[]" + + formParts["exclusion"] = "OR" + if (filter.tagsExclude.isNotEmpty()) { + val exGenreArray = JSONArray(filter.tagsExclude.map { it.key }) + formParts["genre_exclude"] = exGenreArray.toString() + } else formParts["genre_exclude"] = "[]" + + formParts["page"] = page.toString() + + if (!filter.author.isNullOrEmpty()) { + val authorArray = JSONArray(filter.author) + formParts["author"] = authorArray.toString() + } else formParts["author"] = "[]" + + formParts["artist"] = "[]" + formParts["project"] = "0" + + if (filter.types.isNotEmpty()) { + val typeArray = JSONArray() + filter.types.forEach { type -> + when (type) { + ContentType.MANGA -> typeArray.put("manga") + ContentType.MANHWA -> typeArray.put("manhwa") + ContentType.MANHUA -> typeArray.put("manhua") + ContentType.COMICS -> typeArray.put("comic") + ContentType.NOVEL -> typeArray.put("novel") + else -> {} + } + } + formParts["type"] = typeArray.toString() + } else { + formParts["type"] = "[]" + } + + if (filter.states.isNotEmpty()) { + val statusArray = JSONArray() + filter.states.forEach { state -> + when (state) { + MangaState.ONGOING -> statusArray.put("ongoing") + MangaState.FINISHED -> statusArray.put("completed") + MangaState.PAUSED -> statusArray.put("on-hiatus") + else -> {} + } + } + formParts["status"] = statusArray.toString() + } else { + formParts["status"] = "[]" + } + + formParts["order"] = "desc" + formParts["orderby"] = when (order) { + SortOrder.UPDATED -> "updated" + SortOrder.POPULARITY -> "popular" + SortOrder.ALPHABETICAL -> "title" + SortOrder.RATING -> "rating" + else -> "popular" + } + + if (!filter.query.isNullOrEmpty()) { + formParts["query"] = filter.query + } + + val html = httpPost(url, formParts) + return parseMangaList(html) + } + + private fun parseMangaList(doc: Document): List { + val mangaList = mutableListOf() + + doc.select("body > div").forEach { divElement -> + val mainLink = divElement.selectFirst("a[href*='/manga/']") ?: return@forEach + val href = mainLink.attrAsRelativeUrl("href") + + if (href.contains("/chapter-")) return@forEach + + val title = divElement.selectFirst("a.text-base, a.text-white, h1")?.text()?.trim() + ?: mainLink.attr("title").ifEmpty { mainLink.text() } + + val coverUrl = divElement.selectFirst("img")?.src() + + val ratingText = divElement.selectFirst(".numscore, span.text-yellow-400")?.text() + val rating = ratingText?.toFloatOrNull()?.let { + if (it > 5) it / 10f else it / 5f + } ?: RATING_UNKNOWN + + val stateText = + divElement.selectFirst("span.bg-accent, p:contains(Ongoing), p:contains(Completed)") + ?.text()?.lowercase() + val state = when { + stateText?.contains("ongoing") == true -> MangaState.ONGOING + stateText?.contains("completed") == true -> MangaState.FINISHED + stateText?.contains("hiatus") == true -> MangaState.PAUSED + else -> null + } + + mangaList.add( + Manga( + id = generateUid(href), + url = href, + title = title, + altTitles = emptySet(), + publicUrl = mainLink.attrAsAbsoluteUrl("href"), + rating = rating, + contentRating = if (isNsfwSource) ContentRating.ADULT else null, + coverUrl = coverUrl, + tags = emptySet(), + state = state, + authors = emptySet(), + source = source, + ), + ) + } + + return mangaList + } + + override suspend fun getDetails(manga: Manga): Manga { + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + + // Manga ID for chapter loading + val mangaId = doc.selectFirst("[hx-get*='manga_id=']") + ?.attr("hx-get") + ?.substringAfter("manga_id=") + ?.substringBefore("&") + ?.trim() + ?: doc.selectFirst("input#manga_id, [data-manga-id]") + ?.let { it.attr("value").ifEmpty { it.attr("data-manga-id") } } + ?: manga.url.substringAfterLast("/manga/").substringBefore("/") + + val titleElement = doc.selectFirst("h1[itemprop=name]") + val title = titleElement?.text() ?: manga.title + + val altTitles = titleElement?.nextElementSibling()?.text() + ?.split(',') + ?.mapNotNull { it.trim().takeIf(String::isNotBlank) } + ?.toSet() + ?: emptySet() + + val description = doc.select("div[itemprop=description]") + .joinToString("\n\n") { it.text() } + .trim() + .takeIf { it.isNotBlank() } + + val coverUrl = doc.selectFirst("div[itemprop=image] > img")?.src() + ?: manga.coverUrl + + val tags = doc.select("a[itemprop=genre]").mapNotNullToSet { a -> + MangaTag( + key = a.attr("href").substringAfterLast("/genre/").removeSuffix("/"), + title = a.text().toTitleCase(), + source = source, + ) + } + + fun findInfoText(key: String): String? { + return doc.select("div.space-y-2 > .flex:has(h4)") + .find { it.selectFirst("h4")?.text()?.contains(key, ignoreCase = true) == true } + ?.selectFirst("p.font-normal")?.text() + } + + val stateText = findInfoText("Status")?.lowercase() + val state = when { + stateText?.contains("ongoing") == true -> MangaState.ONGOING + stateText?.contains("completed") == true -> MangaState.FINISHED + stateText?.contains("hiatus") == true -> MangaState.PAUSED + else -> manga.state + } + + val authors = findInfoText("Author") + ?.split(",") + ?.map { it.trim() } + ?.toSet() ?: emptySet() + + val chapters = loadChapters(mangaId, manga.url.toAbsoluteUrl(domain)) + + return manga.copy( + title = title, + altTitles = altTitles, + description = description, + coverUrl = coverUrl, + tags = tags, + state = state, + authors = authors, + chapters = chapters, + ) + } + + private suspend fun loadChapters( + mangaId: String, + mangaAbsoluteUrl: String, + ): List { + val chapters = mutableListOf() + var page = 1 + + val headers = Headers.Companion.headersOf( + "hx-request", "true", + "hx-target", "chapter-list", + "hx-trigger", "chapter-list", + "Referer", mangaAbsoluteUrl, + ) + + while (true) { + val url = "https://${domain}/wp-admin/admin-ajax.php?manga_id=$mangaId&page=$page&action=chapter_list" + val doc = webClient.httpGet(url, headers).parseHtml() + + val chapterElements = doc.select("div#chapter-list > div[data-chapter-number]") + if (chapterElements.isEmpty()) break + + chapterElements.forEach { element -> + val a = element.selectFirst("a") ?: return@forEach + val href = a.attrAsRelativeUrl("href") + if (href.isBlank()) return@forEach + + val chapterTitle = element.selectFirst("div.font-medium span")?.text()?.trim() ?: "" + val dateText = element.selectFirst("time")?.text() + val number = element.attr("data-chapter-number").toFloatOrNull() ?: -1f + + chapters.add( + MangaChapter( + id = generateUid(href), + title = chapterTitle, + url = href, + number = number, + volume = 0, + scanlator = null, + uploadDate = parseDate(dateText), + branch = null, + source = source, + ), + ) + } + page++ + if (page > 100) break + } + return chapters.reversed() + } + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() + return doc.select("main section section > img").map { img -> + val url = img.requireSrc().toRelativeUrl(domain) + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ) + } + } + + private suspend fun fetchAvailableTags(): Set { + return try { + // Try to fetch from WP JSON API first (like Keiyoshi NatsuId) + val response = webClient.httpGet("https://${domain}/wp-json/wp/v2/genre?per_page=100&page=1&orderby=count&order=desc") + val jsonText = response.body.use { it?.string() } ?: return emptySet() + val jsonArray = org.json.JSONArray(jsonText) + val tags = mutableSetOf() + + for (i in 0 until jsonArray.length()) { + val item = jsonArray.getJSONObject(i) + val slug = item.optString("slug").takeIf { it.isNotBlank() } ?: continue + val name = item.optString("name").takeIf { it.isNotBlank() } ?: continue + + tags += MangaTag( + title = name.toTitleCase(), + key = slug, + source = source + ) + } + tags + } catch (e: Exception) { + // Fallback to advanced-search page method + try { + val doc = webClient.httpGet("https://${domain}/advanced-search/").parseHtml() + val scriptContent = doc.select("script") + .firstOrNull { it.data().contains("var searchTerms") } + ?.data() + ?: return emptySet() + + val jsonString = scriptContent + .substringAfter("var searchTerms =") + .substringBeforeLast(";") + .trim() + + val json = org.json.JSONObject(jsonString) + val genreObject = json.optJSONObject("genre") ?: return emptySet() + val tags = mutableSetOf() + + for (key in genreObject.keys()) { + val item = genreObject.optJSONObject(key) ?: continue + val taxonomy = item.optString("taxonomy") + if (taxonomy != "genre") continue + val slug = item.optString("slug").takeIf { it.isNotBlank() } ?: continue + val name = item.optString("name").takeIf { it.isNotBlank() } ?: continue + + tags += MangaTag( + title = name.toTitleCase(), + key = slug, + source = source + ) + } + tags + } catch (e2: Exception) { + emptySet() + } + } + } + + private fun parseDate(dateStr: String?): Long { + if (dateStr.isNullOrEmpty()) return 0 + + return try { + when { + dateStr.contains("ago") -> { + val number = Regex("""(\d+)""").find(dateStr)?.value?.toIntOrNull() ?: return 0 + val cal = Calendar.getInstance() + when { + dateStr.contains("min") -> cal.apply { add(Calendar.MINUTE, -number) } + dateStr.contains("hour") -> cal.apply { add(Calendar.HOUR, -number) } + dateStr.contains("day") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) } + dateStr.contains("week") -> cal.apply { + add( + Calendar.WEEK_OF_YEAR, + -number, + ) + } + + dateStr.contains("month") -> cal.apply { add(Calendar.MONTH, -number) } + dateStr.contains("year") -> cal.apply { add(Calendar.YEAR, -number) } + else -> cal + }.timeInMillis + } + + else -> { + SimpleDateFormat("MMM dd, yyyy", sourceLocale).parse(dateStr)?.time ?: 0 + } + } + } catch (_: Exception) { + 0 + } + } + + // Utils + private val multipartHttpClient by lazy { + OkHttpClient.Builder() + .build() + } + + private suspend fun httpPost(url: String, form: Map, extraHeaders: Headers? = null): Document { + val body = MultipartBody.Builder().setType(MultipartBody.FORM) + form.forEach { (k, v) -> body.addFormDataPart(k, v) } + + val requestBuilder = Request.Builder() + .url(url) + .post(body.build()) + .addHeader("Referer", "https://${domain}/advanced-search/") + .addHeader("Origin", "https://${domain}") + + if (extraHeaders != null) { + for (name in extraHeaders.names()) { + if (!name.equals("Content-Type", ignoreCase = true)) { + val value = extraHeaders.get(name) ?: continue + requestBuilder.addHeader(name, value) + } + } + } + + val request = requestBuilder.build() + val response = multipartHttpClient.newCall(request).await() + return response.parseHtml() + } +} diff --git a/src/main/kotlin/org/dokiteam/doki/parsers/site/mangareader/id/KiryuuParser.kt b/src/main/kotlin/org/dokiteam/doki/parsers/site/mangareader/id/KiryuuParser.kt deleted file mode 100644 index 601d7ac..0000000 --- a/src/main/kotlin/org/dokiteam/doki/parsers/site/mangareader/id/KiryuuParser.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.dokiteam.doki.parsers.site.mangareader.id - -import org.dokiteam.doki.parsers.MangaLoaderContext -import org.dokiteam.doki.parsers.MangaSourceParser -import org.dokiteam.doki.parsers.model.MangaListFilterCapabilities -import org.dokiteam.doki.parsers.model.MangaParserSource -import org.dokiteam.doki.parsers.site.mangareader.MangaReaderParser - -@MangaSourceParser("KIRYUU", "Kiryuu", "id") -internal class KiryuuParser(context: MangaLoaderContext) : - MangaReaderParser(context, MangaParserSource.KIRYUU, "kiryuu02.com", pageSize = 50, searchPageSize = 10) { - - override val listUrl = "/manga/" - - override val filterCapabilities: MangaListFilterCapabilities - get() = super.filterCapabilities.copy( - isTagsExclusionSupported = false, - ) -} From 7552ab490870daa8fd3fbb28e8249e1953b05afc Mon Sep 17 00:00:00 2001 From: MuhamadSyabitHidayattulloh Date: Mon, 24 Nov 2025 08:12:20 +0700 Subject: [PATCH 2/2] Refactor manga parsers: Remove KiryuuParser and implement NatsuParser for Ikiru and Kiryuu sources - Deleted KiryuuParser.kt and its associated logic. - Introduced NatsuParser as a base class for handling NatsuId WordPress theme. - Created Ikiru.kt and Kiryuu.kt as specific implementations of NatsuParser for their respective sources. - Updated request handling, filtering, and manga list parsing to align with the new structure. --- .../doki/parsers/site/id/KiryuuParser.kt | 481 ------------------ .../{id/Ikiru.kt => natsu/NatsuParser.kt} | 195 +++---- .../doki/parsers/site/natsu/id/Ikiru.kt | 19 + .../doki/parsers/site/natsu/id/Kiryuu.kt | 19 + 4 files changed, 148 insertions(+), 566 deletions(-) delete mode 100644 src/main/kotlin/org/dokiteam/doki/parsers/site/id/KiryuuParser.kt rename src/main/kotlin/org/dokiteam/doki/parsers/site/{id/Ikiru.kt => natsu/NatsuParser.kt} (74%) create mode 100644 src/main/kotlin/org/dokiteam/doki/parsers/site/natsu/id/Ikiru.kt create mode 100644 src/main/kotlin/org/dokiteam/doki/parsers/site/natsu/id/Kiryuu.kt diff --git a/src/main/kotlin/org/dokiteam/doki/parsers/site/id/KiryuuParser.kt b/src/main/kotlin/org/dokiteam/doki/parsers/site/id/KiryuuParser.kt deleted file mode 100644 index ad6db99..0000000 --- a/src/main/kotlin/org/dokiteam/doki/parsers/site/id/KiryuuParser.kt +++ /dev/null @@ -1,481 +0,0 @@ -package org.dokiteam.doki.parsers.site.id - -import okhttp3.Headers -import okhttp3.MultipartBody -import okhttp3.OkHttpClient -import okhttp3.Request -import org.json.JSONArray -import org.jsoup.nodes.Document -import org.dokiteam.doki.parsers.MangaLoaderContext -import org.dokiteam.doki.parsers.MangaSourceParser -import org.dokiteam.doki.parsers.config.ConfigKey -import org.dokiteam.doki.parsers.core.PagedMangaParser -import org.dokiteam.doki.parsers.model.ContentRating -import org.dokiteam.doki.parsers.model.ContentType -import org.dokiteam.doki.parsers.model.Manga -import org.dokiteam.doki.parsers.model.MangaChapter -import org.dokiteam.doki.parsers.model.MangaListFilter -import org.dokiteam.doki.parsers.model.MangaListFilterCapabilities -import org.dokiteam.doki.parsers.model.MangaListFilterOptions -import org.dokiteam.doki.parsers.model.MangaPage -import org.dokiteam.doki.parsers.model.MangaParserSource -import org.dokiteam.doki.parsers.model.MangaState -import org.dokiteam.doki.parsers.model.MangaTag -import org.dokiteam.doki.parsers.model.RATING_UNKNOWN -import org.dokiteam.doki.parsers.model.SortOrder -import org.dokiteam.doki.parsers.util.attrAsAbsoluteUrl -import org.dokiteam.doki.parsers.util.attrAsRelativeUrl -import org.dokiteam.doki.parsers.util.await -import org.dokiteam.doki.parsers.util.generateUid -import org.dokiteam.doki.parsers.util.mapNotNullToSet -import org.dokiteam.doki.parsers.util.parseHtml -import org.dokiteam.doki.parsers.util.requireSrc -import org.dokiteam.doki.parsers.util.src -import org.dokiteam.doki.parsers.util.toAbsoluteUrl -import org.dokiteam.doki.parsers.util.toRelativeUrl -import org.dokiteam.doki.parsers.util.toTitleCase -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.EnumSet -import java.util.Locale - -@MangaSourceParser("KIRYUU", "Kiryuu", "id") -internal class KiryuuParser(context: MangaLoaderContext) : - PagedMangaParser(context, MangaParserSource.KIRYUU, 24, 24) { - - override val configKeyDomain = ConfigKey.Domain("kiryuu03.com") - override val sourceLocale: Locale = Locale.ENGLISH - - override fun onCreateConfig(keys: MutableCollection>) { - super.onCreateConfig(keys) - keys.add(userAgentKey) - } - - override fun getRequestHeaders() = super.getRequestHeaders().newBuilder() - .add("Referer", "https://$domain/") - .add("Origin", "https://$domain") - .build() - - override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.ALPHABETICAL, - SortOrder.RATING, - ) - - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isMultipleTagsSupported = true, - isTagsExclusionSupported = true, - isSearchSupported = true, - isSearchWithFiltersSupported = true, - ) - - override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = fetchAvailableTags(), - availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.PAUSED), - availableContentTypes = EnumSet.of( - ContentType.MANGA, - ContentType.MANHWA, - ContentType.MANHUA, - ContentType.COMICS, - ContentType.NOVEL, - ), - ) - - private var nonce: String? = null - - private suspend fun getNonce(): String { - if (nonce == null) { - val json = - webClient.httpGet("https://${domain}/wp-admin/admin-ajax.php?type=search_form&action=get_nonce") - val html = json.parseHtml() - val nonceValue = html.select("input[name=search_nonce]").attr("value") - nonce = nonceValue - } - return nonce!! - } - - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val url = "https://${domain}/wp-admin/admin-ajax.php?action=advanced_search" - - val formParts = mutableMapOf() - formParts["nonce"] = getNonce() - - formParts["inclusion"] = "OR" - if (filter.tags.isNotEmpty()) { - val genreArray = JSONArray(filter.tags.map { it.key }) - formParts["genre"] = genreArray.toString() - } else formParts["genre"] = "[]" - - formParts["exclusion"] = "OR" - if (filter.tagsExclude.isNotEmpty()) { - val exGenreArray = JSONArray(filter.tagsExclude.map { it.key }) - formParts["genre_exclude"] = exGenreArray.toString() - } else formParts["genre_exclude"] = "[]" - - formParts["page"] = page.toString() - - if (!filter.author.isNullOrEmpty()) { - val authorArray = JSONArray(filter.author) - formParts["author"] = authorArray.toString() - } else formParts["author"] = "[]" - - formParts["artist"] = "[]" - formParts["project"] = "0" - - if (filter.types.isNotEmpty()) { - val typeArray = JSONArray() - filter.types.forEach { type -> - when (type) { - ContentType.MANGA -> typeArray.put("manga") - ContentType.MANHWA -> typeArray.put("manhwa") - ContentType.MANHUA -> typeArray.put("manhua") - ContentType.COMICS -> typeArray.put("comic") - ContentType.NOVEL -> typeArray.put("novel") - else -> {} - } - } - formParts["type"] = typeArray.toString() - } else { - formParts["type"] = "[]" - } - - if (filter.states.isNotEmpty()) { - val statusArray = JSONArray() - filter.states.forEach { state -> - when (state) { - MangaState.ONGOING -> statusArray.put("ongoing") - MangaState.FINISHED -> statusArray.put("completed") - MangaState.PAUSED -> statusArray.put("on-hiatus") - else -> {} - } - } - formParts["status"] = statusArray.toString() - } else { - formParts["status"] = "[]" - } - - formParts["order"] = "desc" - formParts["orderby"] = when (order) { - SortOrder.UPDATED -> "updated" - SortOrder.POPULARITY -> "popular" - SortOrder.ALPHABETICAL -> "title" - SortOrder.RATING -> "rating" - else -> "popular" - } - - if (!filter.query.isNullOrEmpty()) { - formParts["query"] = filter.query - } - - val html = httpPost(url, formParts) - return parseMangaList(html) - } - - private fun parseMangaList(doc: Document): List { - val mangaList = mutableListOf() - - doc.select("body > div").forEach { divElement -> - val mainLink = divElement.selectFirst("a[href*='/manga/']") ?: return@forEach - val href = mainLink.attrAsRelativeUrl("href") - - if (href.contains("/chapter-")) return@forEach - - val title = divElement.selectFirst("a.text-base, a.text-white, h1")?.text()?.trim() - ?: mainLink.attr("title").ifEmpty { mainLink.text() } - - val coverUrl = divElement.selectFirst("img")?.src() - - val ratingText = divElement.selectFirst(".numscore, span.text-yellow-400")?.text() - val rating = ratingText?.toFloatOrNull()?.let { - if (it > 5) it / 10f else it / 5f - } ?: RATING_UNKNOWN - - val stateText = - divElement.selectFirst("span.bg-accent, p:contains(Ongoing), p:contains(Completed)") - ?.text()?.lowercase() - val state = when { - stateText?.contains("ongoing") == true -> MangaState.ONGOING - stateText?.contains("completed") == true -> MangaState.FINISHED - stateText?.contains("hiatus") == true -> MangaState.PAUSED - else -> null - } - - mangaList.add( - Manga( - id = generateUid(href), - url = href, - title = title, - altTitles = emptySet(), - publicUrl = mainLink.attrAsAbsoluteUrl("href"), - rating = rating, - contentRating = if (isNsfwSource) ContentRating.ADULT else null, - coverUrl = coverUrl, - tags = emptySet(), - state = state, - authors = emptySet(), - source = source, - ), - ) - } - - return mangaList - } - - override suspend fun getDetails(manga: Manga): Manga { - val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() - - // Manga ID for chapter loading - val mangaId = doc.selectFirst("[hx-get*='manga_id=']") - ?.attr("hx-get") - ?.substringAfter("manga_id=") - ?.substringBefore("&") - ?.trim() - ?: doc.selectFirst("input#manga_id, [data-manga-id]") - ?.let { it.attr("value").ifEmpty { it.attr("data-manga-id") } } - ?: manga.url.substringAfterLast("/manga/").substringBefore("/") - - val titleElement = doc.selectFirst("h1[itemprop=name]") - val title = titleElement?.text() ?: manga.title - - val altTitles = titleElement?.nextElementSibling()?.text() - ?.split(',') - ?.mapNotNull { it.trim().takeIf(String::isNotBlank) } - ?.toSet() - ?: emptySet() - - val description = doc.select("div[itemprop=description]") - .joinToString("\n\n") { it.text() } - .trim() - .takeIf { it.isNotBlank() } - - val coverUrl = doc.selectFirst("div[itemprop=image] > img")?.src() - ?: manga.coverUrl - - val tags = doc.select("a[itemprop=genre]").mapNotNullToSet { a -> - MangaTag( - key = a.attr("href").substringAfterLast("/genre/").removeSuffix("/"), - title = a.text().toTitleCase(), - source = source, - ) - } - - fun findInfoText(key: String): String? { - return doc.select("div.space-y-2 > .flex:has(h4)") - .find { it.selectFirst("h4")?.text()?.contains(key, ignoreCase = true) == true } - ?.selectFirst("p.font-normal")?.text() - } - - val stateText = findInfoText("Status")?.lowercase() - val state = when { - stateText?.contains("ongoing") == true -> MangaState.ONGOING - stateText?.contains("completed") == true -> MangaState.FINISHED - stateText?.contains("hiatus") == true -> MangaState.PAUSED - else -> manga.state - } - - val authors = findInfoText("Author") - ?.split(",") - ?.map { it.trim() } - ?.toSet() ?: emptySet() - - val chapters = loadChapters(mangaId, manga.url.toAbsoluteUrl(domain)) - - return manga.copy( - title = title, - altTitles = altTitles, - description = description, - coverUrl = coverUrl, - tags = tags, - state = state, - authors = authors, - chapters = chapters, - ) - } - - private suspend fun loadChapters( - mangaId: String, - mangaAbsoluteUrl: String, - ): List { - val chapters = mutableListOf() - var page = 1 - - val headers = Headers.Companion.headersOf( - "hx-request", "true", - "hx-target", "chapter-list", - "hx-trigger", "chapter-list", - "Referer", mangaAbsoluteUrl, - ) - - while (true) { - val url = "https://${domain}/wp-admin/admin-ajax.php?manga_id=$mangaId&page=$page&action=chapter_list" - val doc = webClient.httpGet(url, headers).parseHtml() - - val chapterElements = doc.select("div#chapter-list > div[data-chapter-number]") - if (chapterElements.isEmpty()) break - - chapterElements.forEach { element -> - val a = element.selectFirst("a") ?: return@forEach - val href = a.attrAsRelativeUrl("href") - if (href.isBlank()) return@forEach - - val chapterTitle = element.selectFirst("div.font-medium span")?.text()?.trim() ?: "" - val dateText = element.selectFirst("time")?.text() - val number = element.attr("data-chapter-number").toFloatOrNull() ?: -1f - - chapters.add( - MangaChapter( - id = generateUid(href), - title = chapterTitle, - url = href, - number = number, - volume = 0, - scanlator = null, - uploadDate = parseDate(dateText), - branch = null, - source = source, - ), - ) - } - page++ - if (page > 100) break - } - return chapters.reversed() - } - - override suspend fun getPages(chapter: MangaChapter): List { - val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() - return doc.select("main section section > img").map { img -> - val url = img.requireSrc().toRelativeUrl(domain) - MangaPage( - id = generateUid(url), - url = url, - preview = null, - source = source, - ) - } - } - - private suspend fun fetchAvailableTags(): Set { - return try { - // Try to fetch from WP JSON API first (like Keiyoshi NatsuId) - val response = webClient.httpGet("https://${domain}/wp-json/wp/v2/genre?per_page=100&page=1&orderby=count&order=desc") - val jsonText = response.body.use { it?.string() } ?: return emptySet() - val jsonArray = org.json.JSONArray(jsonText) - val tags = mutableSetOf() - - for (i in 0 until jsonArray.length()) { - val item = jsonArray.getJSONObject(i) - val slug = item.optString("slug").takeIf { it.isNotBlank() } ?: continue - val name = item.optString("name").takeIf { it.isNotBlank() } ?: continue - - tags += MangaTag( - title = name.toTitleCase(), - key = slug, - source = source - ) - } - tags - } catch (e: Exception) { - // Fallback to advanced-search page method - try { - val doc = webClient.httpGet("https://${domain}/advanced-search/").parseHtml() - val scriptContent = doc.select("script") - .firstOrNull { it.data().contains("var searchTerms") } - ?.data() - ?: return emptySet() - - val jsonString = scriptContent - .substringAfter("var searchTerms =") - .substringBeforeLast(";") - .trim() - - val json = org.json.JSONObject(jsonString) - val genreObject = json.optJSONObject("genre") ?: return emptySet() - val tags = mutableSetOf() - - for (key in genreObject.keys()) { - val item = genreObject.optJSONObject(key) ?: continue - val taxonomy = item.optString("taxonomy") - if (taxonomy != "genre") continue - val slug = item.optString("slug").takeIf { it.isNotBlank() } ?: continue - val name = item.optString("name").takeIf { it.isNotBlank() } ?: continue - - tags += MangaTag( - title = name.toTitleCase(), - key = slug, - source = source - ) - } - tags - } catch (e2: Exception) { - emptySet() - } - } - } - - private fun parseDate(dateStr: String?): Long { - if (dateStr.isNullOrEmpty()) return 0 - - return try { - when { - dateStr.contains("ago") -> { - val number = Regex("""(\d+)""").find(dateStr)?.value?.toIntOrNull() ?: return 0 - val cal = Calendar.getInstance() - when { - dateStr.contains("min") -> cal.apply { add(Calendar.MINUTE, -number) } - dateStr.contains("hour") -> cal.apply { add(Calendar.HOUR, -number) } - dateStr.contains("day") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) } - dateStr.contains("week") -> cal.apply { - add( - Calendar.WEEK_OF_YEAR, - -number, - ) - } - - dateStr.contains("month") -> cal.apply { add(Calendar.MONTH, -number) } - dateStr.contains("year") -> cal.apply { add(Calendar.YEAR, -number) } - else -> cal - }.timeInMillis - } - - else -> { - SimpleDateFormat("MMM dd, yyyy", sourceLocale).parse(dateStr)?.time ?: 0 - } - } - } catch (_: Exception) { - 0 - } - } - - // Utils - private val multipartHttpClient by lazy { - OkHttpClient.Builder() - .build() - } - - private suspend fun httpPost(url: String, form: Map, extraHeaders: Headers? = null): Document { - val body = MultipartBody.Builder().setType(MultipartBody.FORM) - form.forEach { (k, v) -> body.addFormDataPart(k, v) } - - val requestBuilder = Request.Builder() - .url(url) - .post(body.build()) - .addHeader("Referer", "https://${domain}/advanced-search/") - .addHeader("Origin", "https://${domain}") - - if (extraHeaders != null) { - for (name in extraHeaders.names()) { - if (!name.equals("Content-Type", ignoreCase = true)) { - val value = extraHeaders.get(name) ?: continue - requestBuilder.addHeader(name, value) - } - } - } - - val request = requestBuilder.build() - val response = multipartHttpClient.newCall(request).await() - return response.parseHtml() - } -} diff --git a/src/main/kotlin/org/dokiteam/doki/parsers/site/id/Ikiru.kt b/src/main/kotlin/org/dokiteam/doki/parsers/site/natsu/NatsuParser.kt similarity index 74% rename from src/main/kotlin/org/dokiteam/doki/parsers/site/id/Ikiru.kt rename to src/main/kotlin/org/dokiteam/doki/parsers/site/natsu/NatsuParser.kt index 2d9fdb2..1ee898c 100644 --- a/src/main/kotlin/org/dokiteam/doki/parsers/site/id/Ikiru.kt +++ b/src/main/kotlin/org/dokiteam/doki/parsers/site/natsu/NatsuParser.kt @@ -1,4 +1,4 @@ -package org.dokiteam.doki.parsers.site.id +package org.dokiteam.doki.parsers.site.natsu import okhttp3.Headers import okhttp3.MultipartBody @@ -7,8 +7,6 @@ import okhttp3.Request import org.json.JSONArray import org.jsoup.nodes.Document import org.dokiteam.doki.parsers.MangaLoaderContext -import org.dokiteam.doki.parsers.MangaSourceParser -import org.dokiteam.doki.parsers.config.ConfigKey import org.dokiteam.doki.parsers.core.PagedMangaParser import org.dokiteam.doki.parsers.model.ContentRating import org.dokiteam.doki.parsers.model.ContentType @@ -39,22 +37,23 @@ import java.util.Calendar import java.util.EnumSet import java.util.Locale -@MangaSourceParser("IKIRU", "Ikiru", "id") -internal class Ikiru(context: MangaLoaderContext) : - PagedMangaParser(context, MangaParserSource.IKIRU, 24, 24) { +/** + * Base parser for NatsuId WordPress theme + * Theme: https://themesinfo.com/natsu_id-theme-wordpress-c8x1c + * Author: Dzul Qurnain + */ +internal abstract class NatsuParser( + context: MangaLoaderContext, + source: MangaParserSource, + pageSize: Int = 24, +) : PagedMangaParser(context, source, pageSize, pageSize) { - override val configKeyDomain = ConfigKey.Domain("02.ikiru.wtf") override val sourceLocale: Locale = Locale.ENGLISH - override fun onCreateConfig(keys: MutableCollection>) { - super.onCreateConfig(keys) - keys.add(userAgentKey) - } - - override fun getRequestHeaders() = super.getRequestHeaders().newBuilder() - .add("Referer", "https://$domain/") - .add("Origin", "https://$domain") - .build() + override fun getRequestHeaders() = super.getRequestHeaders().newBuilder() + .add("Referer", "https://$domain/") + .add("Origin", "https://$domain") + .build() override val availableSortOrders: Set = EnumSet.of( SortOrder.UPDATED, @@ -100,29 +99,29 @@ internal class Ikiru(context: MangaLoaderContext) : val url = "https://${domain}/wp-admin/admin-ajax.php?action=advanced_search" val formParts = mutableMapOf() - formParts["nonce"] = getNonce() + formParts["nonce"] = getNonce() - formParts["inclusion"] = "OR" - if (filter.tags.isNotEmpty()) { - val genreArray = JSONArray(filter.tags.map { it.key }) - formParts["genre"] = genreArray.toString() - } else formParts["genre"] = "[]" + formParts["inclusion"] = "OR" + if (filter.tags.isNotEmpty()) { + val genreArray = JSONArray(filter.tags.map { it.key }) + formParts["genre"] = genreArray.toString() + } else formParts["genre"] = "[]" - formParts["exclusion"] = "OR" - if (filter.tagsExclude.isNotEmpty()) { - val exGenreArray = JSONArray(filter.tagsExclude.map { it.key }) - formParts["genre_exclude"] = exGenreArray.toString() - } else formParts["genre_exclude"] = "[]" + formParts["exclusion"] = "OR" + if (filter.tagsExclude.isNotEmpty()) { + val exGenreArray = JSONArray(filter.tagsExclude.map { it.key }) + formParts["genre_exclude"] = exGenreArray.toString() + } else formParts["genre_exclude"] = "[]" - formParts["page"] = page.toString() + formParts["page"] = page.toString() - if (!filter.author.isNullOrEmpty()) { - val authorArray = JSONArray(filter.author) - formParts["author"] = authorArray.toString() - } else formParts["author"] = "[]" + if (!filter.author.isNullOrEmpty()) { + val authorArray = JSONArray(filter.author) + formParts["author"] = authorArray.toString() + } else formParts["author"] = "[]" - formParts["artist"] = "[]" - formParts["project"] = "0" + formParts["artist"] = "[]" + formParts["project"] = "0" if (filter.types.isNotEmpty()) { val typeArray = JSONArray() @@ -136,10 +135,10 @@ internal class Ikiru(context: MangaLoaderContext) : else -> {} } } - formParts["type"] = typeArray.toString() + formParts["type"] = typeArray.toString() } else { - formParts["type"] = "[]" - } + formParts["type"] = "[]" + } if (filter.states.isNotEmpty()) { val statusArray = JSONArray() @@ -153,10 +152,10 @@ internal class Ikiru(context: MangaLoaderContext) : } formParts["status"] = statusArray.toString() } else { - formParts["status"] = "[]" - } + formParts["status"] = "[]" + } - formParts["order"] = "desc" + formParts["order"] = "desc" formParts["orderby"] = when (order) { SortOrder.UPDATED -> "updated" SortOrder.POPULARITY -> "popular" @@ -165,15 +164,15 @@ internal class Ikiru(context: MangaLoaderContext) : else -> "popular" } - if (!filter.query.isNullOrEmpty()) { - formParts["query"] = filter.query - } + if (!filter.query.isNullOrEmpty()) { + formParts["query"] = filter.query + } - val html = httpPost(url, formParts) + val html = httpPost(url, formParts) return parseMangaList(html) } - private fun parseMangaList(doc: Document): List { + protected open fun parseMangaList(doc: Document): List { val mangaList = mutableListOf() doc.select("body > div").forEach { divElement -> @@ -294,7 +293,7 @@ internal class Ikiru(context: MangaLoaderContext) : ) } - private suspend fun loadChapters( + protected open suspend fun loadChapters( mangaId: String, mangaAbsoluteUrl: String, ): List { @@ -357,39 +356,65 @@ internal class Ikiru(context: MangaLoaderContext) : } } - private suspend fun fetchAvailableTags(): Set { - val doc = webClient.httpGet("https://${domain}/advanced-search/").parseHtml() - val scriptContent = doc.select("script") - .firstOrNull { it.data().contains("var searchTerms") } - ?.data() - ?: return emptySet() - - val jsonString = scriptContent - .substringAfter("var searchTerms =") - .substringBeforeLast(";") - .trim() - - val json = org.json.JSONObject(jsonString) - val genreObject = json.optJSONObject("genre") ?: return emptySet() - val tags = mutableSetOf() - - for (key in genreObject.keys()) { - val item = genreObject.optJSONObject(key) ?: continue - val taxonomy = item.optString("taxonomy") - if (taxonomy != "genre") continue - val slug = item.optString("slug").takeIf { it.isNotBlank() } ?: continue - val name = item.optString("name").takeIf { it.isNotBlank() } ?: continue - - tags += MangaTag( - title = name.toTitleCase(), - key = slug, - source = source - ) - } - return tags - } - - private fun parseDate(dateStr: String?): Long { + protected open suspend fun fetchAvailableTags(): Set { + return try { + // Try to fetch from WP JSON API first (more reliable) + val response = webClient.httpGet("https://${domain}/wp-json/wp/v2/genre?per_page=100&page=1&orderby=count&order=desc") + val jsonText = response.body.use { it?.string() } ?: return emptySet() + val jsonArray = org.json.JSONArray(jsonText) + val tags = mutableSetOf() + + for (i in 0 until jsonArray.length()) { + val item = jsonArray.getJSONObject(i) + val slug = item.optString("slug").takeIf { it.isNotBlank() } ?: continue + val name = item.optString("name").takeIf { it.isNotBlank() } ?: continue + + tags += MangaTag( + title = name.toTitleCase(), + key = slug, + source = source + ) + } + tags + } catch (e: Exception) { + // Fallback to advanced-search page method + try { + val doc = webClient.httpGet("https://${domain}/advanced-search/").parseHtml() + val scriptContent = doc.select("script") + .firstOrNull { it.data().contains("var searchTerms") } + ?.data() + ?: return emptySet() + + val jsonString = scriptContent + .substringAfter("var searchTerms =") + .substringBeforeLast(";") + .trim() + + val json = org.json.JSONObject(jsonString) + val genreObject = json.optJSONObject("genre") ?: return emptySet() + val tags = mutableSetOf() + + for (key in genreObject.keys()) { + val item = genreObject.optJSONObject(key) ?: continue + val taxonomy = item.optString("taxonomy") + if (taxonomy != "genre") continue + val slug = item.optString("slug").takeIf { it.isNotBlank() } ?: continue + val name = item.optString("name").takeIf { it.isNotBlank() } ?: continue + + tags += MangaTag( + title = name.toTitleCase(), + key = slug, + source = source + ) + } + tags + } catch (e2: Exception) { + emptySet() + } + } + } + + protected open fun parseDate(dateStr: String?): Long { if (dateStr.isNullOrEmpty()) return 0 return try { @@ -423,13 +448,13 @@ internal class Ikiru(context: MangaLoaderContext) : } } - // Utils - private val multipartHttpClient by lazy { - OkHttpClient.Builder() - .build() - } + // Utils + private val multipartHttpClient by lazy { + OkHttpClient.Builder() + .build() + } - private suspend fun httpPost(url: String, form: Map, extraHeaders: Headers? = null): Document { + protected open suspend fun httpPost(url: String, form: Map, extraHeaders: Headers? = null): Document { val body = MultipartBody.Builder().setType(MultipartBody.FORM) form.forEach { (k, v) -> body.addFormDataPart(k, v) } diff --git a/src/main/kotlin/org/dokiteam/doki/parsers/site/natsu/id/Ikiru.kt b/src/main/kotlin/org/dokiteam/doki/parsers/site/natsu/id/Ikiru.kt new file mode 100644 index 0000000..71cfd4d --- /dev/null +++ b/src/main/kotlin/org/dokiteam/doki/parsers/site/natsu/id/Ikiru.kt @@ -0,0 +1,19 @@ +package org.dokiteam.doki.parsers.site.natsu.id + +import org.dokiteam.doki.parsers.MangaLoaderContext +import org.dokiteam.doki.parsers.MangaSourceParser +import org.dokiteam.doki.parsers.config.ConfigKey +import org.dokiteam.doki.parsers.model.MangaParserSource +import org.dokiteam.doki.parsers.site.natsu.NatsuParser + +@MangaSourceParser("IKIRU", "Ikiru", "id") +internal class Ikiru(context: MangaLoaderContext) : + NatsuParser(context, MangaParserSource.IKIRU, pageSize = 24) { + + override val configKeyDomain = ConfigKey.Domain("02.ikiru.wtf") + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } +} diff --git a/src/main/kotlin/org/dokiteam/doki/parsers/site/natsu/id/Kiryuu.kt b/src/main/kotlin/org/dokiteam/doki/parsers/site/natsu/id/Kiryuu.kt new file mode 100644 index 0000000..20b3314 --- /dev/null +++ b/src/main/kotlin/org/dokiteam/doki/parsers/site/natsu/id/Kiryuu.kt @@ -0,0 +1,19 @@ +package org.dokiteam.doki.parsers.site.natsu.id + +import org.dokiteam.doki.parsers.MangaLoaderContext +import org.dokiteam.doki.parsers.MangaSourceParser +import org.dokiteam.doki.parsers.config.ConfigKey +import org.dokiteam.doki.parsers.model.MangaParserSource +import org.dokiteam.doki.parsers.site.natsu.NatsuParser + +@MangaSourceParser("KIRYUU", "Kiryuu", "id") +internal class Kiryuu(context: MangaLoaderContext) : + NatsuParser(context, MangaParserSource.KIRYUU, pageSize = 24) { + + override val configKeyDomain = ConfigKey.Domain("kiryuu03.com") + + override fun onCreateConfig(keys: MutableCollection>) { + super.onCreateConfig(keys) + keys.add(userAgentKey) + } +}