From 25b23854d8e811379e824c6b51e318582d3fad47 Mon Sep 17 00:00:00 2001 From: dragonx943 Date: Tue, 2 Sep 2025 21:57:54 +0700 Subject: [PATCH] =?UTF-8?q?G=C3=B3c=20Truy=E1=BB=87n=20Tranh=20Vui:=20Add?= =?UTF-8?q?=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/summary.yaml | 2 +- .../parsers/site/vi/GocTruyenTranhVui.kt | 287 ++++++++++++++++++ 2 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/GocTruyenTranhVui.kt diff --git a/.github/summary.yaml b/.github/summary.yaml index b027f95db..72b28d315 100644 --- a/.github/summary.yaml +++ b/.github/summary.yaml @@ -1 +1 @@ -total: 1250 \ No newline at end of file +total: 1251 \ No newline at end of file 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 new file mode 100644 index 000000000..abf066f78 --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/GocTruyenTranhVui.kt @@ -0,0 +1,287 @@ +package org.koitharu.kotatsu.parsers.site.vi + +import androidx.collection.arraySetOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +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 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) { + + override val configKeyDomain = ConfigKey.Domain("goctruyentranhvui17.com") + + private val apiUrl = "https://$domain/api/v2" + 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() + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.NEWEST, + SortOrder.RATING, + ) + + override val filterCapabilities = MangaListFilterCapabilities( + isSearchSupported = true, + isMultipleTagsSupported = true, + ) + + 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") + 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") + + 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") + } + } + + 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() + + 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(':') + + 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() + + 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) + } + } + + 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") + + 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-") + + 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).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 + ) + } + } + + 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), + ) + + companion object { + private const val REQUEST_DELAY_MS = 350L + private const val TOKEN_KEY = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJBbG9uZSBGb3JldmVyIiwiY29taWNJZHMiOltdLCJyb2xlSWQiOm51bGwsImdyb3VwSWQiOm51bGwsImFkbWluIjpmYWxzZSwicmFuayI6MCwicGVybWlzc2lvbiI6W10sImlkIjoiMDAwMTA4NDQyNSIsInRlYW0iOmZhbHNlLCJpYXQiOjE3NTM2OTgyOTAsImVtYWlsIjoibnVsbCJ9.HT080LGjvzfh6XAPmdDZhf5vhnzUhXI4GU8U6tzwlnXWjgMO4VdYL1jsSFWd-s3NBGt-OAt89XnzaQ03iqDyA" + } +}