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
This commit is contained in:
heddxh 2025-06-18 21:59:23 +08:00 committed by Draff
parent 5fb99e11eb
commit 8e7146ec24
Signed by: Draff
GPG Key ID: E8A89F3211677653
4 changed files with 277 additions and 43 deletions

View File

@ -1,9 +1,8 @@
ext {
extName = 'Comic Growl'
extClass = '.ComicGrowl'
themePkg = 'gigaviewer'
baseUrl = 'https://comic-growl.com'
overrideVersionCode = 0
extVersionCode = 7
isNsfw = false
}

View File

@ -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<Collection> = 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<SChapter> {
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<Page> {
val pageList = mutableListOf<Page>()
// 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<PageResponse>().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<PageResponse>().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"))
}
}

View File

@ -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<TilePos>): 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
}
}

View File

@ -0,0 +1,16 @@
package eu.kanade.tachiyomi.extension.all.comicgrowl
import kotlinx.serialization.Serializable
@Serializable
class PageResponse(
val totalPages: Int,
val result: List<PageResponseResult>,
)
@Serializable
class PageResponseResult(
val imageUrl: String,
val scramble: String,
val sort: Int,
)