diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/DamCoNuong.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/DamCoNuong.kt index 1160f7d5..30743f9c 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/DamCoNuong.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/DamCoNuong.kt @@ -187,7 +187,7 @@ internal class DamCoNuong(context: MangaLoaderContext) : val doc = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseHtml() doc.selectFirst("script:containsData(window.encryptionConfig)")?.data()?.let { scriptContent -> - val fallbackUrlsRegex = Regex(""""fallbackUrls"\s*:\s*(\[.*?\])""") + val fallbackUrlsRegex = Regex(""""fallbackUrls"\s*:\s*(\[.*?])""") val arrayString = fallbackUrlsRegex.find(scriptContent)?.groupValues?.get(1) ?: return@let val urlRegex = Regex("""(https?:\\?/\\?[^"]+\.(?:jpg|jpeg|png|webp|gif))""") val scriptImages = urlRegex.findAll(arrayString).map { 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 6bca0ee2..40c5f804 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 @@ -238,4 +238,8 @@ internal class KuroNeko(context: MangaLoaderContext) : PagedMangaParser(context, ) calendar.timeInMillis }.getOrDefault(0L) + + companion object { + const val PATH = "AxsAEQdJWk4YDUkHDgcVEwxaBQoHShIXHwYbD1seHAwHOwAKCAYFFw==\n" + } } diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/MimiHentai.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/MimiHentai.kt index d4da7bfc..17520ad7 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/MimiHentai.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/MimiHentai.kt @@ -1,9 +1,14 @@ package org.koitharu.kotatsu.parsers.site.vi +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response import org.json.JSONArray -import org.koitharu.kotatsu.parsers.Broken +import org.json.JSONObject import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser +import org.koitharu.kotatsu.parsers.bitmap.Bitmap +import org.koitharu.kotatsu.parsers.bitmap.Rect import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.core.PagedMangaParser import org.koitharu.kotatsu.parsers.network.UserAgents @@ -13,7 +18,6 @@ import org.koitharu.kotatsu.parsers.util.json.* import java.text.SimpleDateFormat import java.util.* -@Broken("Request from site owner: Open webview to read") @MangaSourceParser("MIMIHENTAI", "MimiHentai", "vi", type = ContentType.HENTAI) internal class MimiHentai(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.MIMIHENTAI, 18) { @@ -40,14 +44,14 @@ internal class MimiHentai(context: MangaLoaderContext) : } override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.ALPHABETICAL, - SortOrder.POPULARITY, - SortOrder.POPULARITY_TODAY, - SortOrder.POPULARITY_WEEK, - SortOrder.POPULARITY_MONTH, - SortOrder.RATING, - ) + SortOrder.UPDATED, + SortOrder.ALPHABETICAL, + SortOrder.POPULARITY, + SortOrder.POPULARITY_TODAY, + SortOrder.POPULARITY_WEEK, + SortOrder.POPULARITY_MONTH, + SortOrder.RATING, + ) override val filterCapabilities: MangaListFilterCapabilities get() = MangaListFilterCapabilities( @@ -65,75 +69,75 @@ internal class MimiHentai(context: MangaLoaderContext) : override suspend fun getFilterOptions() = MangaListFilterOptions(availableTags = fetchTags()) override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val url = buildString { - append("https://") - append("$domain/$apiSuffix") - - if (!filter.query.isNullOrEmpty() || + val url = buildString { + append("https://") + append("$domain/$apiSuffix") + + if (!filter.query.isNullOrEmpty() || !filter.author.isNullOrEmpty() || filter.tags.isNotEmpty() ) { - append("/advance-search?page=") - append(page) - append("&max=18") // page size, avoid rate limit + append("/advance-search?page=") + append(page) + append("&max=18") // page size, avoid rate limit if (!filter.query.isNullOrEmpty()) { append("&name=") append(filter.query.urlEncoded()) } - if (!filter.author.isNullOrEmpty()) { - append("&author=") - append(filter.author.urlEncoded()) + if (!filter.author.isNullOrEmpty()) { + append("&author=") + append(filter.author.urlEncoded()) } - if (filter.tags.isNotEmpty()) { - append("&genre=") - append(filter.tags.joinToString(",") { it.key }) + if (filter.tags.isNotEmpty()) { + append("&genre=") + append(filter.tags.joinToString(",") { it.key }) } if (filter.tagsExclude.isNotEmpty()) { append("&ex=") append(filter.tagsExclude.joinToString(",") { it.key }) } - - append("&sort=") - append( - when (order) { - SortOrder.UPDATED -> "updated_at" - SortOrder.ALPHABETICAL -> "title" - SortOrder.POPULARITY -> "follows" - SortOrder.POPULARITY_TODAY, - SortOrder.POPULARITY_WEEK, + + append("&sort=") + append( + when (order) { + SortOrder.UPDATED -> "updated_at" + SortOrder.ALPHABETICAL -> "title" + SortOrder.POPULARITY -> "follows" + SortOrder.POPULARITY_TODAY, + SortOrder.POPULARITY_WEEK, SortOrder.POPULARITY_MONTH -> "views" - SortOrder.RATING -> "likes" - else -> "" - } + SortOrder.RATING -> "likes" + else -> "" + } ) - } - - else { - append( - when (order) { - SortOrder.UPDATED -> "/tatcatruyen?page=$page&sort=updated_at" - SortOrder.ALPHABETICAL -> "/tatcatruyen?page=$page&sort=title" - SortOrder.POPULARITY -> "/tatcatruyen?page=$page&sort=follows" - SortOrder.POPULARITY_TODAY -> "/tatcatruyen?page=$page&sort=views" - SortOrder.POPULARITY_WEEK -> "/top-manga?page=$page&timeType=1&limit=18" + } + + else { + append( + when (order) { + SortOrder.UPDATED -> "/tatcatruyen?page=$page&sort=updated_at" + SortOrder.ALPHABETICAL -> "/tatcatruyen?page=$page&sort=title" + SortOrder.POPULARITY -> "/tatcatruyen?page=$page&sort=follows" + SortOrder.POPULARITY_TODAY -> "/tatcatruyen?page=$page&sort=views" + SortOrder.POPULARITY_WEEK -> "/top-manga?page=$page&timeType=1&limit=18" SortOrder.POPULARITY_MONTH -> "/top-manga?page=$page&timeType=2&limit=18" - SortOrder.RATING -> "/tatcatruyen?page=$page&sort=likes" - else -> "/tatcatruyen?page=$page&sort=updated_at" // default - } - ) + SortOrder.RATING -> "/tatcatruyen?page=$page&sort=likes" + else -> "/tatcatruyen?page=$page&sort=updated_at" // default + } + ) if (filter.tagsExclude.isNotEmpty()) { append("&ex=") append(filter.tagsExclude.joinToString(",") { it.key }) } - } - } + } + } - val raw = webClient.httpGet(url) + val raw = webClient.httpGet(url) return if (url.contains("/top-manga")) { val data = raw.parseJsonArray() parseTopMangaList(data) @@ -159,7 +163,7 @@ internal class MimiHentai(context: MangaLoaderContext) : } } - val authors = jo.optJSONArray("authors")?.mapJSON { + val authors = jo.optJSONArray("authors")?.mapJSON { it.getString("name") }?.toSet() ?: emptySet() @@ -195,20 +199,20 @@ internal class MimiHentai(context: MangaLoaderContext) : val title = jo.getString("title").takeIf { it.isNotEmpty() } ?: "Web chưa đặt tên" val description = jo.getStringOrNull("description") - val differentNames = mutableSetOf().apply { - jo.optJSONArray("differentNames")?.let { namesArray -> - for (i in 0 until namesArray.length()) { - namesArray.optString(i)?.takeIf { it.isNotEmpty() }?.let { name -> - add(name) - } - } - } - } - - val authors = jo.getJSONArray("authors").mapJSON { - it.getString("name") - }.toSet() - + val differentNames = mutableSetOf().apply { + jo.optJSONArray("differentNames")?.let { namesArray -> + for (i in 0 until namesArray.length()) { + namesArray.optString(i)?.takeIf { it.isNotEmpty() }?.let { name -> + add(name) + } + } + } + } + + val authors = jo.getJSONArray("authors").mapJSON { + it.getString("name") + }.toSet() + val tags = jo.getJSONArray("genres").mapJSON { genre -> MangaTag( key = genre.getLong("id").toString(), @@ -238,7 +242,7 @@ internal class MimiHentai(context: MangaLoaderContext) : override suspend fun getDetails(manga: Manga): Manga { val url = manga.url.toAbsoluteUrl(domain) val json = webClient.httpGet(url).parseJson() - val id = json.getLong("id") + val id = json.getLong("id") val description = json.getStringOrNull("description") val uploaderName = json.getJSONObject("uploader").getString("displayName") @@ -258,7 +262,7 @@ internal class MimiHentai(context: MangaLoaderContext) : id = generateUid(jo.getLong("id")), title = jo.getStringOrNull("title"), number = jo.getFloatOrDefault("order", 0f), - url = "/$apiSuffix/chapter?id=${jo.getLong("id")}", + url = "${jo.getLong("id")}", uploadDate = dateFormat.parse(jo.getString("createdAt"))?.time ?: 0L, source = source, scanlator = uploaderName, @@ -275,13 +279,160 @@ internal class MimiHentai(context: MangaLoaderContext) : } override suspend fun getPages(chapter: MangaChapter): List { - val json = webClient.httpGet(chapter.url.toAbsoluteUrl(domain)).parseJson() + val url = context.decodeBase64(KuroNeko.PATH) + .decodeXorCipher() + .toString(Charsets.UTF_8) + "/" + chapter.url + val json = webClient.httpGet(url).parseJson() + return json.getJSONArray("pages").mapJSON { jo -> + val imageUrl = jo.getString("imageUrl") + val gt = jo.getStringOrNull("drm") + MangaPage( + id = generateUid(imageUrl), + url = if (gt != null) "$imageUrl#$GT$gt" else imageUrl, + preview = null, + source = source, + ) + } + } + + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + val fragment = response.request.url.fragment + + if (fragment == null || !fragment.contains(GT)) { + return response + } + + return context.redrawImageResponse(response) { bitmap -> + val gt = fragment.substringAfter(GT) + runBlocking { + extractMetadata(bitmap, gt) + } + } + } - val info = json.getJSONObject("info") - val manga = info.getJSONObject("manga") + private fun extractMetadata(bitmap: Bitmap, gt: String): Bitmap { + val metadata = JSONObject().apply { + var sw = 0 + var sh = 0 + val pos = JSONObject() + val dims = JSONObject() + + for (t in gt.split("|")) { + when { + t.startsWith("sw:") -> sw = t.substring(3).toInt() + t.startsWith("sh:") -> sh = t.substring(3).toInt() + t.contains("@") && t.contains(">") -> { + val (left, right) = t.split(">") + val (n, rectStr) = left.split("@") + val (x, y, w, h) = rectStr.split(",").map { it.toInt() } + dims.put(n, JSONObject().apply { + put("x", x) + put("y", y) + put("width", w) + put("height", h) + }) + pos.put(n, right) + } + } + } + put("sw", sw) + put("sh", sh) + put("dims", dims) + put("pos", pos) + } + + val sw = metadata.optInt("sw") + val sh = metadata.optInt("sh") + if (sw <= 0 || sh <= 0) return bitmap + + val fullW = bitmap.width + val fullH = bitmap.height + + val working = context.createBitmap(sw, sh).also { k -> + k.drawBitmap(bitmap, Rect(0, 0, sw, sh), Rect(0, 0, sw, sh)) + } - val chapterUrl = "https://$domain/g/${manga.getInt("id")}/chapter/${info.getString("title")}-${info.getInt("id")}" - context.requestBrowserAction(this, chapterUrl) + val keys = arrayOf("00","01","02","10","11","12","20","21","22") + val baseW = sw / 3 + val baseH = sh / 3 + val rw = sw % 3 + val rh = sh % 3 + val defaultDims = HashMap().apply { + for (k in keys) { + val i = k[0].digitToInt() + val j = k[1].digitToInt() + val w = baseW + if (j == 2) rw else 0 + val h = baseH + if (i == 2) rh else 0 + put(k, intArrayOf(j * baseW, i * baseH, w, h)) + } + } + + val dimsJson = metadata.optJSONObject("dims") ?: JSONObject() + val dims = HashMap().apply { + for (k in keys) { + val jo = dimsJson.optJSONObject(k) + if (jo != null) { + put(k, intArrayOf( + jo.getInt("x"), + jo.getInt("y"), + jo.getInt("width"), + jo.getInt("height"), + )) + } else { + put(k, defaultDims.getValue(k)) + } + } + } + + val pos = metadata.optJSONObject("pos") ?: JSONObject() + val inv = HashMap().apply { + val it = pos.keys() + while (it.hasNext()) { + val a = it.next() + val b = pos.getString(a) + put(b, a) + } + } + + val result = context.createBitmap(fullW, fullH) + + for (k in keys) { + val srcKey = inv[k] ?: continue + val s = dims.getValue(k) + val d = dims.getValue(srcKey) + result.drawBitmap( + working, + Rect(s[0], s[1], s[0] + s[2], s[1] + s[3]), + Rect(d[0], d[1], d[0] + d[2], d[1] + d[3]), + ) + } + + if (sh < fullH) { + result.drawBitmap( + bitmap, + Rect(0, sh, fullW, fullH), + Rect(0, sh, fullW, fullH), + ) + } + if (sw < fullW) { + result.drawBitmap( + bitmap, + Rect(sw, 0, fullW, sh), + Rect(sw, 0, fullW, sh), + ) + } + + return result + } + + private fun ByteArray.decodeXorCipher(): ByteArray { + val k = "kotatsuanddokiarethebest" + .toByteArray(Charsets.UTF_8) + + return this.mapIndexed { i, b -> + (b.toInt() xor k[i % k.size].toInt()).toByte() + }.toByteArray() } private suspend fun fetchTags(): Set { @@ -295,4 +446,8 @@ internal class MimiHentai(context: MangaLoaderContext) : ) } } + + companion object { + private const val GT = "gt=" + } }