diff --git a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/CuuTruyenParser.kt b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/CuuTruyenParser.kt index f06c970f..430edcdd 100644 --- a/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/CuuTruyenParser.kt +++ b/src/main/kotlin/org/koitharu/kotatsu/parsers/site/vi/CuuTruyenParser.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.coroutineScope import okhttp3.Headers import okhttp3.Interceptor import okhttp3.Response +import okhttp3.HttpUrl import okhttp3.ResponseBody.Companion.toResponseBody import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaSourceParser @@ -14,185 +15,273 @@ import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.network.UserAgents import org.koitharu.kotatsu.parsers.util.* import org.koitharu.kotatsu.parsers.util.json.* +import java.awt.Color +import java.awt.Graphics2D +import java.awt.image.BufferedImage import java.io.ByteArrayOutputStream import java.text.SimpleDateFormat import java.util.* -import java.util.zip.Inflater +import javax.imageio.ImageIO @MangaSourceParser("CUUTRUYEN", "CuuTruyen", "vi") internal class CuuTruyenParser(context: MangaLoaderContext) : PagedMangaParser(context, MangaParserSource.CUUTRUYEN, 20), Interceptor { - override val configKeyDomain = - ConfigKey.Domain("cuutruyen.net", "nettrom.com", "hetcuutruyen.net", "cuutruyent9sv7.xyz") - - override val availableSortOrders: Set = EnumSet.of( - SortOrder.UPDATED, - SortOrder.POPULARITY, - SortOrder.NEWEST, - ) - - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities( - isSearchSupported = true, - ) - - override suspend fun getFilterOptions() = MangaListFilterOptions() - - override fun getRequestHeaders(): Headers = Headers.Builder() - .add("User-Agent", UserAgents.KOTATSU) - .build() - - private val decryptionKey = "3141592653589793" - - override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { - val url = buildString { - append("https://") - append(domain) - when { - !filter.query.isNullOrEmpty() -> { - append("/api/v2/mangas/search?q=") - append(filter.query.urlEncoded()) - append("&page=") - append(page.toString()) - } - - else -> { - val tag = filter.tags.oneOrThrowIfMany() - if (tag != null) { - append("/api/v2/tags/") - append(tag.key) - } else { - append("/api/v2/mangas") - when (order) { - SortOrder.UPDATED -> append("/recently_updated") - SortOrder.POPULARITY -> append("/top") - SortOrder.NEWEST -> append("/recently_updated") - else -> append("/recently_updated") - } - } - append("?page=") - append(page.toString()) - } - } - - append("&per_page=") - append(pageSize) - } - - val json = webClient.httpGet(url).parseJson() - val data = json.getJSONArray("data") - - return data.mapJSON { jo -> - Manga( - id = generateUid(jo.getLong("id")), - url = "/api/v2/mangas/${jo.getLong("id")}", - publicUrl = "https://$domain/manga/${jo.getLong("id")}", - title = jo.getString("name"), - altTitle = null, - coverUrl = jo.getString("cover_url"), - largeCoverUrl = jo.getString("cover_mobile_url"), - author = jo.getStringOrNull("author_name"), - tags = emptySet(), - state = null, - description = null, - isNsfw = isNsfwSource, - source = source, - rating = RATING_UNKNOWN, - ) - } - } - - override suspend fun getDetails(manga: Manga): Manga = coroutineScope { - val url = "https://" + domain + manga.url - val chapters = async { - webClient.httpGet("$url/chapters").parseJson().getJSONArray("data") - } - val json = webClient.httpGet(url).parseJson().getJSONObject("data") - val chapterDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - - manga.copy( - title = json.getStringOrNull("name") ?: manga.title, - isNsfw = json.getBooleanOrDefault("is_nsfw", manga.isNsfw), - author = json.optJSONObject("author")?.getStringOrNull("name")?.substringBefore(','), - description = json.getString("full_description"), - tags = json.optJSONArray("tags")?.mapJSONToSet { jo -> - MangaTag( - title = jo.getString("name").toTitleCase(sourceLocale), - key = jo.getString("slug"), - source = source, - ) - }.orEmpty(), - chapters = chapters.await().mapJSON { jo -> - val chapterId = jo.getLong("id") - val number = jo.getFloatOrDefault("number", 0f) - MangaChapter( - id = generateUid(chapterId), - name = jo.getStringOrNull("name") ?: number.formatSimple(), - number = number, - volume = 0, - url = "/api/v2/chapters/$chapterId", - scanlator = jo.optString("group_name"), - uploadDate = chapterDateFormat.tryParse(jo.getStringOrNull("created_at")), - branch = null, - source = source, - ) - }.reversed(), - ) - } - - override suspend fun getPages(chapter: MangaChapter): List { - val url = "https://$domain${chapter.url}" - val json = webClient.httpGet(url).parseJson().getJSONObject("data") - - return json.getJSONArray("pages").mapJSON { jo -> - val imageUrl = jo.getString("image_url") - MangaPage( - id = generateUid(jo.getLong("id")), - url = imageUrl, - preview = null, - source = source, - ) - } - } - - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - val response = chain.proceed(request) - - if (!request.url.host.contains(domain, ignoreCase = true)) { - return response - } - - val body = response.body ?: return response - val contentType = body.contentType() - val bytes = body.bytes() - - val decrypted = try { - decompress(decrypt(bytes)) - } catch (e: Exception) { - bytes - } - val newBody = decrypted.toResponseBody(contentType) - return response.newBuilder().body(newBody).build() - } - - private fun decrypt(input: ByteArray): ByteArray { - val key = decryptionKey.toByteArray() - return input.mapIndexed { index, byte -> - (byte.toInt() xor key[index % key.size].toInt()).toByte() - }.toByteArray() - } - - private fun decompress(input: ByteArray): ByteArray { - val inflater = Inflater() - inflater.setInput(input, 0, input.size) - val outputStream = ByteArrayOutputStream(input.size) - val buffer = ByteArray(1024) - while (!inflater.finished()) { - val count = inflater.inflate(buffer) - outputStream.write(buffer, 0, count) - } - return outputStream.toByteArray() - } + override val configKeyDomain = + ConfigKey.Domain("cuutruyen.net", "nettrom.com", "hetcuutruyen.net", "cuutruyent9sv7.xyz") + + override val availableSortOrders: Set = EnumSet.of( + SortOrder.UPDATED, + SortOrder.POPULARITY, + SortOrder.NEWEST, + ) + + override val filterCapabilities: MangaListFilterCapabilities + get() = MangaListFilterCapabilities( + isSearchSupported = true, + ) + + override suspend fun getFilterOptions() = MangaListFilterOptions() + + override fun getRequestHeaders(): Headers = Headers.Builder() + .add("User-Agent", UserAgents.KOTATSU) + .build() + + private val decryptionKey = "3141592653589793".toByteArray() + + override suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List { + val url = buildString { + append("https://") + append(domain) + when { + !filter.query.isNullOrEmpty() -> { + append("/api/v2/mangas/search?q=") + append(filter.query.urlEncoded()) + append("&page=") + append(page.toString()) + } + + else -> { + val tag = filter.tags.oneOrThrowIfMany() + if (tag != null) { + append("/api/v2/tags/") + append(tag.key) + } else { + append("/api/v2/mangas") + when (order) { + SortOrder.UPDATED -> append("/recently_updated") + SortOrder.POPULARITY -> append("/top") + SortOrder.NEWEST -> append("/recently_updated") + else -> append("/recently_updated") + } + } + append("?page=") + append(page.toString()) + } + } + + append("&per_page=") + append(pageSize) + } + + val json = webClient.httpGet(url).parseJson() + val data = json.getJSONArray("data") + + return data.mapJSON { jo -> + Manga( + id = generateUid(jo.getLong("id")), + url = "/api/v2/mangas/${jo.getLong("id")}", + publicUrl = "https://$domain/manga/${jo.getLong("id")}", + title = jo.getString("name"), + altTitle = null, + coverUrl = jo.getString("cover_url"), + largeCoverUrl = jo.getString("cover_mobile_url"), + author = jo.getStringOrNull("author_name"), + tags = emptySet(), + state = null, + description = null, + isNsfw = isNsfwSource, + source = source, + rating = RATING_UNKNOWN, + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga = coroutineScope { + val url = "https://" + domain + manga.url + val chapters = async { + webClient.httpGet("$url/chapters").parseJson().getJSONArray("data") + } + val json = webClient.httpGet(url).parseJson().getJSONObject("data") + val chapterDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + + manga.copy( + title = json.getStringOrNull("name") ?: manga.title, + isNsfw = json.getBooleanOrDefault("is_nsfw", manga.isNsfw), + author = json.optJSONObject("author")?.getStringOrNull("name")?.substringBefore(','), + description = json.getString("full_description"), + tags = json.optJSONArray("tags")?.mapJSONToSet { jo -> + MangaTag( + title = jo.getString("name").toTitleCase(sourceLocale), + key = jo.getString("slug"), + source = source, + ) + }.orEmpty(), + chapters = chapters.await().mapJSON { jo -> + val chapterId = jo.getLong("id") + val number = jo.getFloatOrDefault("number", 0f) + MangaChapter( + id = generateUid(chapterId), + name = jo.getStringOrNull("name") ?: number.formatSimple(), + number = number, + volume = 0, + url = "/api/v2/chapters/$chapterId", + scanlator = jo.optString("group_name"), + uploadDate = chapterDateFormat.tryParse(jo.getStringOrNull("created_at")), + branch = null, + source = source, + ) + }.reversed(), + ) + } + + private val pageSizesMap = mutableMapOf>() + + override suspend fun getPages(chapter: MangaChapter): List { + val url = "https://$domain${chapter.url}" + val json = webClient.httpGet(url).parseJson().getJSONObject("data") + + return json.getJSONArray("pages").mapJSON { jo -> + val imageUrl = jo.getString("image_url") + val id = jo.getLong("id") + pageSizesMap[id] = jo.getInt("width") to jo.getInt("height") + MangaPage( + id = generateUid(id), + url = imageUrl, + preview = null, + source = source, + ) + } + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + + if (!request.url.host.contains(domain, ignoreCase = true)) { + return response + } + + val body = response.body ?: return response + val contentType = body.contentType() + val bytes = body.bytes() + + val pageId = getPageIdFromUrl(request.url) + val (originalWidth, originalHeight) = pageSizesMap[pageId] ?: (0 to 0) + + val decrypted = decryptDRM(bytes, decryptionKey) + val reconstructed = decrypted?.let { + reconstructImage(it, originalWidth, originalHeight) + } ?: bytes + + val newBody = reconstructed.toResponseBody(contentType) + return response.newBuilder().body(newBody).build() + } + + private fun getPageIdFromUrl(url: HttpUrl): Long { + return url.pathSegments.lastOrNull()?.toLongOrNull() ?: 0L + } + + private fun getOriginalWidthFromRequest(request: okhttp3.Request): Int { + val width = request.url.queryParameter("width")?.toIntOrNull() ?: 0 + return width + } + + private fun getOriginalHeightFromRequest(request: okhttp3.Request): Int { + val height = request.url.queryParameter("height")?.toIntOrNull() ?: 0 + return height + } + + private fun decryptDRM(drmData: ByteArray, key: ByteArray): ByteArray? { + return try { + drmData.mapIndexed { index, byte -> + (byte.toInt() xor key[index % key.size].toInt()).toByte() + }.toByteArray() + } catch (e: Exception) { + null + } + } + + private fun reconstructImage(decrypted: ByteArray, originalWidth: Int, originalHeight: Int): ByteArray? { + return try { + val delimiter = "#v".toByteArray() + val delimiterIndex = decrypted.indexOfFirst { + decrypted.sliceArray(it until (it + delimiter.size)).contentEquals(delimiter) + } + if (delimiterIndex == -1) { + return null + } + + val segmentsInfoStart = delimiterIndex + delimiter.size + val segmentsData = decrypted.sliceArray(segmentsInfoStart until decrypted.size) + val segments = String(segmentsData).split("|").filter { it.contains("-") } + + if (segments.isEmpty()) { + return null + } + + val segmentInfo = segments.mapNotNull { seg -> + try { + val (dyStr, heightStr) = seg.split("-") + val dy = if (dyStr.startsWith("dy")) dyStr.substring(2).trim() else dyStr.trim() + val dyInt = dy.toInt() + val height = heightStr.trim().toInt() + dyInt to height + } catch (e: Exception) { + null + } + } + + if (segmentInfo.isEmpty()) { + return null + } + + var finalSegmentInfo = segmentInfo + val totalHeight = finalSegmentInfo.sumOf { it.second } + if (totalHeight != originalHeight) { + val remainingHeight = originalHeight - totalHeight + if (remainingHeight > 0) { + finalSegmentInfo = finalSegmentInfo.toMutableList().apply { add(0 to remainingHeight) } + } + } + + val originalImage = ImageIO.read(decrypted.inputStream()) ?: return null + val newImage = BufferedImage(originalWidth, originalHeight, BufferedImage.TYPE_INT_RGB) + val graphics: Graphics2D = newImage.createGraphics() + + var sy = 0 + for ((dy, segHeight) in finalSegmentInfo) { + if (sy + segHeight > originalHeight) { + break + } + val subImage = originalImage.getSubimage(0, sy, originalWidth, segHeight) + graphics.drawImage(subImage, 0, dy, null) + sy += segHeight + } + graphics.dispose() + + val outputStream = ByteArrayOutputStream() + ImageIO.write(newImage, "JPEG", outputStream) + outputStream.toByteArray() + } catch (e: Exception) { + null + } + } + + private fun ByteArray.indexOfFirst(predicate: (Int) -> Boolean): Int { + for (i in indices) { + if (predicate(i)) return i + } + return -1 + } }