From 8f851282b4ebfb1e6d07f49a04114772fdd639fa Mon Sep 17 00:00:00 2001 From: devi Date: Sun, 28 Jul 2024 01:08:01 +0200 Subject: [PATCH] Add XManhwa, MangaMana, MangaFr Fix XoxoComics close #928 --- .../kotatsu/parsers/site/fr/MangaMana.kt | 336 ++++++++++++++++++ .../kotatsu/parsers/site/madara/id/XManhwa.kt | 15 + .../kotatsu/parsers/site/scan/ScanParser.kt | 84 ++--- .../kotatsu/parsers/site/scan/fr/MangaFr.kt | 50 +++ .../parsers/site/wpcomics/en/XoxoComics.kt | 57 +-- 5 files changed, 466 insertions(+), 76 deletions(-) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/fr/MangaMana.kt create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/id/XManhwa.kt create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/scan/fr/MangaFr.kt diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/fr/MangaMana.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/fr/MangaMana.kt new file mode 100644 index 00000000..c16b7643 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/fr/MangaMana.kt @@ -0,0 +1,336 @@ +package org.koitharu.kotatsu.parsers.site.fr + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import okhttp3.Headers +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.jsoup.Jsoup +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.PagedMangaParser +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.model.* +import org.koitharu.kotatsu.parsers.util.* +import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault +import org.koitharu.kotatsu.parsers.util.json.mapJSON +import java.text.SimpleDateFormat +import java.util.* + +@MangaSourceParser("MANGAMANA", "MangaMana", "fr") +internal class MangaMana(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.MANGAMANA, 25) { + + override val availableSortOrders: Set = + EnumSet.of( + SortOrder.UPDATED, + SortOrder.RATING, + SortOrder.ALPHABETICAL, + SortOrder.ALPHABETICAL_DESC, + SortOrder.NEWEST, + ) + + override val availableStates: Set = + EnumSet.of(MangaState.ONGOING, MangaState.FINISHED, MangaState.ABANDONED) + + override val configKeyDomain = ConfigKey.Domain("www.manga-mana.com") + + override val isMultipleTagsSupported = false + + override suspend fun getListPage(page: Int, filter: MangaListFilter?): List { + + val postData = buildString { + append("page=") + append(page) + when (filter) { + is MangaListFilter.Search -> { + if (page > 1) { + return emptyList() + } + val domainCdn = "cdn" + domain.removePrefix("www") + val json = webClient.httpGet("https://$domain/search-live?q=${filter.query}").parseJsonArray() + return json.mapJSON { jo -> + val slug = jo.getString("slug") ?: throw Exception("Missing Slug") + val url = "https://$domain/m/$slug" + val img = "https://$domainCdn/uploads/manga/$slug/cover/cover_thumb.jpg" + Manga( + id = generateUid(url), + title = jo.getString("name").orEmpty(), + coverUrl = img, + altTitle = jo.getString("otherNames").orEmpty(), + author = null, + isNsfw = when (jo.getIntOrDefault("caution", 0)) { + 0 -> false + 2 -> true + else -> false + }, + rating = RATING_UNKNOWN, + url = url, + description = jo.getString("summary_old").orEmpty(), + publicUrl = url, + tags = emptySet(), + state = when (jo.getIntOrDefault("status_id_fr", 4)) { + 1 -> MangaState.ONGOING + 2 -> MangaState.FINISHED + 3 -> MangaState.ABANDONED + else -> null + }, + source = source, + ) + } + + } + + is MangaListFilter.Advanced -> { + + if (filter.sortOrder == SortOrder.UPDATED) { + + if (filter.tags.isNotEmpty() or filter.states.isNotEmpty()) { + throw IllegalArgumentException("Le filtrage par « tri par : mis à jour » avec les genres ou les statuts n'est pas pris en charge par cette source.") + } + + val doc = webClient.httpGet("https://$domain/?page=$page").parseHtml() + return doc.select("div.row div.col_home").map { div -> + val href = div.selectFirstOrThrow("h4 a").attrAsRelativeUrl("href") + val isNsfw = div.selectFirst("img[data-adult]")?.attr("data-adult")?.isNotEmpty() ?: false + val img = if (isNsfw) { + div.selectFirst("img")?.attr("data-adult") + } else { + div.selectFirst("img")?.attr("data-src")?.replace(" ", "") + } + Manga( + id = generateUid(href), + title = div.select("h4").text(), + altTitle = null, + url = href, + publicUrl = href.toAbsoluteUrl(domain), + rating = RATING_UNKNOWN, + isNsfw = isNsfw, + coverUrl = img.orEmpty(), + description = null, + tags = emptySet(), + state = null, + author = null, + source = source, + ) + } + } else { + filter.tags.oneOrThrowIfMany()?.let { + append("&category=") + append(it.key) + } + + filter.states.oneOrThrowIfMany()?.let { + append("&status=") + append( + when (it) { + MangaState.ONGOING -> "1" + MangaState.FINISHED -> "2" + MangaState.ABANDONED -> "3" + else -> "" + }, + ) + } + + append("&sort_by=") + when (filter.sortOrder) { + SortOrder.RATING -> append("score&sort_dir=desc") + SortOrder.NEWEST -> append("updated_at&sort_dir=desc") + SortOrder.ALPHABETICAL -> append("name&sort_dir=asc") + SortOrder.ALPHABETICAL_DESC -> append("name&sort_dir=desc") + else -> append("updated_at&sort_dir=desc") + } + } + } + + null -> append("&sort_by=updated_at&sort_dir=desc") + } + } + + val url = "https://$domain/liste-mangas" + val token = webClient.httpGet(url).parseHtml().selectFirstOrThrow("meta[name=csrf-token]").attr("content") + val headers = Headers.Builder().add("X-CSRF-TOKEN", token).add("X-Requested-With", "XMLHttpRequest") + .add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").build() + val doc = makeRequest(url, postData.toRequestBody(), headers) + + return doc.select("div.p-2 div.col").map { div -> + val href = div.selectFirstOrThrow("a").attrAsRelativeUrl("href") + val isNsfw = div.selectFirst("img[data-adult]")?.attr("data-adult")?.isNotEmpty() ?: false + val img = if (isNsfw) { + div.selectFirst("img")?.attr("data-adult") + } else { + div.selectFirst("img")?.attr("data-src")?.replace(" ", "") + } + Manga( + id = generateUid(href), + title = div.select("h2.fs-6").text(), + altTitle = doc.selectFirst(".mangalist_item_othernames")?.text().orEmpty(), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + rating = div.getElementById("avgrating")?.ownText()?.toFloat()?.div(5f) ?: RATING_UNKNOWN, + isNsfw = isNsfw, + coverUrl = img.orEmpty(), + description = div.selectFirst(".mangalist_item_description")?.text().orEmpty(), + tags = div.select("div.mb-1 a").mapNotNullToSet { + val key = it.attr("href").substringAfterLast('=') + MangaTag( + key = key, + title = it.text(), + source = source, + ) + }, + state = null, + author = null, + source = source, + ) + } + } + + + private suspend fun makeRequest(url: String, payload: RequestBody, headers: Headers): Document { + var retryCount = 0 + val backoffDelay = 2000L // Initial delay (milliseconds) + val request = Request.Builder().url(url).post(payload).headers(headers).build() + while (true) { + try { + return Jsoup.parse(context.httpClient.newCall(request).execute().parseJson().getString("html")) + + } catch (e: Exception) { + // Log or handle the exception as needed + if (++retryCount <= 5) { + withContext(Dispatchers.Default) { + delay(backoffDelay) + } + } else { + throw e + } + } + } + } + + override suspend fun getDetails(manga: Manga): Manga = coroutineScope { + val mangaUrl = manga.url.toAbsoluteUrl(domain) + val doc = webClient.httpGet(mangaUrl).parseHtml() + val maxPageChapterSelect = doc.select("ul.pagination a.page-link") + var maxPageChapter = 1 + if (!maxPageChapterSelect.isNullOrEmpty()) { + maxPageChapterSelect.map { + val i = it.attr("href").substringAfterLast("=").toInt() + if (i > maxPageChapter) { + maxPageChapter = i + } + } + } + manga.copy( + state = when (doc.select("div.show_details div.d-flex:contains(Statut) span").text()) { + "En Cours" -> MangaState.ONGOING + "Terminé" -> MangaState.FINISHED + "Abandonné" -> MangaState.ABANDONED + else -> null + }, + author = doc.selectFirst("div.show_details span[itemprop=author]")?.text().orEmpty(), + description = doc.selectFirst("dd[itemprop=description]")?.text(), + rating = doc.getElementById("avgrating")?.ownText()?.toFloat()?.div(5f) ?: RATING_UNKNOWN, + tags = doc.select("ul.list-unstyled li a.category").mapNotNullToSet { + val key = it.attr("href").substringAfterLast('=') + MangaTag( + key = key, + title = it.text(), + source = source, + ) + }, + chapters = run { + if (maxPageChapter == 1) { + parseChapters(doc) + } else { + coroutineScope { + val result = ArrayList(parseChapters(doc)) + result.ensureCapacity(result.size * maxPageChapter) + (2..maxPageChapter).map { i -> + async { + loadChapters(mangaUrl, i) + } + }.awaitAll() + .flattenTo(result) + result + } + } + }.reversed(), + ) + } + + + private suspend fun loadChapters(baseUrl: String, page: Int): List { + return parseChapters(webClient.httpGet("$baseUrl?page=$page").parseHtml().body()) + } + + private val dateFormat = SimpleDateFormat("d MMM yyyy", sourceLocale) + + private fun parseChapters(doc: Element): List { + return doc.select("ul.list-unstyled li a.chapter_link") + .mapChapters { i, a -> + val href = a.attrAsRelativeUrl("href") + val name = a.selectFirst(".chapter div")?.html()?.substringBefore("<") ?: "Chapitre $i" + val dateText = a.selectFirst(".small")?.text() + val chapterN = href.substringAfterLast('/').replace("-", ".").replace("[^0-9.]".toRegex(), "").toFloat() + MangaChapter( + id = generateUid(href), + name = name, + number = chapterN, + volume = 0, + url = href, + scanlator = null, + uploadDate = dateFormat.tryParse(dateText), + branch = null, + source = source, + ) + } + } + + override suspend fun getPages(chapter: MangaChapter): List { + val chapterUrl = chapter.url.toAbsoluteUrl(domain) + val docs = webClient.httpGet(chapterUrl).parseHtml() + + val cdn = docs.selectFirstOrThrow("script:containsData(var cdn = )").data().substringAfterLast("var cdn = \"") + .substringBefore('"') + val domainCdn = cdn + domain.removePrefix("www") + val slugManga = chapterUrl.substringAfter("/m/").substringBeforeLast('/') + val slugChapter = chapterUrl.substringAfterLast('/') + + val script = docs.selectFirstOrThrow("script:containsData(var pages =)") + val json = JSONArray(script.data().substringAfter("pages = ").substringBefore("; var next_chapter")) + val pages = ArrayList(json.length()) + for (i in 0 until json.length()) { + val img = json.getJSONObject(i).getString("image") + val v = json.getJSONObject(i).getInt("version") + val url = "https://$domainCdn/uploads/manga/$slugManga/chapters_fr/$slugChapter/$img?$v" + pages.add( + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ), + ) + } + return pages + } + + override suspend fun getAvailableTags(): Set { + val doc = webClient.httpGet("https://$domain/liste-mangas").parseHtml() + return doc.select("select.selectpicker option").drop(1).mapNotNullToSet { + MangaTag( + key = it.attr("value"), + title = it.text(), + source = source, + ) + } + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/id/XManhwa.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/id/XManhwa.kt new file mode 100644 index 00000000..3d18ba7f --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/madara/id/XManhwa.kt @@ -0,0 +1,15 @@ +package org.koitharu.kotatsu.parsers.site.madara.id + +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.site.madara.MadaraParser +import java.util.Locale + +@MangaSourceParser("XMANHWA", "XManhwa", "id", ContentType.HENTAI) +internal class XManhwa(context: MangaLoaderContext) : + MadaraParser(context, MangaParserSource.XMANHWA, "www.xmanhwa.me", 10) { + override val sourceLocale: Locale = Locale.ENGLISH + override val selectPage = "img" +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/scan/ScanParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/scan/ScanParser.kt index 91b7884c..5731b17f 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/scan/ScanParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/scan/ScanParser.kt @@ -3,11 +3,13 @@ package org.koitharu.kotatsu.parsers.site.scan import androidx.collection.ArrayMap import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.jsoup.Jsoup import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.PagedMangaParser import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.* +import org.koitharu.kotatsu.parsers.util.json.unescapeJson import java.text.SimpleDateFormat import java.util.* @@ -22,6 +24,8 @@ internal abstract class ScanParser( EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.UPDATED, SortOrder.POPULARITY, SortOrder.RATING) override val configKeyDomain = ConfigKey.Domain(domain) + protected open val listUrl = "/manga" + override suspend fun getListPage(page: Int, filter: MangaListFilter?): List { var query = false @@ -38,7 +42,7 @@ internal abstract class ScanParser( is MangaListFilter.Advanced -> { - append("/manga") + append(listUrl) append("?q=") append( when (filter.sortOrder) { @@ -60,62 +64,40 @@ internal abstract class ScanParser( } null -> { - append("/manga?page=") + append(listUrl) + append("?page=") append(page.toString()) } } } - if (query) { - val doc = webClient.httpGet(url).parseRaw() - - val list = if (doc.contains("grid-item-series")) { - doc.split("grid-item-series").drop(1) - } else { - doc.split("class=\\u0022series\\u0022\\").drop(1) - } - - return list.map { l -> - val href = l.substringAfter("href=\\u0022\\").substringBefore("\\u0022").replace("\\", "") - val cover = l.substringAfter("data-src=\\u0022").substringBefore("\\u0022\\u003E").replace("\\", "") - val title = l.substringAfter("item-title\\u0022\\u003E").substringBefore("\\u003C\\/p\\u003E").ifEmpty { - l.substringAfter("\\u003Ch3\\u003E").substringBefore("\\u003C\\/h3\\u003E") - } - Manga( - id = generateUid(href), - url = href, - publicUrl = href.toAbsoluteUrl(domain), - coverUrl = cover, - title = title, - altTitle = null, - rating = RATING_UNKNOWN, - tags = emptySet(), - author = null, - state = null, - source = source, - isNsfw = isNsfwSource, - ) - } + val doc = if (query) { + val raw = webClient.httpGet(url).parseRaw() + Jsoup.parseBodyFragment( + raw.unescapeJson(), + domain, + ) } else { - val doc = webClient.httpGet(url).parseHtml() - return doc.select(".series-paginated .series, .series-paginated .grid-item-series").map { div -> - val href = div.selectFirstOrThrow("a").attrAsRelativeUrl("href") - Manga( - id = generateUid(href), - url = href, - publicUrl = href.toAbsoluteUrl(div.host ?: domain), - coverUrl = div.selectFirst("img")?.attr("data-src")?.replace("\t", "").orEmpty(), - title = div.selectFirstOrThrow(".link-series h3, .item-title").text().orEmpty(), - altTitle = null, - rating = RATING_UNKNOWN, - tags = emptySet(), - author = null, - state = null, - source = source, - isNsfw = isNsfwSource, - ) - } + webClient.httpGet(url).parseHtml() + } + + return doc.select(".series, .series-paginated .grid-item-series").map { div -> + val href = div.selectFirstOrThrow("a").attrAsRelativeUrl("href") + Manga( + id = generateUid(href), + url = href, + publicUrl = href.toAbsoluteUrl(div.host ?: domain), + coverUrl = div.selectFirst("img")?.attr("data-src")?.replace("\t", "").orEmpty(), + title = div.selectFirstOrThrow(".link-series h3, .item-title").text().orEmpty(), + altTitle = null, + rating = RATING_UNKNOWN, + tags = emptySet(), + author = null, + state = null, + source = source, + isNsfw = isNsfwSource, + ) } } @@ -130,7 +112,7 @@ internal abstract class ScanParser( protected suspend fun getOrCreateTagMap(): Map = mutex.withLock { tagCache?.let { return@withLock it } val tagMap = ArrayMap() - val tagElements = webClient.httpGet("https://$domain/manga").parseHtml() + val tagElements = webClient.httpGet("https://$domain$listUrl").parseHtml() .requireElementById("filter-wrapper") .select(".form-filters div.form-check, .form-filters div.custom-control") for (el in tagElements) { diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/scan/fr/MangaFr.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/scan/fr/MangaFr.kt new file mode 100644 index 00000000..880abf9d --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/scan/fr/MangaFr.kt @@ -0,0 +1,50 @@ +package org.koitharu.kotatsu.parsers.site.scan.fr + +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaSourceParser +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.MangaTag +import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN +import org.koitharu.kotatsu.parsers.site.scan.ScanParser +import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat + +@MangaSourceParser("MANGAFR", "MangaFr", "fr") +internal class MangaFr(context: MangaLoaderContext) : + ScanParser(context, MangaParserSource.MANGAFR, "www.mangafr.org") { + override val listUrl = "/series" + + override suspend fun getAvailableTags(): Set = emptySet() + + override suspend fun getDetails(manga: Manga): Manga { + val doc = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + val dateFormat = SimpleDateFormat("MM-dd-yyyy", sourceLocale) + return manga.copy( + rating = doc.selectFirst(".card-series-detail .rate-value span, .card-series-about .rate-value span") + ?.ownText()?.toFloatOrNull()?.div(5f) + ?: RATING_UNKNOWN, + tags = emptySet(), + author = doc.selectFirst(".card-series-detail .col-6:contains(Autore) div, .card-series-about .mb-3:contains(Autore) a") + ?.text(), + altTitle = doc.selectFirst(".card div.col-12.mb-4 h2, .card-series-about .h6")?.text().orEmpty(), + description = doc.selectFirst(".card div.col-12.mb-4 p, .card-series-desc .mb-4 p")?.html().orEmpty(), + chapters = doc.select(".chapters-list .col-chapter, .card-list-chapter .col-chapter") + .mapChapters(reversed = true) { i, div -> + val href = div.selectFirstOrThrow("a").attrAsRelativeUrl("href") + MangaChapter( + id = generateUid(href), + name = div.selectFirstOrThrow("h5").html().substringBefore(""), + number = i + 1f, + volume = 0, + url = href, + scanlator = null, + uploadDate = dateFormat.tryParse(doc.selectFirstOrThrow("h5 div").text()), + branch = null, + source = source, + ) + }, + ) + } +} diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/en/XoxoComics.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/en/XoxoComics.kt index f2b93ca8..e3b5eacd 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/en/XoxoComics.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/wpcomics/en/XoxoComics.kt @@ -1,14 +1,13 @@ package org.koitharu.kotatsu.parsers.site.wpcomics.en import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import org.jsoup.nodes.Document import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.site.wpcomics.WpComicsParser import org.koitharu.kotatsu.parsers.util.* +import java.text.SimpleDateFormat import java.util.* @MangaSourceParser("XOXOCOMICS", "XoxoComics", "en", ContentType.COMICS) @@ -119,7 +118,7 @@ internal class XoxoComics(context: MangaLoaderContext) : 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 chaptersDeferred = async { loadChapters(fullUrl) } val desc = doc.selectFirstOrThrow(selectDesc).html() val stateDiv = doc.selectFirst(selectState) val state = stateDiv?.let { @@ -146,29 +145,37 @@ internal class XoxoComics(context: MangaLoaderContext) : ) } - override suspend fun getChapters(doc: Document): List { - val pages = doc.select("ul.pagination > li:not(.active)") - return if (pages.size <= 1) { - super.getChapters(doc) - } else { - val subPageChapterList = coroutineScope { - pages.mapNotNull { page -> - val a = page.selectFirst("a") ?: return@mapNotNull null - if (a.text().isNumeric()) { - val href = a.attrAsAbsoluteUrl("href") - async { - super.getChapters(webClient.httpGet(href).parseHtml()).asReversed() - } - } else { - null // TODO support pagination with overflow - } - }.awaitAll().flatten() - } - val firstPageChapterList = super.getChapters(doc).asReversed().toMutableList() - firstPageChapterList.addAll(subPageChapterList) - firstPageChapterList.reverse() - firstPageChapterList.mapIndexed { i, x -> x.copy(volume = x.volume, number = (i + 1).toFloat()) } + private val dateFormat = SimpleDateFormat("MM/dd/yyyy", sourceLocale) + + private suspend fun loadChapters(baseUrl: String): List { + val chapters = ArrayList() + var page = 0 + while (true) { + ++page + val doc = webClient.httpGet("$baseUrl?page=$page").parseHtml() + doc.selectFirst("#nt_listchapter nav ul li:not(.heading)") ?: break + chapters.addAll( + doc.select("#nt_listchapter nav ul li:not(.heading)").mapChapters { _, li -> + val a = li.selectFirstOrThrow("a") + val href = a.attr("href") + val dateText = li.selectFirst("div.col-xs-3")?.text() + MangaChapter( + id = generateUid(href), + name = a.text(), + number = 0f, + volume = 0, + url = href, + scanlator = null, + uploadDate = dateFormat.tryParse(dateText), + branch = null, + source = source, + ) + + }, + ) } + chapters.reverse() + return chapters.mapIndexed { i, x -> x.copy(volume = x.volume, number = (i + 1).toFloat()) } } override suspend fun getPages(chapter: MangaChapter): List {