diff --git a/.github/workflows/issue_moderator.yml b/.github/workflows/issue_moderator.yml index 3a4db98b1..42ddb8510 100644 --- a/.github/workflows/issue_moderator.yml +++ b/.github/workflows/issue_moderator.yml @@ -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" }, diff --git a/src/en/readcomiconline/AndroidManifest.xml b/src/en/readcomiconline/AndroidManifest.xml new file mode 100644 index 000000000..30deb7f79 --- /dev/null +++ b/src/en/readcomiconline/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/en/readcomiconline/build.gradle b/src/en/readcomiconline/build.gradle new file mode 100644 index 000000000..3938f7cb0 --- /dev/null +++ b/src/en/readcomiconline/build.gradle @@ -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" diff --git a/src/en/readcomiconline/res/mipmap-hdpi/ic_launcher.png b/src/en/readcomiconline/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..8ed86a9a7 Binary files /dev/null and b/src/en/readcomiconline/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/readcomiconline/res/mipmap-mdpi/ic_launcher.png b/src/en/readcomiconline/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..5729b1fe5 Binary files /dev/null and b/src/en/readcomiconline/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/readcomiconline/res/mipmap-xhdpi/ic_launcher.png b/src/en/readcomiconline/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..b91895671 Binary files /dev/null and b/src/en/readcomiconline/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/readcomiconline/res/mipmap-xxhdpi/ic_launcher.png b/src/en/readcomiconline/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..3159e2302 Binary files /dev/null and b/src/en/readcomiconline/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/readcomiconline/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/readcomiconline/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..eb1335f4c Binary files /dev/null and b/src/en/readcomiconline/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/readcomiconline/res/web_hi_res_512.png b/src/en/readcomiconline/res/web_hi_res_512.png new file mode 100644 index 000000000..9894ccb25 Binary files /dev/null and b/src/en/readcomiconline/res/web_hi_res_512.png differ diff --git a/src/en/readcomiconline/src/eu/kanade/tachiyomi/extension/en/readcomiconline/Readcomiconline.kt b/src/en/readcomiconline/src/eu/kanade/tachiyomi/extension/en/readcomiconline/Readcomiconline.kt new file mode 100644 index 000000000..8485b6209 --- /dev/null +++ b/src/en/readcomiconline/src/eu/kanade/tachiyomi/extension/en/readcomiconline/Readcomiconline.kt @@ -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().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 { + 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 { + 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) : Filter.Group("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): List { + return QuickJs.create().use { + it.execute(rguardBytecode) + + val script = """ + var images = ${json.encodeToJsonElement(urls)}; + beau(images); + images; + """.trimIndent() + (it.evaluate(script) as Array).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() + } +}