unyeet ReadComicOnline (#13059)

* Revert "yeet (#11407)"

This reverts commit db1685b912.

* Solve URLs via QuickJs

* Move decryption to `pageListParse()`

* handle captcha challenge

* recreate qjs for each invocation, to prevent QuickJsException("stack overflow")

* Update src/en/readcomiconline/src/eu/kanade/tachiyomi/extension/en/readcomiconline/Readcomiconline.kt

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>

* Update src/en/readcomiconline/src/eu/kanade/tachiyomi/extension/en/readcomiconline/Readcomiconline.kt

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>

* fix return type of rguardBytecode

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>
This commit is contained in:
Vetle Ledaal 2022-08-19 04:13:52 +02:00 committed by GitHub
parent 66534d1899
commit 50221eb16f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 362 additions and 1 deletions

View File

@ -37,7 +37,7 @@ jobs:
},
{
"type": "both",
"regex": ".*(mangago|mangafox|hq\\s*dragon|manga\\s*host|supermangas|superhentais|union\\s*mangas|yes\\s*mangas|manhuascan|heroscan|manhwahot|leitor\\.?net|manga\\s*livre|tsuki\\s*mangas|manga\\s*yabu|mangas\\.in|mangas\\.pw|hentaikai|toptoon\\+?|read\\s*comic\\s*online|cocomanga|hitomi\\.la|copymanga|neox).*",
"regex": ".*(mangago|mangafox|hq\\s*dragon|manga\\s*host|supermangas|superhentais|union\\s*mangas|yes\\s*mangas|manhuascan|heroscan|manhwahot|leitor\\.?net|manga\\s*livre|tsuki\\s*mangas|manga\\s*yabu|mangas\\.in|mangas\\.pw|hentaikai|toptoon\\+?|cocomanga|hitomi\\.la|copymanga|neox).*",
"ignoreCase": true,
"message": "{match} will not be added back as it is too difficult to maintain. Read #3475 for more information"
},

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.extension" />

View File

@ -0,0 +1,11 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'ReadComicOnline'
pkgNameSuffix = 'en.readcomiconline'
extClass = '.Readcomiconline'
extVersionCode = 13
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,348 @@
package eu.kanade.tachiyomi.extension.en.readcomiconline
import android.app.Application
import android.content.SharedPreferences
import app.cash.quickjs.QuickJs
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import okhttp3.CacheControl
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
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 uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.TimeUnit
class Readcomiconline : ConfigurableSource, ParsedHttpSource() {
override val name = "ReadComicOnline"
override val baseUrl = "https://readcomiconline.li"
override val lang = "en"
override val supportsLatest = true
private val json: Json by injectLazy()
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.addNetworkInterceptor(::captchaInterceptor)
.build()
private fun captchaInterceptor(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
val location = response.header("Location")
if (location?.startsWith("/Special/AreYouHuman") == true) {
captchaUrl = "$baseUrl/Special/AreYouHuman"
throw Exception("Solve captcha in WebView")
}
return response
}
private var captchaUrl: String? = null
override fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
}
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun popularMangaSelector() = ".list-comic > .item > a:first-child"
override fun latestUpdatesSelector() = popularMangaSelector()
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/ComicList/MostPopular?page=$page", headers)
}
override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/ComicList/LatestUpdate?page=$page", headers)
}
override fun popularMangaFromElement(element: Element): SManga {
return SManga.create().apply {
setUrlWithoutDomain(element.attr("abs:href"))
title = element.text()
thumbnail_url = element.selectFirst("img")!!.attr("abs:src")
}
}
override fun latestUpdatesFromElement(element: Element): SManga {
return popularMangaFromElement(element)
}
override fun popularMangaNextPageSelector() = "li > a:contains(Next)"
override fun latestUpdatesNextPageSelector(): String = "ul.pager > li > a:contains(Next)"
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val form = FormBody.Builder().apply {
add("comicName", query)
for (filter in if (filters.isEmpty()) getFilterList() else filters) {
when (filter) {
is Status -> add("status", arrayOf("", "Completed", "Ongoing")[filter.state])
is GenreList -> filter.state.forEach { genre -> add("genres", genre.state.toString()) }
else -> {}
}
}
}
return POST("$baseUrl/AdvanceSearch", headers, form.build())
}
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element): SManga {
return popularMangaFromElement(element)
}
override fun searchMangaNextPageSelector(): String? = null
override fun mangaDetailsParse(document: Document): SManga {
val infoElement = document.select("div.barContent").first()
val manga = SManga.create()
manga.artist = infoElement.select("p:has(span:contains(Artist:)) > 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.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.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()
.map { response ->
mangaDetailsParse(response).apply { initialized = true }
}
}
private fun realMangaDetailsRequest(manga: SManga): Request =
super.mangaDetailsRequest(manga)
override fun mangaDetailsRequest(manga: SManga): Request =
captchaUrl?.let { GET(it, headers) }.also { captchaUrl = null }
?: super.mangaDetailsRequest(manga)
private fun parseStatus(status: String) = when {
status.contains("Ongoing") -> SManga.ONGOING
status.contains("Completed") -> SManga.COMPLETED
else -> SManga.UNKNOWN
}
override fun chapterListSelector() = "table.listing tr:gt(1)"
override fun chapterFromElement(element: Element): SChapter {
val urlElement = element.select("a").first()
val chapter = SChapter.create()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text()
chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let {
SimpleDateFormat("MM/dd/yyyy", Locale.getDefault()).parse(it)?.time ?: 0L
} ?: 0
return chapter
}
override fun pageListRequest(chapter: SChapter): Request {
val qualitySuffix = if (qualitypref() != "lq") "&quality=${qualitypref()}" else ""
return GET(baseUrl + chapter.url + qualitySuffix, headers)
}
override fun pageListParse(document: Document): List<Page> {
if (rguardUrl == null) {
rguardUrl = document.selectFirst("script[src*='rguard.min.js']")?.absUrl("src")
}
val script = document.selectFirst("script:containsData(lstImages.push)")?.data()
?: throw Exception("Failed to find image URLs")
return CHAPTER_IMAGES_REGEX.findAll(script).toList()
.let { matches -> urlDecode(matches.map { it.groupValues[1] }) }
.mapIndexed { i, imageUrl -> Page(i, "", imageUrl) }
}
override fun imageUrlParse(document: Document) = ""
private class Status : Filter.TriState("Completed")
private class Genre(name: String) : Filter.TriState(name)
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
override fun getFilterList() = FilterList(
Status(),
GenreList(getGenreList())
)
// $("select[name=\"genres\"]").map((i,el) => `Genre("${$(el).next().text().trim()}", ${i})`).get().join(',\n')
// on https://readcomiconline.li/AdvanceSearch
private fun getGenreList() = listOf(
Genre("Action"),
Genre("Adventure"),
Genre("Anthology"),
Genre("Anthropomorphic"),
Genre("Biography"),
Genre("Children"),
Genre("Comedy"),
Genre("Crime"),
Genre("Drama"),
Genre("Family"),
Genre("Fantasy"),
Genre("Fighting"),
Genre("Graphic Novels"),
Genre("Historical"),
Genre("Horror"),
Genre("Leading Ladies"),
Genre("LGBTQ"),
Genre("Literature"),
Genre("Manga"),
Genre("Martial Arts"),
Genre("Mature"),
Genre("Military"),
Genre("Movies & TV"),
Genre("Music"),
Genre("Mystery"),
Genre("Mythology"),
Genre("Personal"),
Genre("Political"),
Genre("Post-Apocalyptic"),
Genre("Psychological"),
Genre("Pulp"),
Genre("Religious"),
Genre("Robots"),
Genre("Romance"),
Genre("School Life"),
Genre("Sci-Fi"),
Genre("Slice of Life"),
Genre("Sport"),
Genre("Spy"),
Genre("Superhero"),
Genre("Supernatural"),
Genre("Suspense"),
Genre("Thriller"),
Genre("Vampires"),
Genre("Video Games"),
Genre("War"),
Genre("Western"),
Genre("Zombies")
)
// Preferences Code
override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) {
val qualitypref = androidx.preference.ListPreference(screen.context).apply {
key = QUALITY_PREF_Title
title = QUALITY_PREF_Title
entries = arrayOf("High Quality", "Low Quality")
entryValues = arrayOf("hq", "lq")
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = this.findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(QUALITY_PREF, entry).commit()
}
}
screen.addPreference(qualitypref)
}
private fun qualitypref() = preferences.getString(QUALITY_PREF, "hq")
private var rguardUrl: String? = null
private val rguardBytecode: ByteArray by lazy {
val cacheDays = if (rguardUrl == null) 1 else 7
val cacheControl = CacheControl.Builder()
.maxAge(cacheDays, TimeUnit.DAYS)
.build()
val scriptUrl = rguardUrl ?: "$baseUrl/Scripts/rguard.min.js"
val scriptRequest = GET(scriptUrl, headers, cache = cacheControl)
val scriptResponse = client.newCall(scriptRequest).execute()
val scriptBody = scriptResponse.body?.string() ?: ""
val scriptParts = RGUARD_REGEX.find(scriptBody)?.groupValues?.drop(1)
?: throw Exception("Unable to parse rguard script")
QuickJs.create().use {
it.compile(scriptParts.joinToString("") + ATOB_SCRIPT, "?")
}
}
@Suppress("UNCHECKED_CAST")
private fun urlDecode(urls: List<String>): List<String> {
return QuickJs.create().use {
it.execute(rguardBytecode)
val script = """
var images = ${json.encodeToJsonElement(urls)};
beau(images);
images;
""".trimIndent()
(it.evaluate(script) as Array<Any>).map { it as String }.toList()
}
}
companion object {
private const val QUALITY_PREF_Title = "Image Quality Selector"
private const val QUALITY_PREF = "qualitypref"
private val CHAPTER_IMAGES_REGEX = "lstImages\\.push\\([\"'](.*)[\"']\\)".toRegex()
private val RGUARD_REGEX = "(^.+?)var \\w=\\(function\\(\\).+?;(function \\w+.+?\\})if.+?(function beau.+)".toRegex()
/*
* The MIT License (MIT)
* Copyright (c) 2014 MaxArt2501
*/
private val ATOB_SCRIPT = """
var b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
b64re = /^(?:[A-Za-z\d+\/]{4})*?(?:[A-Za-z\d+\/]{2}(?:==)?|[A-Za-z\d+\/]{3}=?)?$/;
atob = function(string) {
// atob can work with strings with whitespaces, even inside the encoded part,
// but only \t, \n, \f, \r and ' ', which can be stripped.
string = String(string).replace(/[\t\n\f\r ]+/g, "");
if (!b64re.test(string))
throw new TypeError("Failed to execute 'atob' on 'Window': The string to be decoded is not correctly encoded.");
// Adding the padding if missing, for semplicity
string += "==".slice(2 - (string.length & 3));
var bitmap, result = "", r1, r2, i = 0;
for (; i < string.length;) {
bitmap = b64.indexOf(string.charAt(i++)) << 18 | b64.indexOf(string.charAt(i++)) << 12
| (r1 = b64.indexOf(string.charAt(i++))) << 6 | (r2 = b64.indexOf(string.charAt(i++)));
result += r1 === 64 ? String.fromCharCode(bitmap >> 16 & 255)
: r2 === 64 ? String.fromCharCode(bitmap >> 16 & 255, bitmap >> 8 & 255)
: String.fromCharCode(bitmap >> 16 & 255, bitmap >> 8 & 255, bitmap & 255);
}
return result;
};
""".trimIndent()
}
}