ReadComicOnline - Update Parsing to Use QuickJS and Make it Configurable (#8672)

* unscuff code, update regexes as configurable

* Update default values, improved pref

* Update Readcomiconline.kt

Add placeholders for future implementation

* Updated page list parsing to use quickjs

* add json config, remove decryption class

* review changes, updated default config path

* review changes, lint

* lint

* lint...
This commit is contained in:
Jake 2025-05-15 23:23:28 +08:00 committed by Draff
parent d3fa36c82d
commit 61f37300ed
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
4 changed files with 162 additions and 138 deletions

View File

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

View File

@ -0,0 +1,5 @@
{
"imageDecryptEval": "const matches=[..._encryptedString.matchAll(/(cdk|pth)\\s*=\\s*['\"](.*?)['\"]\\s*;?/gs)];const pageLinks=new Array;matches.forEach((t=>{if(t[2])pageLinks.push(decryptLink(t[2]))}));function atob(t){const e=\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\";let s=String(t).replace(/=+$/,\"\");if(s.length%4===1)throw new Error(\"'atob' failed: The string to be decoded is not correctly encoded.\");let n=\"\";for(let t=0,r,c,i=0;c=s.charAt(i++);~c&&(r=t%4?r*64+c:c,t++%4)?n+=String.fromCharCode(255&r>>(-2*t&6)):0)c=e.indexOf(c);return n}function decryptLink(t){let e=t.replace(/\\w{5}__\\w{3}__/g,\"g\").replace(/\\w{2}__\\w{6}_/g,\"a\").replace(/b/g,\"pw_.g28x\").replace(/h/g,\"d2pr.x_27\").replace(/pw_.g28x/g,\"b\").replace(/d2pr.x_27/g,\"h\");if(!e.startsWith(\"https\")){const t=e.indexOf(\"?\");const s=e.substring(t);const n=e.includes(\"=s0?\");const r=n?e.indexOf(\"=s0?\"):e.indexOf(\"=s1600?\");let c=e.substring(0,r);c=c.substring(15,33)+c.substring(50);const i=c.length;c=c.substring(0,i-11)+c[i-2]+c[i-1];const o=atob(c);let g=decodeURIComponent(o);g=g.substring(0,13)+g.substring(17);g=g.substring(0,g.length-2)+(n?\"=s0\":\"=s1600\");e=`https://2.bp.blogspot.com/${g}${s}`}return e}JSON.stringify(pageLinks);",
"postDecryptEval": null,
"shouldVerifyLinks": false
}

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.extension.en.readcomiconline
import android.content.SharedPreferences
import app.cash.quickjs.QuickJs
import eu.kanade.tachiyomi.lib.randomua.UserAgentType
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
import eu.kanade.tachiyomi.network.GET
@ -13,7 +14,11 @@ 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.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
@ -22,7 +27,9 @@ import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
@ -35,18 +42,12 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
override val supportsLatest = true
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 fun headersBuilder() = super.headersBuilder().set("Referer", "$baseUrl/")
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.setRandomUserAgent(
userAgentType = UserAgentType.DESKTOP,
filterInclude = listOf("chrome"),
)
.addNetworkInterceptor(::captchaInterceptor)
.build()
override val client: OkHttpClient = network.cloudflareClient.newBuilder().setRandomUserAgent(
userAgentType = UserAgentType.DESKTOP,
filterInclude = listOf("chrome"),
).addNetworkInterceptor(::captchaInterceptor).build()
private fun captchaInterceptor(chain: Interceptor.Chain): Response {
val request = chain.request()
@ -93,8 +94,14 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { // publisher > writer > artist + sorting for both if else
if (query.isEmpty() && (if (filters.isEmpty()) getFilterList() else filters).filterIsInstance<GenreList>().all { it.included.isEmpty() && it.excluded.isEmpty() }) {
override fun searchMangaRequest(
page: Int,
query: String,
filters: FilterList,
): Request { // publisher > writer > artist + sorting for both if else
if (query.isEmpty() && (if (filters.isEmpty()) getFilterList() else filters).filterIsInstance<GenreList>()
.all { it.included.isEmpty() && it.excluded.isEmpty() }
) {
val url = baseUrl.toHttpUrl().newBuilder().apply {
var pathSegmentAdded = false
@ -106,18 +113,21 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
pathSegmentAdded = true
}
}
is WriterFilter -> {
if (filter.state.isNotEmpty()) {
addPathSegments("Writer/${filter.state.replace(" ", "-")}")
pathSegmentAdded = true
}
}
is ArtistFilter -> {
if (filter.state.isNotEmpty()) {
addPathSegments("Artist/${filter.state.replace(" ", "-")}")
pathSegmentAdded = true
}
}
else -> {}
}
@ -125,7 +135,10 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
break
}
}
addPathSegment((if (filters.isEmpty()) getFilterList() else filters).filterIsInstance<SortFilter>().first().selected.toString())
addPathSegment(
(if (filters.isEmpty()) getFilterList() else filters).filterIsInstance<SortFilter>()
.first().selected.toString(),
)
addQueryParameter("page", page.toString())
}.build()
return GET(url, headers)
@ -135,11 +148,16 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
addQueryParameter("page", page.toString())
for (filter in if (filters.isEmpty()) getFilterList() else filters) {
when (filter) {
is Status -> addQueryParameter("status", arrayOf("", "Completed", "Ongoing")[filter.state])
is Status -> addQueryParameter(
"status",
arrayOf("", "Completed", "Ongoing")[filter.state],
)
is GenreList -> {
addQueryParameter("ig", filter.included.joinToString(","))
addQueryParameter("eg", filter.excluded.joinToString(","))
}
else -> {}
}
}
@ -165,21 +183,20 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
manga.author = infoElement.select("p:has(span:contains(Writer:)) > a").first()?.text()
manga.genre = infoElement.select("p:has(span:contains(Genres:)) > *:gt(0)").text()
manga.description = infoElement.select("p:has(span:contains(Summary:)) ~ p").text()
manga.status = infoElement.select("p:has(span:contains(Status:))").first()?.text().orEmpty().let { parseStatus(it) }
manga.status = infoElement.select("p:has(span:contains(Status:))").first()?.text().orEmpty()
.let { parseStatus(it) }
manga.thumbnail_url = document.select(".rightBox:eq(0) img").first()?.absUrl("src")
return manga
}
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(realMangaDetailsRequest(manga))
.asObservableSuccess()
return client.newCall(realMangaDetailsRequest(manga)).asObservableSuccess()
.map { response ->
mangaDetailsParse(response).apply { initialized = true }
}
}
private fun realMangaDetailsRequest(manga: SManga): Request =
super.mangaDetailsRequest(manga)
private fun realMangaDetailsRequest(manga: SManga): Request = super.mangaDetailsRequest(manga)
override fun mangaDetailsRequest(manga: SManga): Request =
captchaUrl?.let { GET(it, headers) }.also { captchaUrl = null }
@ -206,54 +223,64 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
private val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.getDefault())
override fun pageListRequest(chapter: SChapter): Request {
val qualitySuffix = if ((qualitypref() != "lq" && serverpref() != "s2") || (qualitypref() == "lq" && serverpref() == "s2")) {
"&s=${serverpref()}&quality=${qualitypref()}&readType=1"
} else {
"&s=${serverpref()}&readType=1"
}
val qualitySuffix =
if ((qualityPref() != "lq" && serverPref() != "s2") || (qualityPref() == "lq" && serverPref() == "s2")) {
"&s=${serverPref()}&quality=${qualityPref()}&readType=1"
} else {
"&s=${serverPref()}&readType=1"
}
return GET(baseUrl + chapter.url + qualitySuffix, headers)
}
override fun pageListParse(document: Document): List<Page> {
// Declare some important values first
val encryptedLinks = mutableListOf<String>()
val decryptionRegexKeys = mutableListOf<Pair<String, String>>()
var encryptedLinks = mutableListOf<String>()
// Get script elements
val scripts = document.select("script[type=text/javascript]")
val scripts = document.select("script")
// We'll evaluate every script that exists in the HTML
if (remoteConfigItem == null) {
throw IOException("Failed to retrieve configuration")
}
// 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)
QuickJs.create().use {
val eval =
"let _encryptedString = `${script.data()}`;${remoteConfigItem!!.imageDecryptEval}"
val evalResult = (it.evaluate(eval) as String).parseAs<List<String>>()
// We found the encrypted links
if (encryptedValues.count() > 0) {
encryptedValues.forEach {
val url = it.groupValues[1]
if (url.isNotBlank()) {
encryptedLinks.add(url)
}
}
}
// We found the keys
if (decryptionKeys.count() > 0) {
decryptionKeys.forEach {
// Corresponds to Pair<RegexPattern, ReplacementValue>
decryptionRegexKeys.add(Pair(it.groupValues[1], it.groupValues[4]))
}
}
// Add results to 'encryptedLinks'
encryptedLinks.addAll(evalResult)
}
}
return encryptedLinks.mapIndexed { idx, rawUrl ->
Page(idx, imageUrl = decryptLink(rawUrl, decryptionRegexKeys, ""))
encryptedLinks = encryptedLinks.let { links ->
if (remoteConfigItem!!.postDecryptEval != null) {
QuickJs.create().use {
val eval = "let _decryptedLinks = ${Json.encodeToString(links)}"
(it.evaluate(eval) as String).parseAs<MutableList<String>>()
}
} else {
links
}
}
return encryptedLinks.mapIndexedNotNull { idx, url ->
if (!remoteConfigItem!!.shouldVerifyLinks) {
Page(idx, imageUrl = url)
} else {
val request = Request.Builder().url(url).head().build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
Page(idx, imageUrl = url)
} else {
null // Remove from list
}
}
}
}
}
@ -268,10 +295,12 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
val excluded: List<String>
get() = state.filter { it.isExcluded() }.map { it.gid }
}
open class SelectFilter(displayName: String, private val options: Array<Pair<String, String>>) : Filter.Select<String>(
displayName,
options.map { it.first }.toTypedArray(),
) {
open class SelectFilter(displayName: String, private val options: Array<Pair<String, String>>) :
Filter.Select<String>(
displayName,
options.map { it.first }.toTypedArray(),
) {
open val selected get() = options[state].second.takeUnless { it.isEmpty() }
}
@ -355,8 +384,28 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
// Preferences Code
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
val qualitypref = androidx.preference.ListPreference(screen.context).apply {
key = QUALITY_PREF_TITLE
val remoteConfigPref = androidx.preference.EditTextPreference(screen.context).apply {
key = IMAGE_REMOTE_CONFIG_PREF
title = IMAGE_REMOTE_CONFIG_TITLE
summary = IMAGE_REMOTE_CONFIG_SUMMARY
setDefaultValue(IMAGE_REMOTE_CONFIG_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val commitResult =
preferences.edit().putString(IMAGE_REMOTE_CONFIG_PREF, newValue as String)
.commit()
if (commitResult) {
// Make it null so remoteConfigItem would request for a link again
remoteConfigItem = null
}
commitResult
}
}
val qualityPref = androidx.preference.ListPreference(screen.context).apply {
key = QUALITY_PREF
title = QUALITY_PREF_TITLE
entries = arrayOf("High Quality", "Low Quality")
entryValues = arrayOf("hq", "lq")
@ -369,9 +418,9 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
preferences.edit().putString(QUALITY_PREF, entry).commit()
}
}
screen.addPreference(qualitypref)
val serverpref = androidx.preference.ListPreference(screen.context).apply {
key = SERVER_PREF_TITLE
val serverPref = androidx.preference.ListPreference(screen.context).apply {
key = SERVER_PREF
title = SERVER_PREF_TITLE
entries = arrayOf("Server 1", "Server 2")
entryValues = arrayOf("", "s2")
@ -384,17 +433,58 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
preferences.edit().putString(SERVER_PREF, entry).commit()
}
}
screen.addPreference(serverpref)
screen.addPreference(remoteConfigPref)
screen.addPreference(qualityPref)
screen.addPreference(serverPref)
}
private fun qualitypref() = preferences.getString(QUALITY_PREF, "hq")
private fun qualityPref() = preferences.getString(QUALITY_PREF, "hq")
private fun serverpref() = preferences.getString(SERVER_PREF, "")
private fun serverPref() = preferences.getString(SERVER_PREF, "")
private var remoteConfigItem: RemoteConfigDTO? = null
get() {
if (field != null) {
return field
}
val configLink = preferences.getString(
IMAGE_REMOTE_CONFIG_PREF.addBustQuery(),
IMAGE_REMOTE_CONFIG_DEFAULT.addBustQuery(),
) ?: return null
try {
val configResponse = client.newCall(GET(configLink)).execute()
field = configResponse.parseAs<RemoteConfigDTO>()
configResponse.close()
return field
} catch (_: IOException) {
return null
}
}
private fun String.addBustQuery(): String {
return "$this?bust=${Calendar.getInstance().time.time}"
}
@Serializable
private class RemoteConfigDTO(
val imageDecryptEval: String,
val postDecryptEval: String?,
val shouldVerifyLinks: Boolean,
)
companion object {
private const val QUALITY_PREF_TITLE = "Image Quality Selector"
private const val QUALITY_PREF = "qualitypref"
private const val SERVER_PREF_TITLE = "Server Preference"
private const val SERVER_PREF = "serverpref"
private const val IMAGE_REMOTE_CONFIG_TITLE = "Remote Config"
private const val IMAGE_REMOTE_CONFIG_SUMMARY = "Remote Config Link"
private const val IMAGE_REMOTE_CONFIG_PREF = "imageuseremotelinkpref"
private const val IMAGE_REMOTE_CONFIG_DEFAULT =
"https://raw.githubusercontent.com/keiyoushi/extensions-source/refs/heads/main/src/en/readcomiconline/config.json"
}
}

View File

@ -1,71 +0,0 @@
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
}