[ConstellarScans] it's webview time (#15154)

* [ConstellarScans] it's webview time

* trim dead code

* remove unused imports
This commit is contained in:
beerpsi 2023-01-28 19:36:38 +07:00 committed by GitHub
parent 85e28435b2
commit 5aca475914
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 61 additions and 198 deletions

View File

@ -1,57 +1,33 @@
package eu.kanade.tachiyomi.extension.en.constellarscans package eu.kanade.tachiyomi.extension.en.constellarscans
import android.graphics.Bitmap import android.annotation.SuppressLint
import android.graphics.BitmapFactory import android.app.Application
import android.graphics.Canvas import android.os.Handler
import android.graphics.ColorMatrix import android.os.Looper
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.graphics.Rect
import android.util.Log import android.util.Log
import app.cash.quickjs.QuickJs import android.view.View
import android.webkit.JavascriptInterface
import android.webkit.WebView
import eu.kanade.tachiyomi.multisrc.mangathemesia.MangaThemesia import eu.kanade.tachiyomi.multisrc.mangathemesia.MangaThemesia
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import okhttp3.CacheControl import okhttp3.CacheControl
import okhttp3.Headers import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request import okhttp3.Request
import okhttp3.ResponseBody.Companion.toResponseBody
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import java.io.ByteArrayOutputStream import uy.kohesive.injekt.Injekt
import java.io.InputStream import uy.kohesive.injekt.api.get
import java.lang.IllegalArgumentException import java.util.concurrent.CountDownLatch
import java.security.MessageDigest
class ConstellarScans : MangaThemesia("Constellar Scans", "https://constellarscans.com", "en") { class ConstellarScans : MangaThemesia("Constellar Scans", "https://constellarscans.com", "en") {
override val client = super.client.newBuilder() override val client = super.client.newBuilder()
.rateLimit(1, 3) .rateLimit(1, 3)
.addInterceptor { chain ->
val response = chain.proceed(chain.request())
val url = response.request.url
if (url.fragment?.contains(DESCRAMBLE) != true) {
return@addInterceptor response
}
val segments = url.pathSegments
val filenameWithoutExtension = segments.last().split(".")[0]
val fragment = segments[segments.lastIndex - 1]
val key = md5sum(fragment + filenameWithoutExtension)
val image = descrambleImage(response.body!!.byteStream(), key)
val body = image.toResponseBody("image/jpeg".toMediaTypeOrNull())
response.newBuilder()
.body(body)
.build()
}
.build() .build()
override fun headersBuilder(): Headers.Builder = Headers.Builder() override fun headersBuilder(): Headers.Builder = Headers.Builder()
@ -94,22 +70,59 @@ class ConstellarScans : MangaThemesia("Constellar Scans", "https://constellarsca
.cacheControl(CacheControl.FORCE_NETWORK) .cacheControl(CacheControl.FORCE_NETWORK)
.build() .build()
override fun pageListParse(document: Document): List<Page> { internal class JsObject(private val latch: CountDownLatch, var tsData: String = "") {
val obfuscatedCode = document.select("script:containsData(_0x)").html() @JavascriptInterface
val tsDataEncrypted = TS_DATA_RE.find(obfuscatedCode)?.groupValues?.get(1) fun passData(tsData: String) {
if (tsDataEncrypted != null) { Log.d("constellarscans", "received ts_reader.run data: $tsData")
val descrambledData = descrambleString(tsDataEncrypted).trim() this.tsData = tsData
Log.d("constellarscans", "decrypted chapter, data: $descrambledData") latch.countDown()
val match = DESCRAMBLING_KEY_RE.find(descrambledData)?.value }
?: throw Exception("Did not receive valid decryption key. Try opening the chapter again.")
Log.d("constellarscans", "device-limited chapter: $match")
return decodeDeviceLimitedChapter(match)
} }
val tsData = descrambleString(getDecryptionKey(document)).trim() private fun randomString(length: Int = 10): String {
val tsDataObject = json.parseToJsonElement(tsData).jsonObject val charPool = ('a'..'z') + ('A'..'Z')
return tsDataObject["sources"]!!.jsonArray[0].jsonObject["images"]!!.jsonArray.mapIndexed { index, jsonElement -> return List(length) { charPool.random() }.joinToString("")
Page(index, imageUrl = jsonElement.jsonPrimitive.content.replace("http://", "https://")) }
@SuppressLint("SetJavaScriptEnabled")
override fun pageListParse(document: Document): List<Page> {
val interfaceName = randomString()
document.body().prepend(
"""
|<script>
| var ts_reader = {
| run: function (data) {
| window.$interfaceName.passData(JSON.stringify(data))
| }
| }
|</script>
""".trimMargin()
)
val handler = Handler(Looper.getMainLooper())
val latch = CountDownLatch(1)
val jsinterface = JsObject(latch)
var webView: WebView? = null
handler.post {
val webview = WebView(Injekt.get<Application>())
webView = webview
webview.settings.javaScriptEnabled = true
webview.settings.domStorageEnabled = true
webview.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
webview.settings.useWideViewPort = false
webview.settings.loadWithOverviewMode = false
webview.settings.userAgentString = mobileUserAgent
webview.addJavascriptInterface(jsinterface, interfaceName)
Log.d("constellarscans", "starting webview shenanigans")
webview.loadDataWithBaseURL(baseUrl, document.toString(), "text/html", "UTF-8", null)
}
latch.await()
handler.post { webView?.destroy() }
val tsData = json.parseToJsonElement(jsinterface.tsData).jsonObject
return tsData["sources"]!!.jsonArray[0].jsonObject["images"]!!.jsonArray.mapIndexed { idx, it ->
Page(idx, imageUrl = it.jsonPrimitive.content)
} }
} }
@ -120,158 +133,8 @@ class ConstellarScans : MangaThemesia("Constellar Scans", "https://constellarsca
.header("Sec-Fetch-Site", "same-origin") .header("Sec-Fetch-Site", "same-origin")
.build() .build()
private var descramblingBytecode: ByteArray? = null
/**
* Also updates `descramblingBytecode` as a side effect
*/
private fun getDecryptionKey(document: Document?): String {
val doc = if (document == null) {
val req = pageListRequest(
SChapter.create().apply {
url = "/the-dignity-of-sister-in-law-chapter-60/"
}
)
client.newCall(req).execute().asJsoup()
} else {
document
}
val obfuscatedScript = doc.selectFirst("script:containsData(_0x)").data()
val scripts = doc.select("script:containsData(_0x) ~ script").html()
val (decodingSymbol, tsData) = JS_FUNC_RE.findAll(obfuscatedScript).firstNotNullOf {
val func = it.groupValues[1]
val tsDataFuncRe = Regex("""$func\s*\(\s*['"]([\da-z]+?)['"]\s*\)""", RegexOption.IGNORE_CASE)
val tsData = tsDataFuncRe.find(scripts)?.groupValues?.get(1)
?: return@firstNotNullOf null
func to tsData
}
Log.d("constellarscans", "decoding symbol: $decodingSymbol")
descramblingBytecode = QuickJs.create().use {
it.compile(
"""
ts_reader = {}
ts_reader.run = arg => { throw Error(arg) }
JSON.parse = arg => arg
${obfuscatedScript.replace(decodingSymbol, "decode")}
""".trimIndent(),
"#"
)
}
return tsData
}
private fun descrambleString(input: String): String {
if (descramblingBytecode == null) getDecryptionKey(null)
return QuickJs.create().use {
it.execute(descramblingBytecode!!)
it.evaluate(
"""
try {
decode("$input")
} catch (e) {
e.message
}
""".trimIndent()
)
} as String
}
private fun decodeDeviceLimitedChapter(fullKey: String): List<Page> {
if (!DESCRAMBLING_KEY_RE.matches(fullKey)) {
throw IllegalArgumentException("Did not receive suitable decryption key. Try opening the chapter again.")
}
val shiftBy = fullKey.substring(32..33).toInt(16)
val key = fullKey.substring(0..31) + fullKey.substring(34)
val fragmentAndImageCount = key.map {
var idx = LOOKUP_STRING_ALNUM.indexOf(it) - shiftBy
if (idx < 0) {
idx += LOOKUP_STRING_ALNUM.length
}
LOOKUP_STRING_ALNUM[idx]
}.joinToString("")
val fragment = fragmentAndImageCount.substring(0..31)
val imageCount = fragmentAndImageCount.substring(32).toInt()
val pages = mutableListOf<Page>()
for (i in 1..imageCount) {
val filename = i.toString().padStart(5, '0')
pages.add(
Page(
i,
imageUrl = "$encodedUploadsPath/$fragment/$filename.webp#$DESCRAMBLE"
)
)
}
return pages
}
private fun descrambleImage(image: InputStream, key: String): ByteArray {
val bitmap = BitmapFactory.decodeStream(image)
val invertingPaint = Paint().apply {
colorFilter = ColorMatrixColorFilter(
ColorMatrix(
floatArrayOf(
-1.0f, 0.0f, 0.0f, 0.0f, 255.0f,
0.0f, -1.0f, 0.0f, 0.0f, 255.0f,
0.0f, 0.0f, -1.0f, 0.0f, 255.0f,
0.0f, 0.0f, 0.0f, 1.0f, 0.0f
)
)
)
}
val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
val sectionCount = (key.last().code % 10) * 2 + 4
val remainder = bitmap.height % sectionCount
for (i in 0 until sectionCount) {
var sectionHeight = bitmap.height / sectionCount
var sy = bitmap.height - sectionHeight * (i + 1) - remainder
val dy = sectionHeight * i
if (i == sectionCount - 1) {
sectionHeight += remainder
} else {
sy += remainder
}
val sRect = Rect(0, sy, bitmap.width, sy + sectionHeight)
val dRect = Rect(0, dy, bitmap.width, dy + sectionHeight)
canvas.drawBitmap(bitmap, sRect, dRect, invertingPaint)
}
val output = ByteArrayOutputStream()
result.compress(Bitmap.CompressFormat.JPEG, 90, output)
return output.toByteArray()
}
private fun md5sum(input: String): String {
val md = MessageDigest.getInstance("MD5")
return md.digest(input.toByteArray())
.joinToString("") { "%02x".format(it) }
}
private val encodedUploadsPath = "$baseUrl/wp-content/uploads/encoded"
companion object { companion object {
const val DESCRAMBLE = "descramble"
const val UA_DB_URL = const val UA_DB_URL =
"https://cdn.jsdelivr.net/gh/mimmi20/browscap-helper@30a83c095688f40b9eaca0165a479c661e5a7fbe/tests/0002999.json" "https://cdn.jsdelivr.net/gh/mimmi20/browscap-helper@30a83c095688f40b9eaca0165a479c661e5a7fbe/tests/0002999.json"
const val LOOKUP_STRING_ALNUM =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
val NOT_DIGIT_RE = Regex("""\D""")
val JS_FUNC_RE = Regex("""function (.+?)\s*\(""")
val TS_DATA_RE = Regex("""\(\s*['"]([\da-z]+?)['"]\s*\)""", RegexOption.IGNORE_CASE)
// The decoding algorithm looks for a hex number in 32..33, so we write our regex accordingly
val DESCRAMBLING_KEY_RE =
Regex("""[\da-z]{32}[\da-f]{2}[\da-z]+""", RegexOption.IGNORE_CASE)
} }
} }

View File

@ -25,7 +25,7 @@ class MangaThemesiaGenerator : ThemeSourceGenerator {
SingleLang("Azure Scans", "https://azuremanga.com", "en", overrideVersionCode = 1), SingleLang("Azure Scans", "https://azuremanga.com", "en", overrideVersionCode = 1),
SingleLang("Boosei", "https://boosei.net", "id", overrideVersionCode = 2), SingleLang("Boosei", "https://boosei.net", "id", overrideVersionCode = 2),
SingleLang("Clayrer", "https://clayrer.net", "es"), SingleLang("Clayrer", "https://clayrer.net", "es"),
SingleLang("Constellar Scans", "https://constellarscans.com", "en", isNsfw = true, overrideVersionCode = 8), SingleLang("Constellar Scans", "https://constellarscans.com", "en", isNsfw = true, overrideVersionCode = 9),
SingleLang("Cosmic Scans", "https://cosmicscans.com", "en", overrideVersionCode = 1), SingleLang("Cosmic Scans", "https://cosmicscans.com", "en", overrideVersionCode = 1),
SingleLang("Diskus Scan", "https://diskusscan.com", "pt-BR", overrideVersionCode = 7), SingleLang("Diskus Scan", "https://diskusscan.com", "pt-BR", overrideVersionCode = 7),
SingleLang("Dojing.net", "https://dojing.net", "id", isNsfw = true, className = "DojingNet"), SingleLang("Dojing.net", "https://dojing.net", "id", isNsfw = true, className = "DojingNet"),