FMReader pt.3: Revenge of the Obfuscation (#7129)
* Attemped to fix fetchPageListEncrypted * Remove ManhuaScan, HeroScan, ManhwaHot
|
@ -1,4 +0,0 @@
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation project(':lib-ratelimit')
|
|
||||||
}
|
|
Before Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 7.0 KiB |
Before Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 71 KiB |
|
@ -1,24 +0,0 @@
|
||||||
package eu.kanade.tachiyomi.extension.en.heroscan
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
|
|
||||||
import eu.kanade.tachiyomi.multisrc.fmreader.FMReader
|
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
|
|
||||||
class HeroScan : FMReader("HeroScan", "https://heroscan.com", "en") {
|
|
||||||
override val client: OkHttpClient = super.client.newBuilder()
|
|
||||||
.addInterceptor(RateLimitInterceptor(1))
|
|
||||||
.addInterceptor { chain ->
|
|
||||||
val originalRequest = chain.request()
|
|
||||||
chain.proceed(originalRequest).let { response ->
|
|
||||||
if (response.code == 403 && originalRequest.url.host.contains("b-cdn")) {
|
|
||||||
response.close()
|
|
||||||
chain.proceed(originalRequest.newBuilder().removeHeader("Referer").addHeader("Referer", "https://isekaiscan.com").build())
|
|
||||||
} else {
|
|
||||||
response
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
override fun fetchPageList(chapter: SChapter) = fetchPageListEncrypted(chapter)
|
|
||||||
}
|
|
|
@ -1,4 +0,0 @@
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation project(':lib-ratelimit')
|
|
||||||
}
|
|
Before Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 9.2 KiB |
Before Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 50 KiB |
|
@ -1,15 +0,0 @@
|
||||||
package eu.kanade.tachiyomi.extension.en.manhuascan
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
|
|
||||||
import eu.kanade.tachiyomi.multisrc.fmreader.FMReader
|
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
|
||||||
import eu.kanade.tachiyomi.annotations.Nsfw
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
|
|
||||||
@Nsfw
|
|
||||||
class ManhuaScan : FMReader("ManhuaScan", "https://manhuascan.com", "en") {
|
|
||||||
override val client: OkHttpClient = super.client.newBuilder()
|
|
||||||
.addInterceptor(RateLimitInterceptor(1))
|
|
||||||
.build()
|
|
||||||
override fun fetchPageList(chapter: SChapter) = fetchPageListEncrypted(chapter)
|
|
||||||
}
|
|
|
@ -1,4 +0,0 @@
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation project(':lib-ratelimit')
|
|
||||||
}
|
|
Before Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 8.2 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 44 KiB |
|
@ -1,15 +0,0 @@
|
||||||
package eu.kanade.tachiyomi.extension.en.manhwahot
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
|
|
||||||
import eu.kanade.tachiyomi.annotations.Nsfw
|
|
||||||
import eu.kanade.tachiyomi.multisrc.fmreader.FMReader
|
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
|
|
||||||
@Nsfw
|
|
||||||
class ManhwaHot : FMReader("ManhwaHot", "https://manhwahot.com", "en") {
|
|
||||||
override val client: OkHttpClient = super.client.newBuilder()
|
|
||||||
.addInterceptor(RateLimitInterceptor(1))
|
|
||||||
.build()
|
|
||||||
override fun fetchPageList(chapter: SChapter) = fetchPageListEncrypted(chapter)
|
|
||||||
}
|
|
|
@ -1,10 +1,7 @@
|
||||||
package eu.kanade.tachiyomi.multisrc.fmreader
|
package eu.kanade.tachiyomi.multisrc.fmreader
|
||||||
|
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import com.google.gson.JsonParser
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.POST
|
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
|
||||||
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
|
||||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
|
@ -15,26 +12,14 @@ import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||||
import eu.kanade.tachiyomi.util.asJsoup
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okio.ByteString
|
|
||||||
import okio.ByteString.Companion.decodeBase64
|
|
||||||
import okio.ByteString.Companion.decodeHex
|
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import org.jsoup.select.Elements
|
import org.jsoup.select.Elements
|
||||||
import rx.Observable
|
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import javax.crypto.BadPaddingException
|
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.spec.IvParameterSpec
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For sites based on the Flat-Manga CMS
|
* For sites based on the Flat-Manga CMS
|
||||||
*/
|
*/
|
||||||
|
@ -328,32 +313,6 @@ abstract class FMReader(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* fetches the pageList by decrypting and parsing the json response of a "chapter server"
|
|
||||||
* Meant to be used by relevant subclasses to replace their implementation of fetchPageList as needed
|
|
||||||
* e.g ManhuaScan, HeroScan
|
|
||||||
*/
|
|
||||||
protected fun fetchPageListEncrypted(chapter: SChapter): Observable<List<Page>> {
|
|
||||||
fun stringAssignment(varname: String, script: String) = Regex("""(?:let|var)\s+$varname\s*=\s*"([^"]*)"""").find(script)?.groups?.get(1)?.value
|
|
||||||
fun pageList(s: String) = Regex("https.+?(?=https|\"$)").findAll(s).map { it.groups[0]!!.value.replace("\\/", "/") }
|
|
||||||
fun pageListRequest(id: String, server: Int = 1) = POST("$baseUrl/app/manga/controllers/cont.chapterServer$server.php", headers, "id=$id".toRequestBody("application/x-www-form-urlencoded; charset=UTF-8".toMediaTypeOrNull()))
|
|
||||||
return client.newCall(GET("$baseUrl${chapter.url}", headers)).asObservableSuccess().concatMap { htmlResponse ->
|
|
||||||
val soup = htmlResponse.asJsoup()
|
|
||||||
soup.selectFirst("head > script[type='text/javascript']")?.data()?.let { params ->
|
|
||||||
stringAssignment("chapter_id", params)?.let { chapterId ->
|
|
||||||
client.newCall(pageListRequest(chapterId)).asObservableSuccess()
|
|
||||||
.map { jsonResponse ->
|
|
||||||
try {
|
|
||||||
pageList(crypto.aes_decrypt(jsonResponse.body!!.string(), "4xje8fvkub2d3mb5cy9rv661zyjakbcn".toByteArray()))
|
|
||||||
.mapIndexed { i, imgUrl -> Page(i, "", imgUrl) }.toList()
|
|
||||||
} catch (_: BadPaddingException) {
|
|
||||||
throw RuntimeException("Decryption Failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} ?: Observable.just(emptyList())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used")
|
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used")
|
||||||
|
|
||||||
|
@ -513,74 +472,4 @@ abstract class FMReader(
|
||||||
Genre("Zombies")
|
Genre("Zombies")
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
|
||||||
interface Crypto {
|
|
||||||
fun md5(s: String): String;
|
|
||||||
fun aes_decrypt(context: String, key: ByteArray): String;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* cryptography utilities leveraged by fetchPageListEncrypted */
|
|
||||||
val crypto = object : Crypto {
|
|
||||||
private fun parseCryptoCT(s: String): Map<String, ByteString> {
|
|
||||||
// https://cryptojs.gitbook.io/docs/
|
|
||||||
val jsonObj = JsonParser.parseString(s).asJsonObject
|
|
||||||
val cipherParams = mutableMapOf("ciphertext" to jsonObj["ct"].asString.decodeBase64()!!)
|
|
||||||
jsonObj["iv"]?.let { cipherParams.put("iv", it.asString.decodeHex()) }
|
|
||||||
jsonObj["s"]?.let { cipherParams.put("salt", it.asString.decodeHex()) }
|
|
||||||
return cipherParams
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decryptAES(encrypted: ByteString, password: ByteArray, iv: ByteString, salt: ByteString): String {
|
|
||||||
// https://stackoverflow.com/questions/29151211/29152379#29152379
|
|
||||||
val (key, _iv) = EvpKDF(password, 256, 128, salt.toByteArray(), 1, "MD5")
|
|
||||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(_iv))
|
|
||||||
return String(cipher.doFinal(encrypted.toByteArray()), Charset.forName("UTF-8"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun EvpKDF(password: ByteArray, _keySize: Int, _ivSize: Int, salt: ByteArray, iterations: Int, hashAlgorithm: String): Pair<ByteArray, ByteArray> {
|
|
||||||
// Key Derivation Function
|
|
||||||
val keySize = _keySize / 32
|
|
||||||
val ivSize = _ivSize / 32
|
|
||||||
val targetKeySize = keySize + ivSize
|
|
||||||
val derivedBytes = ByteArray(targetKeySize * 4)
|
|
||||||
var numberOfDerivedWords = 0
|
|
||||||
var block = ByteArray(0)
|
|
||||||
val hasher = MessageDigest.getInstance(hashAlgorithm)
|
|
||||||
val key = ByteArray(keySize * 4)
|
|
||||||
val iv = ByteArray(ivSize * 4)
|
|
||||||
while (numberOfDerivedWords < targetKeySize) {
|
|
||||||
if (block != null) {
|
|
||||||
hasher.update(block)
|
|
||||||
}
|
|
||||||
hasher.update(password)
|
|
||||||
block = hasher.digest(salt)
|
|
||||||
hasher.reset()
|
|
||||||
|
|
||||||
// Iterations
|
|
||||||
for (i in 1 until iterations) {
|
|
||||||
block = hasher.digest(block)
|
|
||||||
hasher.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
System.arraycopy(block, 0, derivedBytes, numberOfDerivedWords * 4,
|
|
||||||
block.size.coerceAtMost((targetKeySize - numberOfDerivedWords) * 4))
|
|
||||||
|
|
||||||
numberOfDerivedWords += block.size / 4
|
|
||||||
}
|
|
||||||
|
|
||||||
System.arraycopy(derivedBytes, 0, key, 0, key.size)
|
|
||||||
System.arraycopy(derivedBytes, key.size, iv, 0, iv.size)
|
|
||||||
|
|
||||||
return key to iv // key + iv
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun md5(s: String): String = MessageDigest.getInstance("MD5").digest(s.toByteArray()).joinToString("") { String.format("%02x", it) }
|
|
||||||
|
|
||||||
override fun aes_decrypt(context: String, key: ByteArray): String {
|
|
||||||
val cipherParams = parseCryptoCT(JsonParser.parseString(context).asJsonObject["content"].asString)
|
|
||||||
return decryptAES(cipherParams["ciphertext"]!!, key, cipherParams["iv"]!!, cipherParams["salt"]!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,17 +17,14 @@ class FMReaderGenerator : ThemeSourceGenerator {
|
||||||
|
|
||||||
override val sources = listOf(
|
override val sources = listOf(
|
||||||
SingleLang("Epik Manga", "https://www.epikmanga.com", "tr"),
|
SingleLang("Epik Manga", "https://www.epikmanga.com", "tr"),
|
||||||
SingleLang("HeroScan", "https://heroscan.com", "en", overrideVersionCode = 2),
|
|
||||||
SingleLang("KissLove", "https://kissaway.net", "ja"),
|
SingleLang("KissLove", "https://kissaway.net", "ja"),
|
||||||
SingleLang("LHTranslation", "https://lhtranslation.net", "en", overrideVersionCode = 1),
|
SingleLang("LHTranslation", "https://lhtranslation.net", "en", overrideVersionCode = 1),
|
||||||
SingleLang("Manga-TR", "https://manga-tr.com", "tr", className = "MangaTR"),
|
SingleLang("Manga-TR", "https://manga-tr.com", "tr", className = "MangaTR"),
|
||||||
SingleLang("ManhuaScan", "https://manhuascan.com", "en", isNsfw = true, overrideVersionCode = 3),
|
|
||||||
SingleLang("Manhwa18", "https://manhwa18.com", "en", isNsfw = true),
|
SingleLang("Manhwa18", "https://manhwa18.com", "en", isNsfw = true),
|
||||||
MultiLang("Manhwa18.net", "https://manhwa18.net", listOf("en", "ko"), className = "Manhwa18NetFactory", isNsfw = true),
|
MultiLang("Manhwa18.net", "https://manhwa18.net", listOf("en", "ko"), className = "Manhwa18NetFactory", isNsfw = true),
|
||||||
SingleLang("RawLH", "https://lovehug.net", "ja"),
|
SingleLang("RawLH", "https://lovehug.net", "ja"),
|
||||||
SingleLang("Say Truyen", "https://saytruyen.com", "vi"),
|
SingleLang("Say Truyen", "https://saytruyen.com", "vi"),
|
||||||
SingleLang("KSGroupScans", "https://ksgroupscans.com", "en"),
|
SingleLang("KSGroupScans", "https://ksgroupscans.com", "en"),
|
||||||
SingleLang("ManhwaHot", "https://manhwahot.com", "en", isNsfw = true, overrideVersionCode=1),
|
|
||||||
// Sites that went down
|
// Sites that went down
|
||||||
//SingleLang("18LHPlus", "https://18lhplus.com", "en", className = "EighteenLHPlus"),
|
//SingleLang("18LHPlus", "https://18lhplus.com", "en", className = "EighteenLHPlus"),
|
||||||
//SingleLang("HanaScan (RawQQ)", "https://hanascan.com", "ja", className = "HanaScanRawQQ"),
|
//SingleLang("HanaScan (RawQQ)", "https://hanascan.com", "ja", className = "HanaScanRawQQ"),
|
||||||
|
|