diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/GocTruyenTranhVui.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/GocTruyenTranhVui.kt index abf066f7..6740040a 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/GocTruyenTranhVui.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/GocTruyenTranhVui.kt @@ -14,274 +14,264 @@ import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.util.* import org.json.JSONObject import java.util.* -import org.koitharu.kotatsu.parsers.Broken -@Broken("Need to clean code + Testing") @MangaSourceParser("GOCTRUYENTRANHVUI", "Góc Truyện Tranh Vui", "vi") -internal class GocTruyenTranhVui(context: MangaLoaderContext): - PagedMangaParser(context, MangaParserSource.GOCTRUYENTRANHVUI, 50) { +internal class GocTruyenTranhVui(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.GOCTRUYENTRANHVUI, 50) { - override val configKeyDomain = ConfigKey.Domain("goctruyentranhvui17.com") + override val configKeyDomain = ConfigKey.Domain("goctruyentranhvui17.com") + private val apiUrl by lazy { "https://$domain/api/v2" } - private val apiUrl = "https://$domain/api/v2" - private val requestMutex = Mutex() - private var lastRequestTime = 0L + private val requestMutex = Mutex() + private var lastRequestTime = 0L - override fun getRequestHeaders(): Headers = Headers.Builder() - .add("Authorization", TOKEN_KEY) - .add("Referer", "https://$domain/") - .add("X-Requested-With", "XMLHttpRequest") - .build() + private val apiHeaders by lazy { + Headers.Builder() + .add("Authorization", TOKEN_KEY) + .add("Referer", "https://$domain/") + .add("X-Requested-With", "XMLHttpRequest") + .build() + } - override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.NEWEST, - SortOrder.RATING, - ) + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.NEWEST, + SortOrder.RATING + ) - override val filterCapabilities = MangaListFilterCapabilities( - isSearchSupported = true, - isMultipleTagsSupported = true, - ) + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = true, + ) - override suspend fun getFilterOptions() = MangaListFilterOptions( - availableTags = availableTags(), - availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED), - ) + override suspend fun getFilterOptions() = MangaListFilterOptions( + availableTags = availableTags(), + availableStates = EnumSet.of(MangaState.ONGOING, MangaState.FINISHED) + ) - init { - setFirstPage(0) - } + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + enforceRateLimit() + val url = buildString { + append(apiUrl) + append("/search?p=${page - 1}") + if (!filter.query.isNullOrBlank()) { + append("&searchValue=${filter.query.urlEncoded()}") + } - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - enforceRateLimit() - val url = buildString { - append(apiUrl) - append("/search?p=$page") - if (!filter.query.isNullOrBlank()) { - append("&searchValue=${filter.query.urlEncoded()}") - } + val sortValue = when (order) { + SortOrder.POPULARITY -> "viewCount" + SortOrder.NEWEST -> "createdAt" + SortOrder.RATING -> "evaluationScore" + else -> "recentDate" // UPDATED + } + append("&orders%5B%5D=$sortValue") - val sortValue = when (order) { - SortOrder.POPULARITY -> "viewCount" - SortOrder.NEWEST -> "createdAt" - SortOrder.RATING -> "evaluationScore" - else -> "recentDate" // UPDATED - } - append("&orders%5B%5D=$sortValue") + filter.tags.forEach { append("&categories%5B%5D=${it.key}") } - filter.tags.forEach { append("&categories%5B%5D=${it.key}") } + filter.states.forEach { + val statusKey = when (it) { + MangaState.ONGOING -> "PRG" + MangaState.FINISHED -> "END" + else -> null + } + if (statusKey != null) append("&status%5B%5D=$statusKey") + } + } - filter.states.forEach { - val statusKey = when (it) { - MangaState.ONGOING -> "PRG" - MangaState.FINISHED -> "END" - else -> null - } - if (statusKey != null) append("&status%5B%5D=$statusKey") - } - } + val json = webClient.httpGet(url, extraHeaders = apiHeaders).parseJson() + val result = json.optJSONObject("result") ?: return emptyList() + val data = result.optJSONArray("data") ?: return emptyList() - val json = webClient.httpGet(url).parseJson() - val result = json.optJSONObject("result") ?: return emptyList() - val data = result.optJSONArray("data") ?: return emptyList() + return List(data.length()) { i -> + val item = data.getJSONObject(i) + val comicId = item.getString("id") + val slug = item.getString("nameEn") + val mangaUrl = "/truyen/$slug" + val tags = item.optJSONArray("category")?.let { arr -> + (0 until arr.length()).mapNotNullTo(mutableSetOf()) { index -> + val tagName = arr.getString(index) + availableTags().find { it.title.equals(tagName, ignoreCase = true) }?.let { genrePair -> + MangaTag(key = genrePair.key, title = genrePair.title, source = source) + } + } + } ?: emptySet() - return List(data.length()) { i -> - val item = data.getJSONObject(i) - val comicId = item.getString("id") - val slug = item.getString("nameEn") - val mangaUrl = "/truyen/$slug" - val tags = item.optJSONArray("category")?.let { arr -> - (0 until arr.length()).mapNotNullTo(mutableSetOf()) { index -> - val tagName = arr.getString(index) - availableTags().find { it.title.equals(tagName, ignoreCase = true) }?.let { genrePair -> - MangaTag(key = genrePair.key, title = genrePair.title, source = source) - } - } - } ?: emptySet() + Manga( + id = generateUid(comicId), + title = item.getString("name"), + altTitles = item.optString("otherName", "").split(",").mapNotNull { it.trim().takeIf(String::isNotBlank) }.toSet(), + url = "$comicId:$slug", // Store both id and slug, separated by ':' + publicUrl = "https://$domain$mangaUrl", + rating = item.optDouble("evaluationScore", 0.0).toFloat(), + contentRating = null, + coverUrl = "https://$domain${item.getString("photo")}", + tags = tags, + state = when (item.optString("statusCode")) { + "PRG" -> MangaState.ONGOING + "END" -> MangaState.FINISHED + else -> null + }, + authors = setOf(item.optString("author", "Updating")), + source = source + ) + } + } - Manga( - id = generateUid(comicId), - title = item.getString("name"), - altTitles = item.optString("otherName", "").split(",").mapNotNull { it.trim().takeIf(String::isNotBlank) }.toSet(), - url = "$comicId:$slug", // Store both id and slug, separated by ':' - publicUrl = "https://$domain$mangaUrl", - rating = item.optDouble("evaluationScore", 0.0).toFloat(), - contentRating = null, - coverUrl = "https://$domain${item.getString("photo")}", - tags = tags, - state = when (item.optString("statusCode")) { - "PRG" -> MangaState.ONGOING - "END" -> MangaState.FINISHED - else -> null - }, - authors = setOf(item.optString("author", "Updating")), - source = source - ) - } - } + override suspend fun getDetails(manga: Manga): Manga { + val comicId = manga.url.substringBefore(':') + val slug = manga.url.substringAfter(':') - override suspend fun getDetails(manga: Manga): Manga { - val comicId = manga.url.substringBefore(':') - val slug = manga.url.substringAfter(':') + val chapters = try { + enforceRateLimit() + val chapterApiUrl = "https://$domain/api/comic/$comicId/chapter?limit=-1" + val chapterJson = webClient.httpGet(chapterApiUrl, extraHeaders = apiHeaders).parseJson() + val chaptersData = chapterJson.getJSONObject("result").getJSONArray("chapters") - val chapters = try { - enforceRateLimit() - val chapterApiUrl = "https://$domain/api/comic/$comicId/chapter?limit=-1" - val chapterJson = webClient.httpGet(chapterApiUrl).parseJson() - val chaptersData = chapterJson.getJSONObject("result").getJSONArray("chapters") + List(chaptersData.length()) { i -> + val item = chaptersData.getJSONObject(i) + val number = item.getString("numberChapter") + val name = item.getString("name") + val chapterUrl = "/truyen/$slug/chuong-$number" + MangaChapter( + id = generateUid(chapterUrl), + title = if (name != "N/A" && name.isNotBlank()) name else "Chapter $number", + number = number.toFloatOrNull() ?: -1f, + volume = 0, + url = chapterUrl, + scanlator = null, + uploadDate = item.optLong("updateTime", 0L), + branch = null, + source = source + ) + } + } catch (e: Exception) { + emptyList() + }.reversed() - List(chaptersData.length()) { i -> - val item = chaptersData.getJSONObject(i) - val number = item.getString("numberChapter") - val name = item.getString("name") - val chapterUrl = "/truyen/$slug/chuong-$number" - MangaChapter( - id = generateUid(chapterUrl), - title = if (name != "N/A" && name.isNotBlank()) name else "Chapter $number", - number = number.toFloatOrNull() ?: -1f, - volume = 0, - url = chapterUrl, - scanlator = null, - uploadDate = item.optLong("updateTime", 0L), - branch = null, - source = source - ) - } - } catch (e: Exception) { - emptyList() - }.reversed() + enforceRateLimit() + val doc = webClient.httpGet(manga.publicUrl).parseHtml() - enforceRateLimit() - val doc = webClient.httpGet(manga.publicUrl).parseHtml() + val detailTags = doc.select(".group-content > .v-chip-link").mapNotNullTo(mutableSetOf()) { el -> + availableTags().find { it.title.equals(el.text(), ignoreCase = true) }?.let { + MangaTag(key = it.key, title = it.title, source = source) + } + } - val detailTags = doc.select(".group-content > .v-chip-link").mapNotNullTo(mutableSetOf()) { el -> - availableTags().find { it.title.equals(el.text(), ignoreCase = true) }?.let { - MangaTag(key = it.key, title = it.title, source = source) - } - } + return manga.copy( + title = doc.selectFirst(".v-card-title")?.text().orEmpty(), + tags = manga.tags + detailTags, + coverUrl = doc.selectFirst("img.image")?.absUrl("src"), + state = when (doc.selectFirst(".mb-1:contains(Trạng thái:) span")?.text()) { + "Đang thực hiện" -> MangaState.ONGOING + "Hoàn thành" -> MangaState.FINISHED + else -> manga.state + }, + authors = setOfNotNull(doc.selectFirst(".mb-1:contains(Tác giả:) span")?.text()), + description = doc.selectFirst(".v-card-text")?.text(), + chapters = chapters + ) + } - return manga.copy( - title = doc.selectFirst(".v-card-title")?.text().orEmpty(), - tags = manga.tags + detailTags, - coverUrl = doc.selectFirst("img.image")?.absUrl("src"), - state = when (doc.selectFirst(".mb-1:contains(Trạng thái:) span")?.text()) { - "Đang thực hiện" -> MangaState.ONGOING - "Hoàn thành" -> MangaState.FINISHED - else -> manga.state - }, - authors = setOfNotNull(doc.selectFirst(".mb-1:contains(Tác giả:) span")?.text()), - description = doc.selectFirst(".v-card-text")?.text(), - chapters = chapters - ) - } + override suspend fun getPages(chapter: MangaChapter): List { + enforceRateLimit() + val responseBody = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).body?.string() + ?: throw Exception("Response body is null for chapter page") - override suspend fun getPages(chapter: MangaChapter): List { - enforceRateLimit() - val responseBody = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).body?.string() - ?: throw Exception("Response body is null for chapter page") + val chapterJsonRaw = responseBody.substringAfter("chapterJson: `", "").substringBefore("`", "") - val chapterJsonRaw = responseBody.substringAfter("chapterJson: `", "").substringBefore("`", "") + val imageUrls: List + if (chapterJsonRaw.isNotBlank()) { + val json = JSONObject(chapterJsonRaw) + val data = json.getJSONObject("body").getJSONObject("result").getJSONArray("data") + imageUrls = List(data.length()) { i -> data.getString(i) } + } else { + // Fallback: Call the authenticated API + val comicId = responseBody.substringAfter("comic = {id:\"", "").substringBefore("\"", "") + val chapterNumber = chapter.url.substringAfterLast("chuong-") + val nameEn = chapter.url.substringAfter("/truyen/").substringBefore("/chuong-") - val imageUrls: List - if (chapterJsonRaw.isNotBlank()) { - val json = JSONObject(chapterJsonRaw) - val data = json.getJSONObject("body").getJSONObject("result").getJSONArray("data") - imageUrls = List(data.length()) { i -> data.getString(i) } - } else { - // Fallback: Call the authenticated API - val comicId = responseBody.substringAfter("comic = {id:\"", "").substringBefore("\"", "") - val chapterNumber = chapter.url.substringAfterLast("chuong-") - val nameEn = chapter.url.substringAfter("/truyen/").substringBefore("/chuong-") + if (comicId.isBlank()) { + throw Exception("Cannot find comicId in HTML for fallback image request") + } - if (comicId.isBlank()) { - throw Exception("Cannot find comicId in HTML for fallback image request") - } + val formBody = mapOf( + "comicId" to comicId, + "chapterNumber" to chapterNumber, + "nameEn" to nameEn + ) + val authApiUrl = "$apiUrl/chapter/auth".toHttpUrl() + val authResponse = webClient.httpPost(url = authApiUrl, form = formBody, extraHeaders = apiHeaders).parseJson() + val data = authResponse.getJSONObject("result").getJSONArray("data") + imageUrls = List(data.length()) { i -> data.getString(i) } + } - val formBody = mapOf( - "comicId" to comicId, - "chapterNumber" to chapterNumber, - "nameEn" to nameEn - ) - val authApiUrl = "$apiUrl/chapter/auth".toHttpUrl() - val authResponse = webClient.httpPost(url = authApiUrl, form = formBody).parseJson() - val data = authResponse.getJSONObject("result").getJSONArray("data") - imageUrls = List(data.length()) { i -> data.getString(i) } - } + return imageUrls.map { url -> + val finalUrl = if (url.startsWith("/image/")) "https://$domain$url" else url + MangaPage(id = generateUid(finalUrl), url = finalUrl, preview = null, source = source) + } + } - return imageUrls.map { url -> - val finalUrl = if (url.startsWith("/image/")) "https://$domain$url" else url - MangaPage( - id = generateUid(finalUrl), - url = finalUrl, - preview = null, - source = source - ) - } - } + private suspend fun enforceRateLimit() { + requestMutex.withLock { + val currentTime = System.currentTimeMillis() + val timeSinceLastRequest = currentTime - lastRequestTime + if (timeSinceLastRequest < REQUEST_DELAY_MS) { // Vẫn truy cập được REQUEST_DELAY_MS + delay(REQUEST_DELAY_MS - timeSinceLastRequest) + } + lastRequestTime = System.currentTimeMillis() + } + } - private suspend fun enforceRateLimit() { - requestMutex.withLock { - val currentTime = System.currentTimeMillis() - val timeSinceLastRequest = currentTime - lastRequestTime - if (timeSinceLastRequest < REQUEST_DELAY_MS) { - delay(REQUEST_DELAY_MS - timeSinceLastRequest) - } - lastRequestTime = System.currentTimeMillis() - } - } + private fun availableTags() = arraySetOf( + MangaTag("Anime", "ANI", source), + MangaTag("Drama", "DRA", source), + MangaTag("Josei", "JOS", source), + MangaTag("Manhwa", "MAW", source), + MangaTag("One Shot", "OSH", source), + MangaTag("Shounen", "SHO", source), + MangaTag("Webtoons", "WEB", source), + MangaTag("Shoujo", "SHJ", source), + MangaTag("Harem", "HAR", source), + MangaTag("Ecchi", "ECC", source), + MangaTag("Mature", "MAT", source), + MangaTag("Slice of life", "SOL", source), + MangaTag("Isekai", "ISE", source), + MangaTag("Manga", "MAG", source), + MangaTag("Manhua", "MAU", source), + MangaTag("Hành Động", "ACT", source), + MangaTag("Phiêu Lưu", "ADV", source), + MangaTag("Hài Hước", "COM", source), + MangaTag("Võ Thuật", "MAA", source), + MangaTag("Huyền Bí", "MYS", source), + MangaTag("Lãng Mạn", "ROM", source), + MangaTag("Thể Thao", "SPO", source), + MangaTag("Học Đường", "SCL", source), + MangaTag("Lịch Sử", "HIS", source), + MangaTag("Kinh Dị", "HOR", source), + MangaTag("Siêu Nhiên", "SUN", source), + MangaTag("Bi Kịch", "TRA", source), + MangaTag("Trùng Sinh", "RED", source), + MangaTag("Game", "GAM", source), + MangaTag("Viễn Tưởng", "FTS", source), + MangaTag("Khoa Học", "SCF", source), + MangaTag("Truyện Màu", "COI", source), + MangaTag("Người Lớn", "ADU", source), + MangaTag("BoyLove", "BBL", source), + MangaTag("Hầm Ngục", "DUN", source), + MangaTag("Săn Bắn", "HUNT", source), + MangaTag("Ngôn Từ Nhạy Cảm", "NTNC", source), + MangaTag("Doujinshi", "DOU", source), + MangaTag("Bạo Lực", "BLM", source), + MangaTag("Ngôn Tình", "NTT", source), + MangaTag("Nữ Cường", "NCT", source), + MangaTag("Gender Bender", "GDB", source), + MangaTag("Murim", "MRR", source), + MangaTag("Leo Tháp", "LTT", source), + MangaTag("Nấu Ăn", "COO", source) + ) - private fun availableTags() = arraySetOf( - MangaTag("Anime", "ANI", source), - MangaTag("Drama", "DRA", source), - MangaTag("Josei", "JOS", source), - MangaTag("Manhwa", "MAW", source), - MangaTag("One Shot", "OSH", source), - MangaTag("Shounen", "SHO", source), - MangaTag("Webtoons", "WEB", source), - MangaTag("Shoujo", "SHJ", source), - MangaTag("Harem", "HAR", source), - MangaTag("Ecchi", "ECC", source), - MangaTag("Mature", "MAT", source), - MangaTag("Slice of life", "SOL", source), - MangaTag("Isekai", "ISE", source), - MangaTag("Manga", "MAG", source), - MangaTag("Manhua", "MAU", source), - MangaTag("Hành Động", "ACT", source), - MangaTag("Phiêu Lưu", "ADV", source), - MangaTag("Hài Hước", "COM", source), - MangaTag("Võ Thuật", "MAA", source), - MangaTag("Huyền Bí", "MYS", source), - MangaTag("Lãng Mạn", "ROM", source), - MangaTag("Thể Thao", "SPO", source), - MangaTag("Học Đường", "SCL", source), - MangaTag("Lịch Sử", "HIS", source), - MangaTag("Kinh Dị", "HOR", source), - MangaTag("Siêu Nhiên", "SUN", source), - MangaTag("Bi Kịch", "TRA", source), - MangaTag("Trùng Sinh", "RED", source), - MangaTag("Game", "GAM", source), - MangaTag("Viễn Tưởng", "FTS", source), - MangaTag("Khoa Học", "SCF", source), - MangaTag("Truyện Màu", "COI", source), - MangaTag("Người Lớn", "ADU", source), - MangaTag("BoyLove", "BBL", source), - MangaTag("Hầm Ngục", "DUN", source), - MangaTag("Săn Bắn", "HUNT", source), - MangaTag("Ngôn Từ Nhạy Cảm", "NTNC", source), - MangaTag("Doujinshi", "DOU", source), - MangaTag("Bạo Lực", "BLM", source), - MangaTag("Ngôn Tình", "NTT", source), - MangaTag("Nữ Cường", "NCT", source), - MangaTag("Gender Bender", "GDB", source), - MangaTag("Murim", "MRR", source), - MangaTag("Leo Tháp", "LTT", source), - MangaTag("Nấu Ăn", "COO", source), - ) - - companion object { - private const val REQUEST_DELAY_MS = 350L - private const val TOKEN_KEY = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJBbG9uZSBGb3JldmVyIiwiY29taWNJZHMiOltdLCJyb2xlSWQiOm51bGwsImdyb3VwSWQiOm51bGwsImFkbWluIjpmYWxzZSwicmFuayI6MCwicGVybWlzc2lvbiI6W10sImlkIjoiMDAwMTA4NDQyNSIsInRlYW0iOmZhbHNlLCJpYXQiOjE3NTM2OTgyOTAsImVtYWlsIjoibnVsbCJ9.HT080LGjvzfh6XAPmdDZhf5vhnzUhXI4GU8U6tzwlnXWjgMO4VdYL1jsSFWd-s3NBGt-OAt89XnzaQ03iqDyA" - } + companion object { + private const val REQUEST_DELAY_MS = 350L + private const val TOKEN_KEY = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJBbG9uZSBGb3JldmVyIiwiY29taWNJZHMiOltdLCJyb2xlSWQiOm51bGwsImdyb3VwSWQiOm51bGwsImFkbWluIjpmYWxzZSwicmFuayI6MCwicGVybWlzc2lvbiI6W10sImlkIjoiMDAwMTA4NDQyNSIsInRlYW0iOmZhbHNlLCJpYXQiOjE3NTM2OTgyOTAsImVtYWlsIjoibnVsbCJ9.HT080LGjvzfh6XAPmdDZhf5vhnzUhXI4GU8U6tzwlnXWjgMO4VdYL1jsSFWd-s3NBGt-OAt89XnzaQ03iqDyA" + } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/KuroNeko.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/KuroNeko.kt index 1fbdb84b..de15ce3d 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/KuroNeko.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/KuroNeko.kt @@ -1,6 +1,8 @@ package org.koitharu.kotatsu.parsers.site.vi -import org.jsoup.nodes.Document +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser import org.koitharu.kotatsu.parsers.config.ConfigKey @@ -10,11 +12,13 @@ import org.koitharu.kotatsu.parsers.util.* import java.util.* @MangaSourceParser("KURONEKO", "Kuro Neko / vi-Hentai", "vi", type = ContentType.HENTAI) -internal class KuroNeko(context: MangaLoaderContext): - PagedMangaParser(context, MangaParserSource.KURONEKO, 30) { +internal class KuroNeko(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.KURONEKO, 30) { override val configKeyDomain = ConfigKey.Domain("vi-hentai.moe") + private val pagesRequestMutex = Mutex() + private var lastPagesRequestTime = 0L + override fun onCreateConfig(keys: MutableCollection>) { super.onCreateConfig(keys) keys.add(userAgentKey) @@ -101,7 +105,7 @@ internal class KuroNeko(context: MangaLoaderContext): append("&keyword=") append(filter.query.urlEncoded()) } - + if (page > 1) { append("&page=") append(page) @@ -129,33 +133,33 @@ internal class KuroNeko(context: MangaLoaderContext): } } - val doc = parseHttp(url) { it } - - return doc.select("div.grid div.relative").map { div -> - val href = div.selectFirst("a[href^=/truyen/]")?.attrOrNull("href") - ?: div.parseFailed("Không thể tìm thấy nguồn ảnh của Manga này!") - 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(), - altTitles = emptySet(), - url = href, - publicUrl = href.toAbsoluteUrl(domain), - rating = RATING_UNKNOWN, - contentRating = ContentRating.ADULT, - coverUrl = coverUrl.orEmpty(), - tags = setOf(), - state = null, - authors = emptySet(), - source = source, - ) - } + val doc = webClient.httpGet(url).parseHtml() + return doc.select("div.grid div.relative") + .map { div -> + val href = div.selectFirst("a[href^=/truyen/]")?.attrOrNull("href") + ?: div.parseFailed("Không thể tìm thấy nguồn ảnh của Manga này!") + 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(), + altTitles = emptySet(), + url = href, + publicUrl = href.toAbsoluteUrl(domain), + rating = RATING_UNKNOWN, + contentRating = ContentRating.ADULT, + coverUrl = coverUrl.orEmpty(), + tags = setOf(), + state = null, + authors = emptySet(), + source = source, + ) + } } override suspend fun getDetails(manga: Manga): Manga { - val root = parseHttp(manga.url.toAbsoluteUrl(domain)) { it } + val root = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() val author = root.selectFirst("div.mt-2:contains(Tác giả) span a")?.textOrNull() return manga.copy( @@ -180,7 +184,6 @@ internal class KuroNeko(context: MangaLoaderContext): val name = a.selectFirst("span.text-ellipsis")?.text().orEmpty() val dateText = a.parent()?.selectFirst("span.timeago")?.attr("datetime").orEmpty() val scanlator = root.selectFirst("div.mt-2:contains(Nhóm dịch) span a")?.textOrNull() - MangaChapter( id = generateUid(href), title = name, @@ -197,12 +200,18 @@ internal class KuroNeko(context: MangaLoaderContext): } override suspend fun getPages(chapter: MangaChapter): List { - val doc = parseHttp(chapter.url.toAbsoluteUrl(domain)) { it } - return doc.select("div.text-center img.lazy").mapNotNull { img -> - val url = img.attr("src").takeIf { it.isNotBlank() } - ?: img.attr("data-src").takeIf { it.isNotBlank() } - ?: return@mapNotNull null + pagesRequestMutex.withLock { + val currentTime = System.currentTimeMillis() + val timeSinceLastRequest = currentTime - lastPagesRequestTime + if (timeSinceLastRequest < PAGES_REQUEST_DELAY_MS) { + delay(PAGES_REQUEST_DELAY_MS - timeSinceLastRequest) + } + lastPagesRequestTime = System.currentTimeMillis() + } + val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() + return doc.select("div.text-center img").mapNotNull { img -> + val url = img.requireSrc() MangaPage( id = generateUid(url), url = url, @@ -243,15 +252,8 @@ internal class KuroNeko(context: MangaLoaderContext): calendar.timeInMillis }.getOrDefault(0L) - private suspend fun parseHttp(url: String, block: (Document) -> T): T { - // 15 reqs / minute - kotlinx.coroutines.delay(4000L) - val doc = webClient.httpGet(url).parseHtml() - return block(doc) - } - - companion object { + private const val PAGES_REQUEST_DELAY_MS = 5000L const val PATH = "AxsAEQdJWk4YDUkHDgcVEwxaBQoHShIXHwYbD1seHAwHOwAKCAYFFw==\n" } }