Add Cloudflare interceptor for Kwik extractor

This commit is contained in:
Cuong-Tran
2026-05-09 00:00:18 +07:00
parent 8a9e9d65be
commit f152963803
5 changed files with 31 additions and 193 deletions

View File

@@ -14,7 +14,6 @@ import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.util.concurrent.CountDownLatch
@@ -75,7 +74,7 @@ class CloudflareInterceptor(private val client: OkHttpClient) : Interceptor {
useWideViewPort = true
loadWithOverviewMode = false
userAgentString = request.header("User-Agent")
?: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36"
?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
}
webview.addJavascriptInterface(jsinterface, "CloudflareJSI")

View File

@@ -8,4 +8,5 @@ apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(":lib:unpacker"))
implementation(project(":lib:cloudflareinterceptor"))
}

View File

@@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.animeextension.en.animepahe
import android.app.Application
import androidx.preference.PreferenceScreen
import aniyomi.lib.cloudflareinterceptor.CloudflareInterceptor
import eu.kanade.tachiyomi.animeextension.en.animepahe.dto.EpisodeDto
import eu.kanade.tachiyomi.animeextension.en.animepahe.dto.LatestAnimeDto
import eu.kanade.tachiyomi.animeextension.en.animepahe.dto.ResponseDto
@@ -27,7 +27,6 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Element
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.ceil
@@ -44,14 +43,14 @@ class AnimePahe :
.set("User-Agent", UA_DESKTOP)
.set("Referer", "$baseUrl/")
private val interceptor = DdosGuardInterceptor(network.client)
private val ddosGuardInterceptor = DdosGuardInterceptor(network.client)
private val cloudflareInterceptor = CloudflareInterceptor(network.client)
override val client = network.client.newBuilder()
.addInterceptor(interceptor)
.addInterceptor(ddosGuardInterceptor)
.addInterceptor(cloudflareInterceptor)
.build()
private val context: Application by injectLazy()
override val name = "AnimePahe"
override val baseUrl by lazy {
@@ -338,7 +337,7 @@ class AnimePahe :
links.mapNotNull { (_, paheWinLink, quality) ->
if (paheWinLink.isNullOrBlank()) return@mapNotNull null
runCatching {
KwikExtractor(client, headers).getStreamVideo(context, paheWinLink, quality)
KwikExtractor(client, headers).getStreamVideo(paheWinLink, quality)
}.getOrNull()
}
} else {
@@ -493,6 +492,5 @@ class AnimePahe :
}
const val UA_DESKTOP = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
const val UA_MOBILE = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36"
}
}

View File

@@ -1,121 +0,0 @@
package eu.kanade.tachiyomi.animeextension.en.animepahe.extractor
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.webkit.CookieManager
import android.webkit.WebView
import android.webkit.WebViewClient
import eu.kanade.tachiyomi.animeextension.en.animepahe.AnimePahe.Companion.UA_MOBILE
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
data class CloudFlareBypassResult(
val cookies: String,
val userAgent: String,
)
class CloudflareBypass(private val context: Context) {
@SuppressLint("SetJavaScriptEnabled")
fun getCookies(pageUrl: String): CloudFlareBypassResult? {
// Only clear cookies for the target domain instead of hardcoding unrelated domains.
clearCookiesForUrl(pageUrl)
val latch = CountDownLatch(1)
var result: CloudFlareBypassResult? = null
var webView: WebView? = null
val cancelled = AtomicBoolean(false)
// We MUST jump to the Main Thread because WebView is UI-bound
Handler(Looper.getMainLooper()).post {
webView = WebView(context)
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.settings.userAgentString = UA_MOBILE
val defaultUserAgent = webView.settings.userAgentString
?: UA_MOBILE
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, loadedUrl: String) {
pollForClearance(pageUrl, defaultUserAgent, cancelled) { bypassResult ->
result = bypassResult
latch.countDown()
}
}
}
CookieManager.getInstance().setCookie(pageUrl, "")
webView.loadUrl(pageUrl)
}
// Wait here for up to 30 seconds
try {
latch.await(30, TimeUnit.SECONDS)
} finally {
// Signal the polling runnable to stop rescheduling itself.
cancelled.set(true)
Handler(Looper.getMainLooper()).post {
webView?.destroy()
}
}
return result
}
private fun pollForClearance(
url: String,
userAgent: String,
cancelled: AtomicBoolean,
onComplete: (CloudFlareBypassResult) -> Unit,
) {
val handler = Handler(Looper.getMainLooper())
val startTime = System.currentTimeMillis()
val maxDurationMs = 30_000L // Matches the CountDownLatch timeout
val pollIntervalMs = 500L
val runnable = object : Runnable {
override fun run() {
// Stop if getCookies has already returned / timed out.
if (cancelled.get()) return
// Hard upper bound so we never poll indefinitely.
val elapsed = System.currentTimeMillis() - startTime
if (elapsed >= maxDurationMs) return
val cookies = CookieManager.getInstance().getCookie(url)
if (cookies?.contains("cf_clearance=") == true) {
val finalResult = CloudFlareBypassResult(cookies, userAgent)
onComplete(finalResult)
} else {
handler.postDelayed(this, pollIntervalMs)
}
}
}
handler.post(runnable)
}
/**
* Clear cookies only for the host of the given URL, avoiding disruption
* to sessions on unrelated domains.
*/
private fun clearCookiesForUrl(pageUrl: String) {
val domain = Uri.parse(pageUrl).host ?: return
val cookieManager = CookieManager.getInstance()
listOf("https://$domain", "https://www.$domain").forEach { url ->
cookieManager.getCookie(url)?.split(";")?.forEach { cookieStr ->
val cookieName = cookieStr.substringBefore("=").trim()
if (cookieName.isNotEmpty()) {
cookieManager.setCookie(url, "$cookieName=; Max-Age=0; path=/")
cookieManager.setCookie(url, "$cookieName=; Max-Age=0; path=/; domain=.$domain")
}
}
}
cookieManager.flush()
}
}

View File

@@ -27,7 +27,6 @@ SOFTWARE.
package eu.kanade.tachiyomi.animeextension.en.animepahe.extractor
import android.app.Application
import aniyomi.lib.jsunpacker.JsUnpacker
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
@@ -87,8 +86,8 @@ class KwikExtractor(
return unpacked.substringAfter("const source=\\'").substringBefore("\\';")
}
fun getStreamVideo(context: Application, paheUrl: String, quality: String = ""): Video {
val videoUrl = getStreamUrlFromKwik(context, paheUrl)
fun getStreamVideo(paheUrl: String, quality: String = ""): Video {
val videoUrl = getStreamUrlFromKwik(paheUrl)
return Video(
videoUrl,
@@ -98,14 +97,14 @@ class KwikExtractor(
)
}
fun getStreamUrlFromKwik(context: Application, paheUrl: String): String {
fun getStreamUrlFromKwik(paheUrl: String): String {
val kwikUrl = noRedirectClient.newCall(GET("$paheUrl/i", headers)).execute().use { response ->
val location = response.header("location")
?: throw KwikException.ExtractionException("Pahe redirect failed: No location header found.")
"https://" + location.substringAfterLast("https://")
}
var (fContentCookies, fContentString, fContentUrl) = fetchKwikHtml(context, kwikUrl)
val (fContentCookies, fContentString, fContentUrl) = fetchKwikHtml(kwikUrl)
// Extract JS Parameters
val match = kwikParamsRegex.find(fContentString)
@@ -120,87 +119,49 @@ class KwikExtractor(
?: throw KwikException.ExtractionException("Failed to decrypt stream Token.")
// Extraction Loop
var cloudFlareBypassResult: CloudFlareBypassResult? = null
var kwikLocation: String? = null
var code = 419
var tries = 0
val tryLimit = 5
while (code != 302 && tries < tryLimit) {
tries++
// Expecting a Redirect (302) here
while (code != 302 && tries++ < tryLimit) {
val headersBuilder = kwikHeaders.newBuilder()
.set("Referer", fContentUrl)
.set("Cookie", fContentCookies)
cloudFlareBypassResult?.let { headersBuilder.set("User-Agent", it.userAgent) }
noRedirectClient.newCall(
POST(uri, headersBuilder.build(), FormBody.Builder().add("_token", tok).build()),
).execute().use { response ->
code = response.code
kwikLocation = response.header("location")
}
// Cloudflare/Session Timeout Handling
if (code == 403 || code == 419) {
cloudFlareBypassResult = CloudflareBypass(context).getCookies(kwikUrl)
?: throw KwikException.CloudflareBlockedException("Cloudflare bypass failed to return result.")
// Prevent stacking multiple cf_clearance cookies
val cleanedCookies = fContentCookies.split("; ")
.filter { !it.trimStart().startsWith("cf_clearance=") }
.joinToString("; ")
fContentCookies = "$cleanedCookies; ${cloudFlareBypassResult.cookies}"
}
}
return kwikLocation ?: throw KwikException.ExtractionException("Failed to extract stream URI after $tries attempts.")
}
private fun fetchKwikHtml(context: Application, kwikUrl: String): KwikContent {
fun attemptKwikFetch(cfResult: CloudFlareBypassResult?): KwikContent? {
// Use `Headers.Builder()` because we want to use the default User-Agent from the app,
// since that would be the one used when open webview manually
val headers = Headers.Builder()
.set("Origin", "https://kwik.cx")
.set("Referer", "https://kwik.cx/")
.apply {
if (cfResult != null) {
set("Cookie", cfResult.cookies)
set("User-Agent", cfResult.userAgent)
}
}
.build()
private fun fetchKwikHtml(kwikUrl: String): KwikContent {
// Use `Headers.Builder()` because we want to use the default User-Agent from the app,
// since that would be the one used when open webview manually
val headers = Headers.Builder()
.set("Origin", "https://kwik.cx")
.set("Referer", "https://kwik.cx/")
.build()
// Use the base client directly so all interceptors are preserved.
return try {
// try-catch the `Failed to bypass Cloudflare` exception
client.newCall(GET(kwikUrl, headers)).execute().use { resp ->
val html = resp.body.string()
if (html.contains("eval(function(")) {
val respCookies = resp.extractCookies()
val finalCookies =
listOfNotNull(respCookies.ifBlank { null }, cfResult?.cookies?.ifBlank { null }).joinToString("; ")
KwikContent(finalCookies, html, resp.request.url.toString())
} else {
null
}
// Use the base client directly so all interceptors are preserved.
try {
// try-catch the `Failed to bypass Cloudflare` exception
client.newCall(GET(kwikUrl, headers)).execute().use { resp ->
val html = resp.body.string()
if (html.contains("eval(function(")) {
val respCookies = resp.extractCookies()
return KwikContent(respCookies, html, resp.request.url.toString())
}
} catch (_: Exception) {
null
}
} catch (_: Exception) {
throw KwikException.CloudflareBlockedException("Failed to bypass Cloudflare protection.")
}
// 1. Try standard fetch without bypass
attemptKwikFetch(null)?.let { return it }
// 2. Try Cloudflare Bypass (Always fresh)
val cfResult = CloudflareBypass(context).getCookies(kwikUrl)
?: throw KwikException.CloudflareBlockedException("Bypass returned null result.")
attemptKwikFetch(cfResult)?.let { return it }
throw KwikException.CloudflareBlockedException("Cloudflare challenge not solved.")
}