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:
parent
5fb99e11eb
commit
8e7146ec24
@ -1,9 +1,8 @@
|
||||
ext {
|
||||
extName = 'Comic Growl'
|
||||
extClass = '.ComicGrowl'
|
||||
themePkg = 'gigaviewer'
|
||||
baseUrl = 'https://comic-growl.com'
|
||||
overrideVersionCode = 0
|
||||
extVersionCode = 7
|
||||
isNsfw = false
|
||||
}
|
||||
|
||||
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
Loading…
x
Reference in New Issue
Block a user