mirror of
https://github.com/yuzono/anime-extensions.git
synced 2026-06-13 13:39:44 +00:00
Add Cloudflare interceptor for Kwik extractor
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -8,4 +8,5 @@ apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:unpacker"))
|
||||
implementation(project(":lib:cloudflareinterceptor"))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user