Mangaku: update selectors, fix URL decryption (#360)

* Mangaku: update selectors, fix URL decryption

* Translate error message to ID

Co-authored-by: Luqman <riyanluqman@gmail.com>

---------

Co-authored-by: Luqman <riyanluqman@gmail.com>
This commit is contained in:
Vetle Ledaal 2024-01-19 08:40:01 +00:00 committed by Draff
parent b6e923ac49
commit 8a55ca5d6f
4 changed files with 106 additions and 159 deletions

File diff suppressed because one or more lines are too long

2
src/id/mangaku/assets/zepto.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -2,12 +2,7 @@ ext {
extName = 'Mangaku' extName = 'Mangaku'
pkgNameSuffix = 'id.mangaku' pkgNameSuffix = 'id.mangaku'
extClass = '.Mangaku' extClass = '.Mangaku'
extVersionCode = 7 extVersionCode = 8
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(':lib-cryptoaes'))
implementation(project(':lib-unpacker'))
}

View File

@ -1,10 +1,13 @@
package eu.kanade.tachiyomi.extension.id.mangaku package eu.kanade.tachiyomi.extension.id.mangaku
import android.net.Uri import android.annotation.SuppressLint
import android.util.Base64 import android.app.Application
import android.util.Log import android.os.Handler
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES import android.os.Looper
import eu.kanade.tachiyomi.lib.unpacker.Unpacker import android.view.View
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
@ -15,6 +18,9 @@ import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.Headers import okhttp3.Headers
import okhttp3.Request import okhttp3.Request
@ -23,7 +29,11 @@ import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.jsoup.select.Elements import org.jsoup.select.Elements
import rx.Observable import rx.Observable
import java.security.MessageDigest import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class Mangaku : ParsedHttpSource() { class Mangaku : ParsedHttpSource() {
@ -111,18 +121,24 @@ class Mangaku : ParsedHttpSource() {
override fun searchMangaNextPageSelector(): String? = null override fun searchMangaNextPageSelector(): String? = null
override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply {
title = document.select(".post.singlep .titles a").text().replace("Bahasa Indonesia", "").trim() title = document
thumbnail_url = document.select(".post.singlep img").attr("abs:src") .select("h1.titles a, h1.title").text()
document.select("#wrapper-a #content-a .inf").forEach { .replace("Bahasa Indonesia", "").trim()
when (it.select(".infx").text()) {
"Genre" -> genre = it.select("p a[rel=tag]").joinToString { it.text() } thumbnail_url = document
"Author" -> author = it.select("p").text() .select("#sidebar-a a[imageanchor] > img, #abc a[imageanchor] > img")
"Sinopsis" -> description = it.select("p").text() .attr("abs:src")
document.select("#wrapper-a #content-a .inf, #abc .inf").forEach { row ->
when (row.select(".infx").text()) {
"Genre" -> genre = row.select("p a[rel=tag]").joinToString { it.text() }
"Author" -> author = row.select("p").text()
"Sinopsis" -> description = row.select("p").text()
} }
} }
} }
override fun chapterListSelector() = "#content-b > div > a" override fun chapterListSelector() = "#content-b > div > a, .fndsosmed-social + div > a"
override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply {
setUrlWithoutDomain(element.attr("href")) setUrlWithoutDomain(element.attr("href"))
@ -135,164 +151,97 @@ class Mangaku : ParsedHttpSource() {
} }
} }
@SuppressLint("SetJavaScriptEnabled")
override fun pageListParse(document: Document): List<Page> { override fun pageListParse(document: Document): List<Page> {
val wpRoutineUrl = document.selectFirst("script[src*=wp-routine]")!!.attr("abs:src") val interfaceName = randomString()
Log.d("mangaku", "wp-routine: $wpRoutineUrl")
val wpRoutineJs = client.newCall(GET(wpRoutineUrl, headers)).execute().use { val decodeScriptOriginal = document
it.body.string() .select("script:containsData(dtx = )")
.joinToString("\n") { it.data() }
val decodeScript = decodeScriptOriginal.replace(urlsnxRe) {
it.value + "window.$interfaceName.passPayload(JSON.stringify(urlsnx));"
} }
val upt3 = wpRoutineJs val wpRoutineUrl = document
.substringAfterLast("upt3(") .selectFirst("script[src*=wp-routine]")!!
.substringBefore(");") .attr("abs:src")
val keymapJsPacked = wpRoutineJs val wpRoutineScript = client
.substringAfter("eval(function(x,a,c,k,e,d)") .newCall(GET(wpRoutineUrl, headers))
.substringBefore(".split('|'),0,{}))") + ".split('|'),0,{}))" .execute().use { it.body.string() }
val keymapJs = Unpacker.unpack(keymapJsPacked)
val appMgkVariable = keymapJs
.substringAfter("$upt3=")
.substringBefore(";")
val appMgk = keymapJs
.substringAfter("let $appMgkVariable=\"")
.substringBefore("\";")
.reversed()
Log.d("mangaku", "app-mgk: $appMgk")
val dtxScript = document.selectFirst("script:containsData(var dtx =)")!!.html() val handler = Handler(Looper.getMainLooper())
val dtxIsEqualTo = dtxScript val latch = CountDownLatch(1)
.substringAfter("var dtx = ") val jsInterface = JsInterface(latch)
.substringBefore(";") var webView: WebView? = null
val dtx = dtxScript
.substringAfter("var $dtxIsEqualTo= \"")
.substringBefore("\"")
val mainScriptTag = document.selectFirst("script:containsData(await jrsx)")!!.html() handler.post {
val jrsxArgs = mainScriptTag val webview = WebView(Injekt.get<Application>())
.substringAfter("await jrsx(") webView = webview
.substringBefore(");") webview.settings.javaScriptEnabled = true
.split(",") webview.settings.blockNetworkLoads = true
Log.d("mangaku", "args: $jrsxArgs") webview.settings.blockNetworkImage = true
webview.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
webview.addJavascriptInterface(jsInterface, interfaceName)
val thirdArgValue = mainScriptTag webview.webViewClient = object : WebViewClient() {
.substringAfter("const ${jrsxArgs[2]} = '") override fun onPageFinished(view: WebView, url: String) {
.substringBefore("'") view.evaluateJavascript(jQueryScript) {}
Log.d("mangaku", "arg2: $thirdArgValue") view.evaluateJavascript(cryptoJSScript) {}
view.evaluateJavascript(wpRoutineScript) {}
val encodedAttr = jrsxArgs[4].removeSurrounding("'") view.evaluateJavascript(decodeScript) {}
}
val upt4arg = mainScriptTag }
.substringAfter("const ${jrsxArgs[3]} = await upt4('") webview.loadDataWithBaseURL(
.substringBefore("'") document.location(),
Log.d("mangaku", "upt4arg: $upt4arg") "",
val upt4value = upt4(appMgk, upt4arg) "text/html",
"UTF-8",
val decrypted = CryptoAES.decrypt(dtx, huzorttshj(thirdArgValue, upt4value)) null,
.replace(rsxxxRe, "")
.replace("_", "=")
.reversed()
.replace("+", "%20")
val htmImageList = Base64.decode(decrypted, Base64.DEFAULT)
.toString(Charsets.UTF_8)
.percentDecode()
val attr = stringRotator(encodedAttr, 23, 69, 9).lowercase()
val fifthArgValueDigest =
stringRotator(digest("SHA384", encodedAttr), 23, 69, 20).lowercase()
val re = Regex("""$attr=['"](.*?)['"]""")
return re.findAll(htmImageList).mapIndexed { idx, it ->
val url = Base64.decode(
CryptoAES.decrypt(it.groupValues[1], fifthArgValueDigest),
Base64.DEFAULT,
) )
.toString(Charsets.UTF_8) }
.replace("+", "%20")
.percentDecode() // 5s is ten times over the execution time on a crappy emulator
Page(idx, imageUrl = url) latch.await(5, TimeUnit.SECONDS)
}.toList() handler.post { webView?.destroy() }
if (latch.count == 1L) {
throw Exception("Kehabisan waktu saat men-decrypt tautan gambar") //Timeout while decrypting image links
}
return jsInterface.images.mapIndexed { i, url ->
Page(i, imageUrl = url)
}
} }
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
private val rsxxxRe = Regex(""".............?\+.......""") private val urlsnxRe = Regex("""urlsnx=(?!\[];)[^;]+;""")
private val noLetterRe = Regex("""[^a-z]""") private fun randomString(length: Int = 10): String {
val charPool = ('a'..'z') + ('A'..'Z')
private val noNumberRe = Regex("""[^0-9]""") return List(length) { charPool.random() }.joinToString("")
private val whitespaceRe = Regex("""\s+""")
private fun huzorttshj(key: String, upt4val: String): String {
val mapping = "-ABCDEFGHIJKLMNOPQRSTUVWXYZ=0123456789abcdefghijklmnopqrstuvwxyz+"
val b64upt4 = btoa(upt4val).replace(whitespaceRe, "")
var idx = 0
return b64upt4.map {
val upt4idx = mapping.indexOf(it)
val keyidx = mapping.indexOf(key[idx])
val output = (mapping.substring(keyidx) + mapping.substring(0, keyidx))[upt4idx]
if (idx == key.length - 1) {
idx = 0
} else {
idx += 1
}
output
}.joinToString("")
} }
private fun upt4(appMgk: String, key: String): String { internal class JsInterface(private val latch: CountDownLatch) {
val fullKey = key + appMgk.map {
(it.code xor 71).toChar()
}.joinToString("")
val b64FullKey = btoa(fullKey) private val json: Json by injectLazy()
val sixLastChars = b64FullKey.substring(b64FullKey.length - 7, b64FullKey.length - 1) var images: List<String> = listOf()
val elevenFirstChars = b64FullKey.substring(0, 12) private set
val keyFragment = btoa(sixLastChars + elevenFirstChars).trim()
val firstDigest = digest("SHA384", keyFragment)
val uniqueLetters = firstDigest.replace(noLetterRe, "").distinct() @JavascriptInterface
val uniqueNumbers = firstDigest.replace(noNumberRe, "").distinct() fun passPayload(rawData: String) {
val joined = uniqueNumbers + uniqueLetters val data = json.parseToJsonElement(rawData).jsonArray
images = data.map { it.jsonPrimitive.content }
val secondDigest = digest("SHA1", joined) latch.countDown()
val secondDigestReversed = secondDigest.reversed()
val rotated = stringRotator(secondDigestReversed) + "-$key"
return keyFragment + joined + secondDigestReversed + rotated
}
private fun stringRotator(
input: String,
multiplier: Int = 73,
adder: Int = 93,
length: Int = 20,
strings: List<String> = listOf("PEWAW", "MJKJG", "SGWRT", "KUIQ"),
): String {
val firstPass = input
.map { input.length * multiplier + (adder + it.code) }
.joinToString("") + adder.toString()
return firstPass.map {
val idx = it.toString().toInt()
if (idx < strings.size) strings[idx] else idx.toString()
} }
.joinToString("")
.padEnd(length)
.substring(0, length)
} }
private fun btoa(input: String): String = private val jQueryScript = javaClass
Base64.encode(input.toByteArray(), Base64.DEFAULT).toString(Charsets.UTF_8) .getResource("/assets/zepto.min.js")!!
.readText() // Zepto v1.2.0 (jQuery compatible)
private fun digest(digest: String, input: String): String = private val cryptoJSScript = javaClass
MessageDigest.getInstance(digest).digest(input.toByteArray()) .getResource("/assets/crypto-js.min.js")!!
.joinToString("") { "%02x".format(it) } .readText() // CryptoJS v4.0.0 (on site: cpr2.js)
private fun String.distinct(): String = toCharArray().distinct().joinToString("")
private fun String.percentDecode(): String = Uri.decode(this)
} }