diff --git a/.github/summary.yaml b/.github/summary.yaml index cdc8a9b0..6f6e52b0 100644 --- a/.github/summary.yaml +++ b/.github/summary.yaml @@ -1 +1 @@ -total: 1177 \ No newline at end of file +total: 1178 \ No newline at end of file diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/VcomycsParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/VcomycsParser.kt new file mode 100644 index 00000000..58e240ea --- /dev/null +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/VcomycsParser.kt @@ -0,0 +1,241 @@ +package org.koitharu.kotatsu.parsers.site.vi + +import org.json.JSONObject +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.ContentRating +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities +import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.attrAsRelativeUrl +import org.koitharu.kotatsu.parsers.util.attrOrNull +import org.koitharu.kotatsu.parsers.util.attrOrThrow +import org.koitharu.kotatsu.parsers.util.domain +import org.koitharu.kotatsu.parsers.util.generateUid +import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull +import org.koitharu.kotatsu.parsers.util.mapChapters +import org.koitharu.kotatsu.parsers.util.mapToSet +import org.koitharu.kotatsu.parsers.util.oneOrThrowIfMany +import org.koitharu.kotatsu.parsers.util.parseHtml +import org.koitharu.kotatsu.parsers.util.parseJson +import org.koitharu.kotatsu.parsers.util.selectFirstOrThrow +import org.koitharu.kotatsu.parsers.util.src +import org.koitharu.kotatsu.parsers.util.textOrNull +import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl +import org.koitharu.kotatsu.parsers.util.toRelativeUrl +import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.koitharu.kotatsu.parsers.util.urlEncoded +import java.util.EnumSet +import javax.crypto.Cipher +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +@MangaSourceParser("VCOMYCS", "Vcomycs", "vi", ContentType.MANGA) +internal class VcomycsParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.VCOMYCS, 36) { + + override val configKeyDomain: ConfigKey.Domain + get() = ConfigKey.Domain("vivicomi.store") + + override val availableSortOrders: Set + get() = EnumSet.of(SortOrder.UPDATED) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + ) + + override suspend fun getFilterOptions(): MangaListFilterOptions = MangaListFilterOptions( + availableTags = fetchTags(), + ) + + private suspend fun fetchTags(): Set { + return webClient.httpGet("/so-do-trang".toAbsoluteUrl(domain)).parseHtml() + .selectFirstOrThrow(".sitemap-content .tags") + .select("a") + .mapToSet(::parseTag) + } + + private fun parseTag(tagEl: Element): MangaTag { + return MangaTag( + title = tagEl.text().toTitleCase(), + key = tagEl.attrAsRelativeUrl("href"), + source = source, + ) + } + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + if (!filter.query.isNullOrEmpty()) { + if (page > 1) return emptyList() + + val payload = "action=searchtax&keyword=${filter.query.urlEncoded()}" + return webClient.httpPost("/wp-admin/admin-ajax.php".toAbsoluteUrl(domain), payload) + .parseJson().getJSONArray("data") + .mapJSONNotNull { jo -> + val status = jo.getString("cstatus") + if (status == "Nhóm dịch" || status == "Tin tức") return@mapJSONNotNull null + + val relativeUrl = jo.getString("link").toRelativeUrl(domain) + Manga( + id = generateUid(relativeUrl), + title = jo.getString("title"), + altTitle = null, + url = relativeUrl, + publicUrl = relativeUrl.toAbsoluteUrl(domain), + rating = RATING_UNKNOWN, + contentRating = null, + coverUrl = jo.getString("img"), + tags = emptySet(), + state = null, + author = null, + largeCoverUrl = null, + description = null, + chapters = null, + source = source, + ) + } + } + + val url = filter.tags.oneOrThrowIfMany()?.let { "${it.key}?page=$page" } ?: "/page/$page" + val pageContent = webClient.httpGet(url.toAbsoluteUrl(domain)).parseHtml() + if (pageContent.selectFirst(".pnf-404")?.text() == "Hết trang rồi!") { + return emptyList() + } + + return parseMangaList(pageContent) + } + + private fun parseMangaList(page: Document): List { + return page.selectFirstOrThrow("div.comic-list") + .select(".comic-img").map { item -> + val linkEl = item.selectFirstOrThrow("a") + val relativeUrl = linkEl.attrAsRelativeUrl("href") + Manga( + id = generateUid(relativeUrl), + title = linkEl.attrOrThrow("title"), + altTitle = null, + url = relativeUrl, + publicUrl = relativeUrl.toAbsoluteUrl(domain), + rating = RATING_UNKNOWN, + contentRating = null, + coverUrl = linkEl.selectFirstOrThrow(".img-thumbnail").src(), + tags = emptySet(), + state = null, + author = null, + largeCoverUrl = null, + description = null, + chapters = null, + source = source, + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga { + val content = webClient.httpGet(manga.url.toAbsoluteUrl(domain)).parseHtml() + val info = content.selectFirstOrThrow(".comic-info") + return manga.copy( + rating = info.getElementById("cate-rating")?.let { + val score = it.attrOrNull("data-score")?.toIntOrNull() + val vote = it.attrOrNull("data-votes")?.toIntOrNull() + if (score == null || vote == null || vote == 0) return@let null + score / (vote * 10f) + } ?: RATING_UNKNOWN, + altTitle = info.selectFirst(".comic-intro-text > strong:contains(Tên khác:)")?.nextElementSibling() + ?.textOrNull(), + author = info.selectFirst(".comic-intro-text > strong:contains(Tác giả:)")?.nextElementSibling() + ?.textOrNull(), + state = when (info.selectFirst(".comic-stt")?.text()) { + "Đang tiến hành" -> MangaState.ONGOING + "Trọn bộ" -> MangaState.FINISHED + else -> null + }, + tags = info.select("div.tags > a").mapToSet(::parseTag), + description = content.selectFirst(".intro-container > div.text-justify")?.let { + it.selectFirst(".hide-long-text-shadow")?.remove() + it.html() + }, + contentRating = if (content.getElementById("adult-modal") != null) { + ContentRating.ADULT + } else { + ContentRating.SAFE + }, + chapters = content.select(".chapter-table .table-scroll tbody > tr a") + .mapChapters(reversed = true) { index, element -> + val url = element.attrAsRelativeUrl("href") + MangaChapter( + id = generateUid(url), + name = element.selectFirst("span")?.text().orEmpty().trim(), + number = index + 1f, + volume = 0, + url = url, + scanlator = null, + uploadDate = 0L, + branch = null, + source = source, + ) + }, + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val encryptedContent = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)) + .parseHtml() + .selectFirstOrThrow("#view-chapter script").data() + .substringAfter('\"') + .substringBeforeLast('\"') + .replace("\\\"", "\"") + + val images = decryptImages(encryptedContent) + return Jsoup.parse(images).select("img").map { img -> + val url = img.attrOrThrow("data-ehwufp") + .replace("EhwuFp", ".") + .replace("SJkhMV", ":") + .replace("uUPzrw", "/") + MangaPage( + id = generateUid(url), + url = url, + preview = null, + source = source, + ) + } + } + + private fun decryptImages(secret: String): String { + val json = JSONObject(secret) + val salt = json.getString("salt").decodeHex() + val iv = json.getString("iv").decodeHex() + val cipherText = context.decodeBase64(json.getString("ciphertext")) + + val keySpec = PBEKeySpec("EhwuFpSJkhMVuUPzrw".toCharArray(), salt, 999, 256) + val secretKey = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512").generateSecret(keySpec).encoded + val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(secretKey, "AES"), IvParameterSpec(iv)) + return cipher.doFinal(cipherText).toString(Charsets.UTF_8) + } + + override suspend fun getRelatedManga(seed: Manga): List { + val pageDocument = webClient.httpGet(seed.url.toAbsoluteUrl(domain)).parseHtml() + return parseMangaList(pageDocument) + } + + private fun String.decodeHex(): ByteArray { + check(length % 2 == 0) { "Must have an even length" } + + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } +}