From 8e7146ec24ff8d3cb91292d869a9f3a404ca3f62 Mon Sep 17 00:00:00 2001 From: heddxh Date: Wed, 18 Jun 2025 21:59:23 +0800 Subject: [PATCH] Fix Comic Growl (#9009) * fetch popular mangas * fetch pages * descramble image * clean code * fix date time parse * get all chapters * add dto for page response * move dto and descrambler into separate files * happily use parseAs * add dummy url for missing chapters * set different icons for lock and pay chapters * search and latest * get all authors * clean code * remove comment and unneeded json field * fix incorrectly http url conversion --- src/all/comicgrowl/build.gradle | 3 +- .../extension/all/comicgrowl/ComicGrowl.kt | 231 ++++++++++++++---- .../all/comicgrowl/ImageDescrambler.kt | 70 ++++++ .../extension/all/comicgrowl/PageResponse.kt | 16 ++ 4 files changed, 277 insertions(+), 43 deletions(-) create mode 100644 src/all/comicgrowl/src/eu/kanade/tachiyomi/extension/all/comicgrowl/ImageDescrambler.kt create mode 100644 src/all/comicgrowl/src/eu/kanade/tachiyomi/extension/all/comicgrowl/PageResponse.kt diff --git a/src/all/comicgrowl/build.gradle b/src/all/comicgrowl/build.gradle index 64f04920d..e96a04371 100644 --- a/src/all/comicgrowl/build.gradle +++ b/src/all/comicgrowl/build.gradle @@ -1,9 +1,8 @@ ext { extName = 'Comic Growl' extClass = '.ComicGrowl' - themePkg = 'gigaviewer' baseUrl = 'https://comic-growl.com' - overrideVersionCode = 0 + extVersionCode = 7 isNsfw = false } diff --git a/src/all/comicgrowl/src/eu/kanade/tachiyomi/extension/all/comicgrowl/ComicGrowl.kt b/src/all/comicgrowl/src/eu/kanade/tachiyomi/extension/all/comicgrowl/ComicGrowl.kt index 94b29f977..a3e21c8b4 100644 --- a/src/all/comicgrowl/src/eu/kanade/tachiyomi/extension/all/comicgrowl/ComicGrowl.kt +++ b/src/all/comicgrowl/src/eu/kanade/tachiyomi/extension/all/comicgrowl/ComicGrowl.kt @@ -1,63 +1,212 @@ package eu.kanade.tachiyomi.extension.all.comicgrowl -import eu.kanade.tachiyomi.multisrc.gigaviewer.GigaViewer import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import keiyoushi.utils.parseAs +import keiyoushi.utils.tryParse +import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document import org.jsoup.nodes.Element +import java.text.SimpleDateFormat +import java.util.Locale -// TODO: get manga status -// TODO: filter by status -// TODO: change cdnUrl as a array(upstream) -class ComicGrowl : GigaViewer( - "コミックグロウル", - "https://comic-growl.com", - "all", - "https://cdn-img.comic-growl.com/public/page", -) { +class ComicGrowl( + override val lang: String = "all", + override val baseUrl: String = "https://comic-growl.com", + override val name: String = "コミックグロウル", + override val supportsLatest: Boolean = true, +) : ParsedHttpSource() { - override val publisher = "BUSHIROAD WORKS" + override val client = super.client.newBuilder() + .addNetworkInterceptor(ImageDescrambler::interceptor) + .build() - override val chapterListMode = CHAPTER_LIST_LOCKED - - override val supportsLatest: Boolean = true - - override val client: OkHttpClient = - super.client.newBuilder().addInterceptor(::imageIntercept).build() - - override fun popularMangaRequest(page: Int): Request = GET(baseUrl, headers) - - // Show only ongoing works - override fun popularMangaSelector(): String = "ul[class=\"lineup-list ongoing\"] > li > div > a" - - override fun popularMangaFromElement(element: Element) = SManga.create().apply { - title = element.select("h5").text() - thumbnail_url = element.select("div > img").attr("data-src") - setUrlWithoutDomain(element.attr("href")) + override fun headersBuilder(): Headers.Builder { + return super.headersBuilder().set("Referer", "$baseUrl/") } - override fun latestUpdatesSelector() = - "div[class=\"update latest\"] > div.card-board > " + "div[class~=card]:not([class~=ad]) > div > a" + override fun popularMangaRequest(page: Int) = GET("$baseUrl/ranking/manga", headers) - override fun latestUpdatesFromElement(element: Element) = SManga.create().apply { - title = element.select("div.data h3").text() - thumbnail_url = element.select("div.thumb-container img").attr("data-src") - setUrlWithoutDomain(element.attr("href")) + override fun popularMangaNextPageSelector() = null + + override fun popularMangaSelector() = ".ranking-item" + + override fun popularMangaFromElement(element: Element): SManga { + return SManga.create().apply { + setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href")) + title = element.selectFirst(".title-text")!!.text() + setImageUrlFromElement(element) + } } - override fun getCollections(): List = listOf( - Collection("連載作品", ""), - ) + override fun mangaDetailsParse(document: Document): SManga { + val infoElement = document.selectFirst(".series-h-info")!! + val authorElements = infoElement.select(".series-h-credit-user-item .article-text") + val updateDateElement = infoElement.selectFirst(".series-h-tag-label") + return SManga.create().apply { + title = infoElement.selectFirst("h1 > span:not(.g-hidden)")!!.text() + author = authorElements.joinToString { it.text() } + description = infoElement.selectFirst(".series-h-credit-info-text-text p")?.wholeText()?.trim() + setImageUrlFromElement(document.selectFirst(".series-h-img")) + status = if (updateDateElement != null) SManga.ONGOING else SManga.COMPLETED + } + } + + override fun chapterListRequest(manga: SManga) = GET(baseUrl + manga.url + "/list", headers) + + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + return document.select(chapterListSelector()).mapIndexed { index, element -> + chapterFromElement(element).apply { + chapter_number = index.toFloat() + if (url.isEmpty()) { // need login, set a dummy url and append lock icon for chapter name + val hasLockElement = element.selectFirst(".g-payment-article.wait-free-enabled") + url = response.request.url.newBuilder().fragment("$index-$DUMMY_URL_SUFFIX").build().toString() + name = (if (hasLockElement != null) LOCK_ICON else PAY_ICON) + name + } + } + } + } + + override fun chapterListSelector() = ".article-ep-list-item-img-link" + + override fun chapterFromElement(element: Element): SChapter { + return SChapter.create().apply { + setUrlWithoutDomain(element.absUrl("data-href")) + name = element.selectFirst(".series-ep-list-item-h-text")!!.text() + setUploadDate(element.selectFirst(".series-ep-list-date-time")) + scanlator = PUBLISHER + } + } + + override fun pageListRequest(chapter: SChapter): Request { + if (chapter.url.endsWith(DUMMY_URL_SUFFIX)) { + throw Exception("Login required to see this chapter") + } + return super.pageListRequest(chapter) + } + + override fun pageListParse(document: Document): List { + val pageList = mutableListOf() + + // Get some essential info from document + val viewer = document.selectFirst("#comici-viewer")!! + val comiciViewerId = viewer.attr("comici-viewer-id") + val memberJwt = viewer.attr("data-member-jwt") + val requestUrl = "$baseUrl/book/contentsInfo".toHttpUrl().newBuilder() + .addQueryParameter("comici-viewer-id", comiciViewerId) + .addQueryParameter("user-id", memberJwt) + .addQueryParameter("page-from", "0") + + // Initial request to get total pages + val initialRequest = GET(requestUrl.addQueryParameter("page-to", "1").build(), headers) + client.newCall(initialRequest).execute().use { initialResponseRaw -> + if (!initialResponseRaw.isSuccessful) { + throw Exception("Failed to get page list") + } + + // Get all pages + val pageTo = initialResponseRaw.parseAs().totalPages.toString() + val getAllPagesUrl = requestUrl.setQueryParameter("page-to", pageTo).build() + val getAllPagesRequest = GET(getAllPagesUrl, headers) + client.newCall(getAllPagesRequest).execute().use { + if (!it.isSuccessful) { + throw Exception("Failed to get page list") + } + + it.parseAs().result.forEach { resultItem -> + // Origin scramble string is something like [6, 9, 14, 15, 8, 3, 4, 12, 1, 5, 0, 7, 13, 2, 11, 10] + val scramble = resultItem.scramble.drop(1).dropLast(1).replace(", ", "-") + // Add fragment to let interceptor descramble the image + val imageUrl = resultItem.imageUrl.toHttpUrl().newBuilder().fragment(scramble).build() + + pageList.add( + Page(index = resultItem.sort, imageUrl = imageUrl.toString()), + ) + } + } + } + return pageList + } + + override fun imageUrlParse(document: Document): String { + throw UnsupportedOperationException() + } override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - if (query.isNotEmpty()) { - val url = "$baseUrl/search".toHttpUrl().newBuilder().addQueryParameter("q", query) + val searchUrl = "$baseUrl/search".toHttpUrl().newBuilder() + .setQueryParameter("keyword", query) + .setQueryParameter("page", page.toString()) + .build() + return GET(searchUrl, headers) + } - return GET(url.build(), headers) + override fun searchMangaNextPageSelector() = null + + override fun searchMangaSelector() = ".series-list a" + + override fun searchMangaFromElement(element: Element) = SManga.create().apply { + setUrlWithoutDomain(element.absUrl("href")) + title = element.selectFirst(".manga-title")!!.text() + setImageUrlFromElement(element) + } + + override fun latestUpdatesRequest(page: Int) = GET(baseUrl, headers) + + override fun latestUpdatesNextPageSelector() = null + + override fun latestUpdatesSelector() = "h2:contains(新連載) + .feature-list > .feature-item" + + override fun latestUpdatesFromElement(element: Element) = SManga.create().apply { + setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href")) + title = element.selectFirst("h3")!!.text() + setImageUrlFromElement(element) + } + + // ========================================= Helper Functions ===================================== + + companion object { + private const val PUBLISHER = "BUSHIROAD WORKS" + + private val imageUrlRegex by lazy { Regex("^.*?webp") } + + private val DATE_PARSER by lazy { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT) } + + private const val DUMMY_URL_SUFFIX = "NeedLogin" + + private const val PAY_ICON = "💴 " + private const val LOCK_ICON = "🔒 " + } + + /** + * Set cover image url from [element] for [SManga] + */ + private fun SManga.setImageUrlFromElement(element: Element?) { + if (element == null) { + return } - return GET(baseUrl, headers) // Currently just get all ongoing works + val match = imageUrlRegex.find(element.selectFirst("source")!!.attr("data-srcset")) + // Add missing protocol + if (match != null) { + this.thumbnail_url = "https:${match.value}" + } + } + + /** + * Set date_upload to [SChapter], parsing from string like "3月31日" to UNIX Epoch time. + */ + private fun SChapter.setUploadDate(element: Element?) { + if (element == null) { + return + } + this.date_upload = DATE_PARSER.tryParse(element.attr("datetime")) } } diff --git a/src/all/comicgrowl/src/eu/kanade/tachiyomi/extension/all/comicgrowl/ImageDescrambler.kt b/src/all/comicgrowl/src/eu/kanade/tachiyomi/extension/all/comicgrowl/ImageDescrambler.kt new file mode 100644 index 000000000..db41c9625 --- /dev/null +++ b/src/all/comicgrowl/src/eu/kanade/tachiyomi/extension/all/comicgrowl/ImageDescrambler.kt @@ -0,0 +1,70 @@ +package eu.kanade.tachiyomi.extension.all.comicgrowl + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Rect +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import java.io.ByteArrayOutputStream + +object ImageDescrambler { + + // Left-top corner position + private class TilePos(val x: Int, val y: Int) + + /** + * Interceptor to descramble the image. + */ + fun interceptor(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + + val scramble = request.url.fragment ?: return response // return if no scramble fragment + val tiles = buildList { + scramble.split("-").forEachIndexed { index, s -> + val scrambleInt = s.toInt() + add(index, TilePos(scrambleInt / 4, scrambleInt % 4)) + } + } + + val scrambledImg = BitmapFactory.decodeStream(response.body.byteStream()) + val descrambledImg = drawDescrambledImage(scrambledImg, scrambledImg.width, scrambledImg.height, tiles) + + val output = ByteArrayOutputStream() + descrambledImg.compress(Bitmap.CompressFormat.JPEG, 90, output) + + val body = output.toByteArray().toResponseBody("image/jpeg".toMediaType()) + + return response.newBuilder().body(body).build() + } + + private fun drawDescrambledImage(rawImage: Bitmap, width: Int, height: Int, tiles: List): Bitmap { + // Prepare canvas + val descrambledImg = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(descrambledImg) + + // Tile width and height(4x4) + val tileWidth = width / 4 + val tileHeight = height / 4 + + // Draw rect + var count = 0 + for (x in 0..3) { + for (y in 0..3) { + val desRect = Rect(x * tileWidth, y * tileHeight, (x + 1) * tileWidth, (y + 1) * tileHeight) + val srcRect = Rect( + tiles[count].x * tileWidth, + tiles[count].y * tileHeight, + (tiles[count].x + 1) * tileWidth, + (tiles[count].y + 1) * tileHeight, + ) + canvas.drawBitmap(rawImage, srcRect, desRect, null) + count++ + } + } + return descrambledImg + } +} diff --git a/src/all/comicgrowl/src/eu/kanade/tachiyomi/extension/all/comicgrowl/PageResponse.kt b/src/all/comicgrowl/src/eu/kanade/tachiyomi/extension/all/comicgrowl/PageResponse.kt new file mode 100644 index 000000000..f7464844d --- /dev/null +++ b/src/all/comicgrowl/src/eu/kanade/tachiyomi/extension/all/comicgrowl/PageResponse.kt @@ -0,0 +1,16 @@ +package eu.kanade.tachiyomi.extension.all.comicgrowl + +import kotlinx.serialization.Serializable + +@Serializable +class PageResponse( + val totalPages: Int, + val result: List, +) + +@Serializable +class PageResponseResult( + val imageUrl: String, + val scramble: String, + val sort: Int, +)