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
Signed by: Draff
GPG Key ID: E8A89F3211677653
4 changed files with 162 additions and 138 deletions

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'ReadComicOnline' extName = 'ReadComicOnline'
extClass = '.Readcomiconline' extClass = '.Readcomiconline'
extVersionCode = 35 extVersionCode = 36
} }
apply from: "$rootDir/common.gradle" 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 package eu.kanade.tachiyomi.extension.en.readcomiconline
import android.content.SharedPreferences import android.content.SharedPreferences
import app.cash.quickjs.QuickJs
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
@ -13,7 +14,11 @@ 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.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -22,7 +27,9 @@ 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 java.io.IOException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale import java.util.Locale
class Readcomiconline : ConfigurableSource, ParsedHttpSource() { class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
@ -35,18 +42,12 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
override val supportsLatest = true override val supportsLatest = true
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( userAgentType = UserAgentType.DESKTOP,
userAgentType = UserAgentType.DESKTOP, filterInclude = listOf("chrome"),
filterInclude = listOf("chrome"), ).addNetworkInterceptor(::captchaInterceptor).build()
)
.addNetworkInterceptor(::captchaInterceptor)
.build()
private fun captchaInterceptor(chain: Interceptor.Chain): Response { private fun captchaInterceptor(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
@ -93,8 +94,14 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { // publisher > writer > artist + sorting for both if else override fun searchMangaRequest(
if (query.isEmpty() && (if (filters.isEmpty()) getFilterList() else filters).filterIsInstance<GenreList>().all { it.included.isEmpty() && it.excluded.isEmpty() }) { 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 { val url = baseUrl.toHttpUrl().newBuilder().apply {
var pathSegmentAdded = false var pathSegmentAdded = false
@ -106,18 +113,21 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
pathSegmentAdded = true pathSegmentAdded = true
} }
} }
is WriterFilter -> { is WriterFilter -> {
if (filter.state.isNotEmpty()) { if (filter.state.isNotEmpty()) {
addPathSegments("Writer/${filter.state.replace(" ", "-")}") addPathSegments("Writer/${filter.state.replace(" ", "-")}")
pathSegmentAdded = true pathSegmentAdded = true
} }
} }
is ArtistFilter -> { is ArtistFilter -> {
if (filter.state.isNotEmpty()) { if (filter.state.isNotEmpty()) {
addPathSegments("Artist/${filter.state.replace(" ", "-")}") addPathSegments("Artist/${filter.state.replace(" ", "-")}")
pathSegmentAdded = true pathSegmentAdded = true
} }
} }
else -> {} else -> {}
} }
@ -125,7 +135,10 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
break 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()) addQueryParameter("page", page.toString())
}.build() }.build()
return GET(url, headers) return GET(url, headers)
@ -135,11 +148,16 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
addQueryParameter("page", page.toString()) addQueryParameter("page", page.toString())
for (filter in if (filters.isEmpty()) getFilterList() else filters) { for (filter in if (filters.isEmpty()) getFilterList() else filters) {
when (filter) { when (filter) {
is Status -> addQueryParameter("status", arrayOf("", "Completed", "Ongoing")[filter.state]) is Status -> addQueryParameter(
"status",
arrayOf("", "Completed", "Ongoing")[filter.state],
)
is GenreList -> { is GenreList -> {
addQueryParameter("ig", filter.included.joinToString(",")) addQueryParameter("ig", filter.included.joinToString(","))
addQueryParameter("eg", filter.excluded.joinToString(",")) addQueryParameter("eg", filter.excluded.joinToString(","))
} }
else -> {} else -> {}
} }
} }
@ -165,21 +183,20 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
manga.author = infoElement.select("p:has(span:contains(Writer:)) > a").first()?.text() 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.genre = infoElement.select("p:has(span:contains(Genres:)) > *:gt(0)").text()
manga.description = infoElement.select("p:has(span:contains(Summary:)) ~ p").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") manga.thumbnail_url = document.select(".rightBox:eq(0) img").first()?.absUrl("src")
return manga return manga
} }
override fun fetchMangaDetails(manga: SManga): Observable<SManga> { override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(realMangaDetailsRequest(manga)) return client.newCall(realMangaDetailsRequest(manga)).asObservableSuccess()
.asObservableSuccess()
.map { response -> .map { response ->
mangaDetailsParse(response).apply { initialized = true } mangaDetailsParse(response).apply { initialized = true }
} }
} }
private fun realMangaDetailsRequest(manga: SManga): Request = private fun realMangaDetailsRequest(manga: SManga): Request = super.mangaDetailsRequest(manga)
super.mangaDetailsRequest(manga)
override fun mangaDetailsRequest(manga: SManga): Request = override fun mangaDetailsRequest(manga: SManga): Request =
captchaUrl?.let { GET(it, headers) }.also { captchaUrl = null } 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()) private val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.getDefault())
override fun pageListRequest(chapter: SChapter): Request { override fun pageListRequest(chapter: SChapter): Request {
val qualitySuffix = if ((qualitypref() != "lq" && serverpref() != "s2") || (qualitypref() == "lq" && serverpref() == "s2")) { val qualitySuffix =
"&s=${serverpref()}&quality=${qualitypref()}&readType=1" if ((qualityPref() != "lq" && serverPref() != "s2") || (qualityPref() == "lq" && serverPref() == "s2")) {
} else { "&s=${serverPref()}&quality=${qualityPref()}&readType=1"
"&s=${serverpref()}&readType=1" } else {
} "&s=${serverPref()}&readType=1"
}
return GET(baseUrl + chapter.url + qualitySuffix, headers) return GET(baseUrl + chapter.url + qualitySuffix, headers)
} }
override fun pageListParse(document: Document): List<Page> { override fun pageListParse(document: Document): List<Page> {
// Declare some important values first // Declare some important values first
val encryptedLinks = mutableListOf<String>() var encryptedLinks = mutableListOf<String>()
val decryptionRegexKeys = mutableListOf<Pair<String, String>>()
// Get script elements // 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) { for (script in scripts) {
val scriptContent = script.data() QuickJs.create().use {
if (scriptContent.isNotEmpty()) { val eval =
val encryptedValues = scriptPageRegex.findAll(scriptContent) "let _encryptedString = `${script.data()}`;${remoteConfigItem!!.imageDecryptEval}"
val decryptionKeys = urlDecryptionRegex.findAll(scriptContent) val evalResult = (it.evaluate(eval) as String).parseAs<List<String>>()
// We found the encrypted links // Add results to 'encryptedLinks'
if (encryptedValues.count() > 0) { encryptedLinks.addAll(evalResult)
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]))
}
}
} }
} }
return encryptedLinks.mapIndexed { idx, rawUrl -> encryptedLinks = encryptedLinks.let { links ->
Page(idx, imageUrl = decryptLink(rawUrl, decryptionRegexKeys, "")) 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> val excluded: List<String>
get() = state.filter { it.isExcluded() }.map { it.gid } get() = state.filter { it.isExcluded() }.map { it.gid }
} }
open class SelectFilter(displayName: String, private val options: Array<Pair<String, String>>) : Filter.Select<String>(
displayName, open class SelectFilter(displayName: String, private val options: Array<Pair<String, String>>) :
options.map { it.first }.toTypedArray(), Filter.Select<String>(
) { displayName,
options.map { it.first }.toTypedArray(),
) {
open val selected get() = options[state].second.takeUnless { it.isEmpty() } open val selected get() = options[state].second.takeUnless { it.isEmpty() }
} }
@ -355,8 +384,28 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
// Preferences Code // Preferences Code
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) { override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
val qualitypref = androidx.preference.ListPreference(screen.context).apply { val remoteConfigPref = androidx.preference.EditTextPreference(screen.context).apply {
key = QUALITY_PREF_TITLE 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 title = QUALITY_PREF_TITLE
entries = arrayOf("High Quality", "Low Quality") entries = arrayOf("High Quality", "Low Quality")
entryValues = arrayOf("hq", "lq") entryValues = arrayOf("hq", "lq")
@ -369,9 +418,9 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
preferences.edit().putString(QUALITY_PREF, entry).commit() preferences.edit().putString(QUALITY_PREF, entry).commit()
} }
} }
screen.addPreference(qualitypref)
val serverpref = androidx.preference.ListPreference(screen.context).apply { val serverPref = androidx.preference.ListPreference(screen.context).apply {
key = SERVER_PREF_TITLE key = SERVER_PREF
title = SERVER_PREF_TITLE title = SERVER_PREF_TITLE
entries = arrayOf("Server 1", "Server 2") entries = arrayOf("Server 1", "Server 2")
entryValues = arrayOf("", "s2") entryValues = arrayOf("", "s2")
@ -384,17 +433,58 @@ class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
preferences.edit().putString(SERVER_PREF, entry).commit() 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 { companion object {
private const val QUALITY_PREF_TITLE = "Image Quality Selector" private const val QUALITY_PREF_TITLE = "Image Quality Selector"
private const val QUALITY_PREF = "qualitypref" private const val QUALITY_PREF = "qualitypref"
private const val SERVER_PREF_TITLE = "Server Preference" private const val SERVER_PREF_TITLE = "Server Preference"
private const val SERVER_PREF = "serverpref" 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
}