MangaFire: search vrf from webview (#11396)

* MangaFire: WebView it out

* bump & stuff
This commit is contained in:
AwkwardPeak7 2025-11-04 19:58:32 +05:00 committed by Draff
parent 399f44d219
commit 8f13e4185c
Signed by: Draff
GPG Key ID: E8A89F3211677653
4 changed files with 290 additions and 370 deletions

View File

@ -1,7 +1,7 @@
ext {
extName = 'MangaFire'
extClass = '.MangaFireFactory'
extVersionCode = 15
extVersionCode = 16
isNsfw = true
}

View File

@ -1,16 +1,10 @@
package eu.kanade.tachiyomi.extension.all.mangafire
import android.annotation.SuppressLint
import android.app.Application
import android.util.Log
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
@ -22,24 +16,17 @@ import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.int
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import okhttp3.internal.charset
import okio.Buffer
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import rx.Observable
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.text.SimpleDateFormat
@ -47,9 +34,6 @@ import java.util.Locale
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.time.Duration.Companion.seconds
class MangaFire(
override val lang: String,
@ -85,6 +69,15 @@ class MangaFire(
override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
private val webViewHelper = WebViewHelper(client, headers)
// dirty hack to disable suggested mangas on Komikku
// we don't want to spawn N webviews for N search token
// https://github.com/komikku-app/komikku/blob/4323fd5841b390213aa4c4af77e07ad42eb423fc/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt#L176-L184
@Suppress("Unused")
@JvmName("getDisableRelatedMangasBySearch")
fun disableRelatedMangasBySearch() = true
// ============================== Popular ===============================
override fun popularMangaRequest(page: Int): Request {
@ -111,12 +104,18 @@ class MangaFire(
// =============================== Search ===============================
private val vrfCache = object : LinkedHashMap<String, String>() {
override fun removeEldestEntry(eldest: Map.Entry<String?, String?>?) = size > 20
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val stdQuery = query.replace("\"", " ").trim()
val url = baseUrl.toHttpUrl().newBuilder().apply {
addPathSegment("filter")
if (query.isNotBlank()) {
addQueryParameter("keyword", query.trim())
if (stdQuery.isNotBlank()) {
addQueryParameter("keyword", stdQuery)
}
val filterList = filters.ifEmpty { getFilterList() }
@ -127,8 +126,39 @@ class MangaFire(
addQueryParameter("language[]", langCode)
addQueryParameter("page", page.toString())
if (query.isNotBlank()) {
val vrf = VrfGenerator.generate(query.trim())
if (stdQuery.isNotBlank()) {
val vrf = vrfCache.get(stdQuery)
?: runBlocking {
webViewHelper.loadInWebView(
url = "$baseUrl/home",
requestIntercept = { request ->
val url = request.url
if (
url.host == "mangafire.to" &&
url.encodedPath.orEmpty().contains("ajax/manga/search")
) {
WebViewHelper.RequestIntercept.Capture
} else {
WebViewHelper.RequestIntercept.Block
}
},
onPageFinish = { view ->
view.evaluateJavascript(
"""
$(function() {
setInterval(() => {
$(".search-inner input[name=keyword]").val("$stdQuery").trigger("keyup");
}, 1000);
});
""".trimIndent(),
) {}
},
)
}.toHttpUrl().queryParameter("vrf")
?.takeIf { it.isNotBlank() }
?.also { vrfCache.put(stdQuery, it) }
?: throw Exception("Unable to find vrf token")
addQueryParameter("vrf", vrf)
}
}.build()
@ -267,171 +297,49 @@ class MangaFire(
// =============================== Pages ================================
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
val ajaxUrl = runBlocking { getVrfFromWebview(document) }
return client.newCall(GET(ajaxUrl, headers)).execute()
.parseAs<ResponseDto<PageListDto>>().result
.pages.mapIndexed { index, image ->
val url = image.url
val offset = image.offset
val imageUrl = if (offset > 0) "$url#${ImageInterceptor.SCRAMBLED}_$offset" else url
Page(index, imageUrl = imageUrl)
}
}
@SuppressLint("SetJavaScriptEnabled")
private suspend fun getVrfFromWebview(document: Document): String = withContext(Dispatchers.Main.immediate) {
withTimeoutOrNull(20.seconds) {
suspendCancellableCoroutine { continuation ->
val emptyWebViewResponse = runCatching {
WebResourceResponse("text/html", "utf-8", Buffer().inputStream())
}.getOrElse {
continuation.resumeWithException(it)
return@suspendCancellableCoroutine
}
val context = Injekt.get<Application>()
var webview: WebView? = WebView(context)
fun cleanup() = runBlocking(Dispatchers.Main.immediate) {
webview?.stopLoading()
webview?.destroy()
webview = null
}
webview?.apply {
with(settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
blockNetworkImage = true
}
webViewClient = object : WebViewClient() {
private val ajaxCalls = setOf("ajax/read/chapter", "ajax/read/volume")
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest,
): WebResourceResponse? {
val url = request.url
// allow script from their cdn
if (
url.host.orEmpty().contains("mfcdn.cc") &&
url.pathSegments.lastOrNull().orEmpty().contains("js")
) {
Log.d(name, "allowed: $url")
runCatching { fetchWebResource(request) }
.onSuccess { return it }
.onFailure {
if (continuation.isActive) {
continuation.resumeWithException(it)
cleanup()
}
}
}
// allow jquery script
if (
url.host.orEmpty().contains("cloudflare.com") &&
url.encodedPath.orEmpty().contains("jquery")
) {
Log.d(name, "allowed: $url")
runCatching { fetchWebResource(request) }
.onSuccess { return it }
.onFailure {
if (continuation.isActive) {
continuation.resumeWithException(it)
cleanup()
}
}
}
// allow ajax/read calls and intercept ajax/read/chapter or ajax/read/volume
if (
url.host == "mangafire.to" &&
url.encodedPath.orEmpty().contains("ajax/read")
) {
if (ajaxCalls.any { url.encodedPath!!.contains(it) }) {
Log.d(name, "found: $url")
if (url.getQueryParameter("vrf") != null) {
if (continuation.isActive) {
continuation.resume(url.toString())
cleanup()
}
} else {
if (continuation.isActive) {
continuation.resumeWithException(
Exception("Unable to find vrf token"),
)
cleanup()
}
}
} else {
// need to allow other call to ajax/read
Log.d(name, "allowed: $url")
runCatching { fetchWebResource(request) }
.onSuccess { return it }
.onFailure {
if (continuation.isActive) {
continuation.resumeWithException(it)
cleanup()
}
}
}
}
Log.d(name, "denied: $url")
return emptyWebViewResponse
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
val intercepted = runBlocking {
webViewHelper.loadInWebView(
url = "$baseUrl${chapter.url}",
requestIntercept = { request ->
val url = request.url
if (
url.host == "mangafire.to" &&
url.encodedPath.orEmpty().contains("ajax/read")
) {
if (setOf("ajax/read/chapter", "ajax/read/volume").any { url.encodedPath!!.contains(it) }) {
WebViewHelper.RequestIntercept.Capture
} else {
// need to allow other call to ajax/read
WebViewHelper.RequestIntercept.Allow
}
} else {
WebViewHelper.RequestIntercept.Block
}
loadDataWithBaseURL(
document.location(),
document.outerHtml(),
"text/html",
"utf-8",
"",
)
}
continuation.invokeOnCancellation {
cleanup()
}
}
} ?: throw Exception("Timeout getting vrf token")
}
private fun fetchWebResource(request: WebResourceRequest): WebResourceResponse = runBlocking(Dispatchers.IO) {
val okhttpRequest = Request.Builder().apply {
url(request.url.toString())
headers(headers)
val skipHeaders = setOf("referer", "user-agent", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "x-requested-with")
for ((name, value) in request.requestHeaders) {
if (skipHeaders.contains(name.lowercase())) continue
addHeader(name, value)
}
}.build()
client.newCall(okhttpRequest).await().use { response ->
val mediaType = response.body.contentType()
WebResourceResponse(
mediaType?.let { "${it.type}/${it.subtype}" },
mediaType?.charset()?.name(),
Buffer().readFrom(
response.body.byteStream(),
).inputStream(),
},
onPageFinish = {},
)
}
if (intercepted.toHttpUrl().queryParameter("vrf") == null) {
throw Exception("Unable to find vrf token")
}
return client.newCall(GET(intercepted, headers))
.asObservableSuccess().map {
it.parseAs<ResponseDto<PageListDto>>().result
.pages.mapIndexed { index, image ->
val url = image.url
val offset = image.offset
val imageUrl =
if (offset > 0) "$url#${ImageInterceptor.SCRAMBLED}_$offset" else url
Page(index, imageUrl = imageUrl)
}
}
}
override fun pageListParse(response: Response): List<Page> {
throw UnsupportedOperationException()
}
@Serializable

View File

@ -1,186 +0,0 @@
package eu.kanade.tachiyomi.extension.all.mangafire
import android.util.Base64
/**
* Original script by @Trung0246 on Github
*/
object VrfGenerator {
private fun atob(data: String): ByteArray = Base64.decode(data, Base64.DEFAULT)
private fun btoa(data: ByteArray): String = Base64.encodeToString(data, Base64.DEFAULT)
private fun rc4(key: ByteArray, input: ByteArray): ByteArray {
val s = IntArray(256) { it }
var j = 0
// KSA
for (i in 0..255) {
j = (j + s[i] + key[i % key.size].toInt().and(0xFF)) and 0xFF
val temp = s[i]
s[i] = s[j]
s[j] = temp
}
// PRGA
val output = ByteArray(input.size)
var i = 0
j = 0
for (y in input.indices) {
i = (i + 1) and 0xFF
j = (j + s[i]) and 0xFF
val temp = s[i]
s[i] = s[j]
s[j] = temp
val k = s[(s[i] + s[j]) and 0xFF]
output[y] = (input[y].toInt() xor k).toByte()
}
return output
}
private fun transform(
input: ByteArray,
initSeedBytes: ByteArray,
prefixKeyBytes: ByteArray,
prefixLen: Int,
schedule: List<(Int) -> Int>,
): ByteArray {
val out = mutableListOf<Byte>()
for (i in input.indices) {
if (i < prefixLen) {
out.add(prefixKeyBytes[i])
}
val transformed = schedule[i % 10](
(input[i].toInt() xor initSeedBytes[i % 32].toInt()) and 0xFF,
) and 0xFF
out.add(transformed.toByte())
}
return out.toByteArray()
}
private val scheduleC = listOf<(Int) -> Int>(
{ c -> (c - 48 + 256) and 0xFF },
{ c -> (c - 19 + 256) and 0xFF },
{ c -> (c xor 241) and 0xFF },
{ c -> (c - 19 + 256) and 0xFF },
{ c -> (c + 223) and 0xFF },
{ c -> (c - 19 + 256) and 0xFF },
{ c -> (c - 170 + 256) and 0xFF },
{ c -> (c - 19 + 256) and 0xFF },
{ c -> (c - 48 + 256) and 0xFF },
{ c -> (c xor 8) and 0xFF },
)
private val scheduleY = listOf<(Int) -> Int>(
{ c -> ((c shl 4) or (c ushr 4)) and 0xFF },
{ c -> (c + 223) and 0xFF },
{ c -> ((c shl 4) or (c ushr 4)) and 0xFF },
{ c -> (c xor 163) and 0xFF },
{ c -> (c - 48 + 256) and 0xFF },
{ c -> (c + 82) and 0xFF },
{ c -> (c + 223) and 0xFF },
{ c -> (c - 48 + 256) and 0xFF },
{ c -> (c xor 83) and 0xFF },
{ c -> ((c shl 4) or (c ushr 4)) and 0xFF },
)
private val scheduleB = listOf<(Int) -> Int>(
{ c -> (c - 19 + 256) and 0xFF },
{ c -> (c + 82) and 0xFF },
{ c -> (c - 48 + 256) and 0xFF },
{ c -> (c - 170 + 256) and 0xFF },
{ c -> ((c shl 4) or (c ushr 4)) and 0xFF },
{ c -> (c - 48 + 256) and 0xFF },
{ c -> (c - 170 + 256) and 0xFF },
{ c -> (c xor 8) and 0xFF },
{ c -> (c + 82) and 0xFF },
{ c -> (c xor 163) and 0xFF },
)
private val scheduleJ = listOf<(Int) -> Int>(
{ c -> (c + 223) and 0xFF },
{ c -> ((c shl 4) or (c ushr 4)) and 0xFF },
{ c -> (c + 223) and 0xFF },
{ c -> (c xor 83) and 0xFF },
{ c -> (c - 19 + 256) and 0xFF },
{ c -> (c + 223) and 0xFF },
{ c -> (c - 170 + 256) and 0xFF },
{ c -> (c + 223) and 0xFF },
{ c -> (c - 170 + 256) and 0xFF },
{ c -> (c xor 83) and 0xFF },
)
private val scheduleE = listOf<(Int) -> Int>(
{ c -> (c + 82) and 0xFF },
{ c -> (c xor 83) and 0xFF },
{ c -> (c xor 163) and 0xFF },
{ c -> (c + 82) and 0xFF },
{ c -> (c - 170 + 256) and 0xFF },
{ c -> (c xor 8) and 0xFF },
{ c -> (c xor 241) and 0xFF },
{ c -> (c + 82) and 0xFF },
{ c -> (c + 176) and 0xFF },
{ c -> ((c shl 4) or (c ushr 4)) and 0xFF },
)
private val rc4Keys = mapOf(
"l" to "u8cBwTi1CM4XE3BkwG5Ble3AxWgnhKiXD9Cr279yNW0=",
"g" to "t00NOJ/Fl3wZtez1xU6/YvcWDoXzjrDHJLL2r/IWgcY=",
"B" to "S7I+968ZY4Fo3sLVNH/ExCNq7gjuOHjSRgSqh6SsPJc=",
"m" to "7D4Q8i8dApRj6UWxXbIBEa1UqvjI+8W0UvPH9talJK8=",
"F" to "0JsmfWZA1kwZeWLk5gfV5g41lwLL72wHbam5ZPfnOVE=",
)
private val seeds32 = mapOf(
"A" to "pGjzSCtS4izckNAOhrY5unJnO2E1VbrU+tXRYG24vTo=",
"V" to "dFcKX9Qpu7mt/AD6mb1QF4w+KqHTKmdiqp7penubAKI=",
"N" to "owp1QIY/kBiRWrRn9TLN2CdZsLeejzHhfJwdiQMjg3w=",
"P" to "H1XbRvXOvZAhyyPaO68vgIUgdAHn68Y6mrwkpIpEue8=",
"k" to "2Nmobf/mpQ7+Dxq1/olPSDj3xV8PZkPbKaucJvVckL0=",
)
private val prefixKeys = mapOf(
"O" to "Rowe+rg/0g==",
"v" to "8cULcnOMJVY8AA==",
"L" to "n2+Og2Gth8Hh",
"p" to "aRpvzH+yoA==",
"W" to "ZB4oBi0=",
)
fun generate(input: String): String {
var bytes = input.toByteArray()
// RC4 1
bytes = rc4(atob(rc4Keys["l"]!!), bytes)
// Step C1
bytes = transform(bytes, atob(seeds32["A"]!!), atob(prefixKeys["O"]!!), 7, scheduleC)
// RC4 2
bytes = rc4(atob(rc4Keys["g"]!!), bytes)
// Step Y
bytes = transform(bytes, atob(seeds32["V"]!!), atob(prefixKeys["v"]!!), 10, scheduleY)
// RC4 3
bytes = rc4(atob(rc4Keys["B"]!!), bytes)
// Step B
bytes = transform(bytes, atob(seeds32["N"]!!), atob(prefixKeys["L"]!!), 9, scheduleB)
// RC4 4
bytes = rc4(atob(rc4Keys["m"]!!), bytes)
// Step J
bytes = transform(bytes, atob(seeds32["P"]!!), atob(prefixKeys["p"]!!), 7, scheduleJ)
// RC4 5
bytes = rc4(atob(rc4Keys["F"]!!), bytes)
// Step E
bytes = transform(bytes, atob(seeds32["k"]!!), atob(prefixKeys["W"]!!), 5, scheduleE)
// Base64URL encode
return btoa(bytes)
.replace("+", "-")
.replace("/", "_")
.replace("=", "")
}
}

View File

@ -0,0 +1,198 @@
package eu.kanade.tachiyomi.extension.all.mangafire
import android.annotation.SuppressLint
import android.app.Application
import android.util.Log
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import eu.kanade.tachiyomi.network.await
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.Buffer
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.time.Duration.Companion.seconds
class WebViewHelper(
private val client: OkHttpClient,
private val headers: Headers,
) {
private val name = "MangaFire"
private val mutex = Mutex()
@SuppressLint("SetJavaScriptEnabled")
suspend fun loadInWebView(
url: String,
requestIntercept: (request: WebResourceRequest) -> RequestIntercept,
onPageFinish: (view: WebView) -> Unit = {},
): String = mutex.withLock {
withContext(Dispatchers.Main.immediate) {
withTimeout(20.seconds) {
suspendCancellableCoroutine { continuation ->
val emptyWebViewResponse = runCatching {
WebResourceResponse("text/html", "utf-8", Buffer().inputStream())
}.getOrElse {
continuation.resumeWithException(it)
return@suspendCancellableCoroutine
}
val context = Injekt.get<Application>()
var webview: WebView? = WebView(context)
fun cleanup() = runBlocking(Dispatchers.Main.immediate) {
webview?.stopLoading()
webview?.destroy()
webview = null
}
webview?.apply {
with(settings) {
javaScriptEnabled = true
domStorageEnabled = true
blockNetworkImage = true
}
webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest,
): WebResourceResponse? {
// allow main page
if (request.url.toString() == url) {
Log.d(name, "allowed: ${request.url}")
runCatching { fetchWebResource(request) }
.onSuccess { return it }
.onFailure {
if (continuation.isActive) {
continuation.resumeWithException(it)
cleanup()
}
}
}
// allow script from their cdn
if (
request.url.host.orEmpty().contains("mfcdn.cc") &&
request.url.pathSegments.lastOrNull().orEmpty().contains("js")
) {
Log.d(name, "allowed: ${request.url}")
runCatching { fetchWebResource(request) }
.onSuccess { return it }
.onFailure {
if (continuation.isActive) {
continuation.resumeWithException(it)
cleanup()
}
}
}
// allow jquery script
if (
request.url.host.orEmpty().contains("cloudflare.com") &&
request.url.encodedPath.orEmpty().contains("jquery")
) {
Log.d(name, "allowed: ${request.url}")
runCatching { fetchWebResource(request) }
.onSuccess { return it }
.onFailure {
if (continuation.isActive) {
continuation.resumeWithException(it)
cleanup()
}
}
}
when (requestIntercept(request)) {
RequestIntercept.Allow -> {
Log.d(name, "allowed: ${request.url}")
runCatching { fetchWebResource(request) }
.onSuccess { return it }
.onFailure {
if (continuation.isActive) {
continuation.resumeWithException(it)
cleanup()
}
}
}
RequestIntercept.Block -> {
Log.d(name, "denied: ${request.url}")
return emptyWebViewResponse
}
RequestIntercept.Capture -> {
Log.d(name, "captured: ${request.url}")
if (continuation.isActive) {
continuation.resume(request.url.toString())
cleanup()
}
return emptyWebViewResponse
}
}
return emptyWebViewResponse
}
override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
onPageFinish(view)
}
}
loadUrl(url)
}
continuation.invokeOnCancellation {
cleanup()
}
}
}
}
}
enum class RequestIntercept {
Allow,
Block,
Capture,
}
private fun fetchWebResource(request: WebResourceRequest): WebResourceResponse = runBlocking(Dispatchers.IO) {
val okhttpRequest = Request.Builder().apply {
url(request.url.toString())
headers(headers)
val skipHeaders = setOf("user-agent", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "x-requested-with")
for ((name, value) in request.requestHeaders) {
if (skipHeaders.contains(name.lowercase())) continue
header(name, value)
}
}.build()
client.newCall(okhttpRequest).await().use { response ->
val mediaType = response.body.contentType()
WebResourceResponse(
mediaType?.let { "${it.type}/${it.subtype}" },
mediaType?.charset()?.name(),
Buffer().readFrom(
response.body.byteStream(),
).inputStream(),
)
}
}
}