[ConstellarScans] it's webview time (#15154)
* [ConstellarScans] it's webview time * trim dead code * remove unused imports
This commit is contained in:
parent
85e28435b2
commit
5aca475914
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"),
|
||||||
|
|
Loading…
Reference in New Issue