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
Signed by: Draff
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 { ext {
extName = 'ReadComicOnline' extName = 'ReadComicOnline'
extClass = '.Readcomiconline' extClass = '.Readcomiconline'
extVersionCode = 34 extVersionCode = 35
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"

View File

@ -1,23 +1,10 @@
package eu.kanade.tachiyomi.extension.en.readcomiconline package eu.kanade.tachiyomi.extension.en.readcomiconline
import android.annotation.SuppressLint
import android.app.Application
import android.content.SharedPreferences 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.UserAgentType
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList 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.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import keiyoushi.utils.getPreferencesLazy import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse import keiyoushi.utils.tryParse
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -39,12 +22,8 @@ import okhttp3.Response
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import rx.Observable import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class Readcomiconline : ConfigurableSource, ParsedHttpSource() { class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
@ -58,6 +37,8 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
override fun headersBuilder() = super.headersBuilder() override fun headersBuilder() = super.headersBuilder()
.set("Referer", "$baseUrl/") .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() override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.setRandomUserAgent( .setRandomUserAgent(
@ -234,123 +215,45 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
return GET(baseUrl + chapter.url + qualitySuffix, headers) return GET(baseUrl + chapter.url + qualitySuffix, headers)
} }
@SuppressLint("SetJavaScriptEnabled")
override fun pageListParse(document: Document): List<Page> { override fun pageListParse(document: Document): List<Page> {
val handler = Handler(Looper.getMainLooper()) // Declare some important values first
val latch = CountDownLatch(1) val encryptedLinks = mutableListOf<String>()
var webView: WebView? = null val decryptionRegexKeys = mutableListOf<Pair<String, String>>()
var urls: List<List<String>> = emptyList()
handler.post { // Get script elements
val innerWv = WebView(Injekt.get<Application>()) val scripts = document.select("script[type=text/javascript]")
webView = innerWv // 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
innerWv.settings.javaScriptEnabled = true // that contains the partial decryption key.
innerWv.settings.blockNetworkImage = true for (script in scripts) {
innerWv.settings.domStorageEnabled = true val scriptContent = script.data()
innerWv.settings.userAgentString = headers["User-Agent"] if (scriptContent.isNotEmpty()) {
innerWv.setLayerType(View.LAYER_TYPE_SOFTWARE, null) val encryptedValues = scriptPageRegex.findAll(scriptContent)
val decryptionKeys = urlDecryptionRegex.findAll(scriptContent)
innerWv.webViewClient = object : WebViewClient() { // We found the encrypted links
override fun shouldInterceptRequest( if (encryptedValues.count() > 0) {
view: WebView?, encryptedValues.forEach {
request: WebResourceRequest?, val url = it.groupValues[1]
): WebResourceResponse? {
val emptyResponse = WebResourceResponse("text/plain", "utf-8", 200, "OK", mapOf(), "".byteInputStream())
val url = request?.url?.toString() if (url.isNotBlank()) {
encryptedLinks.add(url)
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())
} }
} }
super.onLoadResource(view, url)
} }
}
innerWv.webChromeClient = object : WebChromeClient() { // We found the keys
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { if (decryptionKeys.count() > 0) {
if (consoleMessage == null) { return false } decryptionKeys.forEach {
val logContent = "wv: ${consoleMessage.message()} (${consoleMessage.sourceId()}, line ${consoleMessage.lineNumber()})" // Corresponds to Pair<RegexPattern, ReplacementValue>
when (consoleMessage.messageLevel()) { decryptionRegexKeys.add(Pair(it.groupValues[1], it.groupValues[4]))
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
} }
} }
}.awaitAll()
val index = valid.indexOf(true)
if (index == -1) {
throw Exception("No valid image links found")
} }
urls[index]
} }
return images.mapIndexed { idx, img -> return encryptedLinks.mapIndexed { idx, rawUrl ->
Page(idx, imageUrl = img) 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
}