RCO: decrypt page links in extension (#8634)

* Reimplemented pageListParse to decrypt the page in-app

* remove unused assets

---------

Co-authored-by: JakeeLuwi <jeloubirad@gmail.com>
This commit is contained in:
Vetle Ledaal 2025-04-26 15:32:43 +02:00 committed by Draff
parent f8a44eb538
commit 38f511b815
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
4 changed files with 99 additions and 181 deletions

View File

@ -1,56 +0,0 @@
(() => {
const isValidUrl = (str) => {
try {
new URL(str);
return true;
} catch (e) {
return false;
}
}
const arrays = [];
const functions = [];
const results = [];
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const builtInKeys = new Set(Object.getOwnPropertyNames(iframe.contentWindow));
document.body.removeChild(iframe);
for (const [key, value] of Object.entries(window)) {
if (builtInKeys.has(key)) continue;
if (Array.isArray(value)) {
arrays.push(value);
} else if (typeof value === 'function' && value.length >= 1) {
functions.push(value);
}
}
arrays.forEach(arrayValue => {
functions.forEach(funcValue => {
const argCount = funcValue.length;
for (let i = 0; i < argCount; i++) {
try {
const mapped = arrayValue.map(elem => {
const args = new Array(argCount).fill(undefined);
args[i] = elem;
return funcValue(...args);
});
if (
Array.isArray(mapped) &&
mapped.length !== 0 &&
mapped.every(item => typeof item === 'string' && isValidUrl(item))
) {
results.push(mapped);
break;
}
} catch (err) {}
}
});
});
return results;
})();

View File

@ -1,7 +1,7 @@
ext {
extName = 'ReadComicOnline'
extClass = '.Readcomiconline'
extVersionCode = 34
extVersionCode = 35
}
apply from: "$rootDir/common.gradle"

View File

@ -1,23 +1,10 @@
package eu.kanade.tachiyomi.extension.en.readcomiconline
import android.annotation.SuppressLint
import android.app.Application
import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import eu.kanade.tachiyomi.lib.randomua.UserAgentType
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
@ -26,11 +13,7 @@ import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
@ -39,12 +22,8 @@ import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
@ -58,6 +37,8 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
override fun headersBuilder() = super.headersBuilder()
.set("Referer", "$baseUrl/")
private val scriptPageRegex = """(?s)pth\s*=\s*['"](.*?)['"]\s*;?""".toRegex()
private val urlDecryptionRegex = """l\s*\.replace\(\s*/(.*?)/([gimuy]*)\s*,\s*(['"`])(.*?)\3\s*\)""".toRegex()
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.setRandomUserAgent(
@ -234,123 +215,45 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
return GET(baseUrl + chapter.url + qualitySuffix, headers)
}
@SuppressLint("SetJavaScriptEnabled")
override fun pageListParse(document: Document): List<Page> {
val handler = Handler(Looper.getMainLooper())
val latch = CountDownLatch(1)
var webView: WebView? = null
var urls: List<List<String>> = emptyList()
// Declare some important values first
val encryptedLinks = mutableListOf<String>()
val decryptionRegexKeys = mutableListOf<Pair<String, String>>()
handler.post {
val innerWv = WebView(Injekt.get<Application>())
// Get script elements
val scripts = document.select("script[type=text/javascript]")
webView = innerWv
innerWv.settings.javaScriptEnabled = true
innerWv.settings.blockNetworkImage = true
innerWv.settings.domStorageEnabled = true
innerWv.settings.userAgentString = headers["User-Agent"]
innerWv.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
// We'll get a bunch of results on the selector but we only need 2: The script that contains the encrypted links and the script
// that contains the partial decryption key.
for (script in scripts) {
val scriptContent = script.data()
if (scriptContent.isNotEmpty()) {
val encryptedValues = scriptPageRegex.findAll(scriptContent)
val decryptionKeys = urlDecryptionRegex.findAll(scriptContent)
innerWv.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?,
): WebResourceResponse? {
val emptyResponse = WebResourceResponse("text/plain", "utf-8", 200, "OK", mapOf(), "".byteInputStream())
// We found the encrypted links
if (encryptedValues.count() > 0) {
encryptedValues.forEach {
val url = it.groupValues[1]
val url = request?.url?.toString()
url ?: return emptyResponse
if (!url.contains("rguard")) {
return emptyResponse
}
return super.shouldInterceptRequest(view, request)
}
override fun onPageFinished(view: WebView?, url: String?) {
view?.evaluateJavascript(
Readcomiconline::class.java
.getResourceAsStream("/assets/script.js")!!
.bufferedReader()
.use { it.readText() },
) {
try {
urls = it.parseAs()
latch.countDown()
} catch (e: Exception) {
Log.e("RCO", e.stackTraceToString())
if (url.isNotBlank()) {
encryptedLinks.add(url)
}
}
super.onLoadResource(view, url)
}
}
innerWv.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
if (consoleMessage == null) { return false }
val logContent = "wv: ${consoleMessage.message()} (${consoleMessage.sourceId()}, line ${consoleMessage.lineNumber()})"
when (consoleMessage.messageLevel()) {
ConsoleMessage.MessageLevel.DEBUG -> Log.d("RCO", logContent)
ConsoleMessage.MessageLevel.ERROR -> Log.e("RCO", logContent)
ConsoleMessage.MessageLevel.LOG -> Log.i("RCO", logContent)
ConsoleMessage.MessageLevel.TIP -> Log.i("RCO", logContent)
ConsoleMessage.MessageLevel.WARNING -> Log.w("RCO", logContent)
else -> Log.d("RCO", logContent)
}
return true
}
}
innerWv.loadDataWithBaseURL(
document.location(),
document.outerHtml(),
"text/html",
"UTF-8",
null,
)
}
latch.await(30, TimeUnit.SECONDS)
handler.post { webView?.destroy() }
if (latch.count == 1L) {
throw Exception("Timeout getting image links")
}
val images = runBlocking {
val valid = urls.map { urlList ->
async {
val request = Request.Builder()
.url(urlList.random())
.method("HEAD", null)
.headers(headers)
.build()
try {
val response = client.newCall(request).await()
val contentType = response.headers["content-type"] ?: ""
val size = response.headers["content-length"]?.toLongOrNull() ?: 0L
response.isSuccessful && contentType.startsWith("image") && size > 100
} catch (e: Exception) {
false
// We found the keys
if (decryptionKeys.count() > 0) {
decryptionKeys.forEach {
// Corresponds to Pair<RegexPattern, ReplacementValue>
decryptionRegexKeys.add(Pair(it.groupValues[1], it.groupValues[4]))
}
}
}.awaitAll()
val index = valid.indexOf(true)
if (index == -1) {
throw Exception("No valid image links found")
}
urls[index]
}
return images.mapIndexed { idx, img ->
Page(idx, imageUrl = img)
return encryptedLinks.mapIndexed { idx, rawUrl ->
Page(idx, imageUrl = decryptLink(rawUrl, decryptionRegexKeys, ""))
}
}

View File

@ -0,0 +1,71 @@
package eu.kanade.tachiyomi.extension.en.readcomiconline
import android.util.Base64
import java.net.URLDecoder
private fun step1(param: String): String {
return param.substring(15, 15 + 18) + param.substring(15 + 18 + 17)
}
private fun step2(param: String): String {
return param.substring(0, param.length - (9 + 2)) +
param[param.length - 2] +
param[param.length - 1]
}
fun decryptLink(
firstStringFormat: String,
partialDecryptKeys: List<Pair<String, String>>,
formatter: String = "",
): String {
var processedString = firstStringFormat
partialDecryptKeys.forEach {
processedString = processedString.replace(it.first.toRegex(), it.second)
}
processedString = processedString
.replace("pw_.g28x", "b")
.replace("d2pr.x_27", "h")
if (!processedString.startsWith("https")) {
val firstStringFormatLocalVar = processedString
val firstStringSubS = firstStringFormatLocalVar.substring(
firstStringFormatLocalVar.indexOf("?"),
)
processedString = if (firstStringFormatLocalVar.contains("=s0?")) {
firstStringFormatLocalVar.substring(0, firstStringFormatLocalVar.indexOf("=s0?"))
} else {
firstStringFormatLocalVar.substring(0, firstStringFormatLocalVar.indexOf("=s1600?"))
}
processedString = step1(processedString)
processedString = step2(processedString)
// Base64 decode and URL decode
val decodedBytes = Base64.decode(processedString, Base64.DEFAULT)
processedString = URLDecoder.decode(String(decodedBytes), "UTF-8")
processedString = processedString.substring(0, 13) +
processedString.substring(17)
processedString = if (firstStringFormat.contains("=s0")) {
processedString.substring(0, processedString.length - 2) + "=s0"
} else {
processedString.substring(0, processedString.length - 2) + "=s1600"
}
processedString += firstStringSubS
processedString = "https://2.bp.blogspot.com/$processedString"
}
if (formatter.isNotEmpty()) {
processedString = processedString.replace(
"https://2.bp.blogspot.com",
formatter,
)
}
return processedString
}