fix(pt/betteranime): Fix http 405 on search + add automatic login (#2839)

This commit is contained in:
Claudemirovsky 2024-01-29 09:51:38 -03:00 committed by GitHub
parent 44a284a85b
commit 388b2213fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 329 additions and 173 deletions

View File

@ -1,7 +1,7 @@
ext {
extName = 'Better Anime'
extClass = '.BetterAnime'
extVersionCode = 8
extVersionCode = 9
}
apply from: "$rootDir/common.gradle"

View File

@ -55,12 +55,10 @@ object BAFilters {
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
if (filters.isEmpty()) return FilterSearchParams()
val genres = listOf(" ") + filters.parseCheckbox<GenresFilter>(BAFiltersData.GENRES)
return FilterSearchParams(
filters.asQueryPart<LanguageFilter>(),
filters.asQueryPart<YearFilter>(),
genres,
filters.parseCheckbox<GenresFilter>(BAFiltersData.GENRES),
)
}
@ -73,7 +71,7 @@ object BAFilters {
Pair("Dublado", "dublado"),
)
val YEARS = arrayOf(EVERY) + (2023 downTo 1976).map {
val YEARS = arrayOf(EVERY) + (2024 downTo 1976).map {
Pair(it.toString(), it.toString())
}.toTypedArray()

View File

@ -1,12 +1,10 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animeextension.pt.betteranime.dto.LivewireResponseDto
import eu.kanade.tachiyomi.animeextension.pt.betteranime.dto.ComponentsDto
import eu.kanade.tachiyomi.animeextension.pt.betteranime.dto.PayloadData
import eu.kanade.tachiyomi.animeextension.pt.betteranime.dto.PayloadItem
import eu.kanade.tachiyomi.animeextension.pt.betteranime.extractors.BetterAnimeExtractor
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
@ -19,10 +17,14 @@ import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.encodeToString
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.Interceptor
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@ -33,7 +35,6 @@ import org.jsoup.nodes.Element
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.IOException
class BetterAnime : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
@ -46,12 +47,12 @@ class BetterAnime : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val supportsLatest = true
override val client = network.client.newBuilder()
.addInterceptor(::loginInterceptor)
.addInterceptor(LoginInterceptor(network.client, baseUrl, headers))
.build()
private val json: Json by injectLazy()
private val preferences: SharedPreferences by lazy {
private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
@ -64,58 +65,34 @@ class BetterAnime : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
// so we use the latest added anime page instead.
override fun popularAnimeParse(response: Response) = latestUpdatesParse(response)
override fun popularAnimeRequest(page: Int): Request =
GET("$baseUrl/ultimosAdicionados?page=$page")
override fun popularAnimeRequest(page: Int) = GET("$baseUrl/ultimosAdicionados?page=$page", headers)
override fun popularAnimeSelector() = TODO()
override fun popularAnimeFromElement(element: Element) = TODO()
override fun popularAnimeNextPageSelector() = TODO()
// ============================== Episodes ==============================
override fun episodeListSelector(): String = "ul#episodesList > li.list-group-item-action > a"
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/ultimosLancamentos?page=$page", headers)
override fun episodeListParse(response: Response) =
super.episodeListParse(response).reversed()
override fun latestUpdatesSelector() = "div.list-animes article"
override fun episodeFromElement(element: Element) = SEpisode.create().apply {
val episodeName = element.text()
setUrlWithoutDomain(element.attr("href"))
name = episodeName
episode_number = episodeName.substringAfterLast(" ").toFloatOrNull() ?: 0F
override fun latestUpdatesFromElement(element: Element) = SAnime.create().apply {
val img = element.selectFirst("img")!!
val url = element.selectFirst("a")?.attr("href")!!
setUrlWithoutDomain(url)
title = element.selectFirst("h3")?.text()!!
thumbnail_url = "https:" + img.attr("src")
}
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val html = response.body.string()
val extractor = BetterAnimeExtractor(client, baseUrl, json)
return extractor.videoListFromHtml(html)
}
override fun videoListSelector() = throw UnsupportedOperationException()
override fun videoFromElement(element: Element) = throw UnsupportedOperationException()
override fun videoUrlParse(document: Document) = throw UnsupportedOperationException()
override fun latestUpdatesNextPageSelector() = "ul.pagination li.page-item:contains():not(.disabled)"
// =============================== Search ===============================
override fun searchAnimeFromElement(element: Element) = latestUpdatesFromElement(element)
override fun searchAnimeSelector() = latestUpdatesSelector()
override fun searchAnimeNextPageSelector() = latestUpdatesNextPageSelector()
override fun searchAnimeParse(response: Response): AnimesPage {
val body = response.body.string()
val data = json.decodeFromString<LivewireResponseDto>(body)
val html = data.effects.html?.unescape().orEmpty()
val document = Jsoup.parse(html)
val animes = document.select(searchAnimeSelector()).map { element ->
searchAnimeFromElement(element)
}
val hasNext = document.selectFirst(searchAnimeNextPageSelector()) != null
return AnimesPage(animes, hasNext)
}
override fun getFilterList() = BAFilters.FILTER_LIST
override suspend fun getSearchAnime(page: Int, query: String, filters: AnimeFilterList): AnimesPage {
return if (query.startsWith(PREFIX_SEARCH_PATH)) {
val path = query.removePrefix(PREFIX_SEARCH_PATH)
client.newCall(GET("$baseUrl/$path"))
client.newCall(GET("$baseUrl/$path", headers))
.awaitSuccess()
.use(::searchAnimeByPathParse)
} else {
@ -129,10 +106,12 @@ class BetterAnime : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val searchParams = buildList {
add(PayloadItem(PayloadData(method = "search"), "callMethod"))
val calls = buildJsonArray {
val payloadSerializer = PayloadData.serializer()
add(json.encodeToJsonElement(payloadSerializer, PayloadData(method = "search")))
add(
PayloadItem(
json.encodeToJsonElement(
payloadSerializer,
PayloadData(
method = "gotoPage",
params = listOf(
@ -140,27 +119,75 @@ class BetterAnime : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
JsonPrimitive("page"),
),
),
"callMethod",
),
)
val params = BAFilters.getSearchParameters(filters)
val data = buildList {
if (params.genres.size > 1) {
add(PayloadData(name = "byGenres", value = params.genres))
}
listOf(
params.year to "byYear",
params.language to "byLanguage",
query to "searchTerm",
).forEach { it.first.toPayloadData(it.second)?.let(::add) }
}
addAll(data.map { PayloadItem(it, "syncInput") })
}
return wireRequest("anime-search", searchParams)
val params = BAFilters.getSearchParameters(filters)
val updates = buildJsonObject {
if (params.genres.isNotEmpty()) {
putJsonArray("byGenres") {
params.genres.forEach { add(JsonPrimitive(it)) }
}
}
listOf(
params.year to "byYear",
params.language to "byLanguage",
query to "searchTerm",
).forEach { if (it.first.isNotEmpty()) put(it.second, it.first) }
}
if (wireToken.isBlank()) {
updateSnapshot(GET("$baseUrl/pesquisa", headers))
}
val data = buildJsonObject {
put("_token", wireToken)
putJsonArray("components") {
add(
buildJsonObject {
put("calls", calls)
put("snapshot", snapshot)
put("updates", updates)
},
)
}
}
val reqBody = json.encodeToString(JsonObject.serializer(), data).toRequestBody("application/json".toMediaType())
val headers = headersBuilder()
.add("x-livewire", "true")
.add("x-csrf-token", wireToken)
.build()
return POST("$baseUrl/livewire/update", headers, reqBody)
}
private var snapshot = ""
private var wireToken = ""
private fun updateSnapshot(request: Request) {
val document = client.newCall(request).execute().asJsoup()
val wireElement = document.selectFirst("[wire:snapshot]")!!
snapshot = wireElement.attr("wire:snapshot")
wireToken = document.selectFirst("script[data-csrf]")!!.attr("data-csrf")
}
override fun searchAnimeParse(response: Response): AnimesPage {
val body = response.body.string()
val data = json.decodeFromString<ComponentsDto>(body)
val html = data.components.firstOrNull()?.effects?.html?.unescape().orEmpty()
val document = Jsoup.parse(html)
val animes = document.select(searchAnimeSelector()).map(::searchAnimeFromElement)
val hasNext = document.selectFirst(searchAnimeNextPageSelector()) != null
return AnimesPage(animes, hasNext)
}
override fun searchAnimeSelector() = latestUpdatesSelector()
override fun searchAnimeFromElement(element: Element) = latestUpdatesFromElement(element)
override fun searchAnimeNextPageSelector() = latestUpdatesNextPageSelector()
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document) = SAnime.create().apply {
val doc = getRealDoc(document)
@ -182,24 +209,34 @@ class BetterAnime : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
}
}
// =============================== Latest ===============================
override fun latestUpdatesNextPageSelector() = "ul.pagination li.page-item:contains():not(.disabled)"
override fun latestUpdatesSelector() = "div.list-animes article"
// ============================== Episodes ==============================
override fun episodeListSelector() = "ul#episodesList > li.list-group-item-action > a"
override fun latestUpdatesRequest(page: Int): Request =
GET("$baseUrl/ultimosLancamentos?page=$page")
override fun episodeListParse(response: Response) =
super.episodeListParse(response).reversed()
override fun latestUpdatesFromElement(element: Element) = SAnime.create().apply {
val img = element.selectFirst("img")!!
val url = element.selectFirst("a")?.attr("href")!!
setUrlWithoutDomain(url)
title = element.selectFirst("h3")?.text()!!
thumbnail_url = "https:" + img.attr("src")
override fun episodeFromElement(element: Element) = SEpisode.create().apply {
val episodeName = element.text()
setUrlWithoutDomain(element.attr("href"))
name = episodeName
episode_number = episodeName.substringAfterLast(" ").toFloatOrNull() ?: 0F
}
// ============================ Video Links =============================
private val extractor by lazy { BetterAnimeExtractor(client, baseUrl, json) }
override fun videoListParse(response: Response): List<Video> {
val html = response.body.string()
return extractor.videoListFromHtml(html)
}
override fun videoListSelector() = throw UnsupportedOperationException()
override fun videoFromElement(element: Element) = throw UnsupportedOperationException()
override fun videoUrlParse(document: Document) = throw UnsupportedOperationException()
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val videoQualityPref = ListPreference(screen.context).apply {
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = PREF_QUALITY_TITLE
entries = PREF_QUALITY_ENTRIES
@ -212,26 +249,13 @@ class BetterAnime : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
screen.addPreference(videoQualityPref)
}.also(screen::addPreference)
}
override fun getFilterList(): AnimeFilterList = BAFilters.FILTER_LIST
// ============================= Utilities ==============================
private fun loginInterceptor(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if ("/dmca" in response.request.url.toString()) {
response.close()
throw IOException(ERROR_LOGIN_MISSING)
}
return response
}
private fun getRealDoc(document: Document): Document {
return document.selectFirst("div.anime-title a")?.let { link ->
client.newCall(GET(link.attr("href")))
client.newCall(GET(link.attr("href"), headers))
.execute()
.asJsoup()
} ?: document
@ -244,49 +268,12 @@ class BetterAnime : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
}
}
private var initialData: String = ""
private var wireToken: String = ""
private fun updateInitialData(request: Request) {
val document = client.newCall(request).execute().asJsoup()
val wireElement = document.selectFirst("[wire:id]")
wireToken = document.html()
.substringAfter("livewire_token")
.substringAfter("'")
.substringBefore("'")
initialData = wireElement!!.attr("wire:initial-data").dropLast(1)
}
private fun wireRequest(path: String, updates: List<PayloadItem>): Request {
if (wireToken.isBlank()) {
updateInitialData(GET("$baseUrl/pesquisa"))
}
val url = "$baseUrl/livewire/message/$path"
val items = updates.joinToString(",") { json.encodeToString(it) }
val data = "$initialData, \"updates\": [$items]}"
val reqBody = data.toRequestBody("application/json".toMediaType())
val headers = headersBuilder()
.add("x-livewire", "true")
.add("x-csrf-token", wireToken)
.build()
return POST(url, headers, reqBody)
}
private fun Element.getInfo(key: String): String? {
return selectFirst("p:containsOwn($key) > span")
?.text()
?.trim()
}
private fun String.toPayloadData(name: String): PayloadData? {
return when {
isNotBlank() -> PayloadData(name = name, value = listOf(this))
else -> null
}
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
return sortedWith(
@ -298,9 +285,6 @@ class BetterAnime : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
private const val ACCEPT_LANGUAGE = "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7"
const val PREFIX_SEARCH_PATH = "path:"
private const val ERROR_LOGIN_MISSING = "Login necessário. " +
"Abra a WebView, insira os dados de sua conta e realize o login."
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_TITLE = "Qualidade preferida"
private const val PREF_QUALITY_DEFAULT = "720p"

View File

@ -0,0 +1,50 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime
import android.util.Base64
import eu.kanade.tachiyomi.network.POST
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import java.io.IOException
internal class LoginInterceptor(
private val client: OkHttpClient,
private val baseUrl: String,
private val headers: Headers,
) : Interceptor {
private val recapBypasser by lazy { RecaptchaV3Bypasser(client, headers) }
@Synchronized
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val originalResponse = chain.proceed(originalRequest)
if (!originalResponse.request.url.encodedPath.contains("/dmca")) {
return originalResponse
}
originalResponse.close()
val (token, recaptchaToken) = recapBypasser.getRecaptchaToken("$baseUrl/login")
if (recaptchaToken.isBlank()) throw IOException(FAILED_AUTOLOGIN_MESSAGE)
val formBody = FormBody.Builder()
.add("_token", token)
.add("g-recaptcha-response", recaptchaToken)
.add("email", String(Base64.decode("aGVmaWczNTY0NUBuYW1ld29rLmNvbQ==", Base64.DEFAULT)))
.add("password", String(Base64.decode("SE1HNFdoVEI0QnRJWTlIdg==", Base64.DEFAULT)))
.build()
val loginRes = chain.proceed(POST("$baseUrl/login", headers, formBody))
loginRes.close()
if (!loginRes.isSuccessful) throw IOException(FAILED_AUTOLOGIN_MESSAGE)
return chain.proceed(originalRequest)
}
companion object {
private const val FAILED_AUTOLOGIN_MESSAGE = "Falha na tentativa de logar automaticamente! " +
"Tente manualmente na WebView."
}
}

View File

@ -0,0 +1,155 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime
import android.annotation.SuppressLint
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import eu.kanade.tachiyomi.network.GET
import okhttp3.Headers
import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayInputStream
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
internal class RecaptchaV3Bypasser(private val client: OkHttpClient, private val headers: Headers) {
private val context: Application by injectLazy()
private val handler by lazy { Handler(Looper.getMainLooper()) }
class AndroidJSI(private val latch: CountDownLatch) {
var token = ""
@JavascriptInterface
fun sendResponse(response: String) {
token = response.substringAfter("uvresp\",\"").substringBefore('"')
latch.countDown()
}
}
@SuppressLint("SetJavaScriptEnabled")
fun getRecaptchaToken(targetUrl: String): Pair<String, String> {
val latch = CountDownLatch(1)
var webView: WebView? = null
var token = ""
val androidjsi = AndroidJSI(latch)
handler.post {
val webview = WebView(context)
webView = webview
with(webview.settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
useWideViewPort = false
loadWithOverviewMode = false
}
webview.addJavascriptInterface(androidjsi, "androidjsi")
webview.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
view?.evaluateJavascript("document.querySelector('input[name=_token]').value") {
token = it.trim('"')
}
}
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
val url = request?.url.toString()
val reqHeaders = request?.requestHeaders.orEmpty()
// Our beloved token
if (url.contains("/recaptcha/api2/anchor")) {
// Injects the script to click on the captcha box
return injectScripts(url, reqHeaders, CLICK_BOX_SCRIPT, INTERCEPTOR_SCRIPT)
} else if (reqHeaders.get("Accept").orEmpty().contains("text/html")) {
// Injects the XMLHttpRequest hack
return injectScripts(url, reqHeaders, INTERCEPTOR_SCRIPT)
}
return super.shouldInterceptRequest(view, request)
}
}
webview.loadUrl(targetUrl)
}
latch.await(20, TimeUnit.SECONDS)
handler.post {
webView?.stopLoading()
webView?.destroy()
webView = null
}
return Pair(token, androidjsi.token)
}
private fun Headers.toWebViewHeaders() = toMultimap()
.mapValues { it.value.getOrNull(0) ?: "" }
.toMutableMap()
.apply {
remove("cross-origin-embedder-policy")
remove("content-security-policy")
remove("report-to")
}
private fun injectScripts(
url: String,
reqHeaders: Map<String, String>,
vararg scripts: String,
): WebResourceResponse {
val headers = Headers.Builder().apply {
reqHeaders.entries.forEach { (key, value) -> add(key, value) }
}.build()
val res = client.newCall(GET(url, headers)).execute()
val newHeaders = res.headers.toWebViewHeaders()
val body = res.body.string()
val newBody = if (res.isSuccessful) {
body.substringBeforeLast("</body>") + scripts.joinToString("\n") + "</body></html>"
} else {
body
}
return WebResourceResponse(
"text/html", // mimeType
"utf-8", // encoding
res.code, // status code
res.message.ifEmpty { "ok" }, // reason phrase
newHeaders, // response headers
ByteArrayInputStream(newBody.toByteArray()), // data
)
}
}
private const val INTERCEPTOR_SCRIPT = """
<script type="text/javascript">
const originalOpen = window.XMLHttpRequest.prototype.open
window.XMLHttpRequest.prototype.open = function(_unused_method, url, _unused_arg) {
if (url.includes('/api2/userverify')) {
originalOpen.apply(this, arguments) // call the original open method
this.onreadystatechange = function() {
if (this.readyState === 4 && this.status === 200) {
const responseBody = this.responseText
window.androidjsi.sendResponse(responseBody)
}
}
} else {
originalOpen.apply(this, arguments)
}
}
</script>
"""
private const val CLICK_BOX_SCRIPT = """
<script type="text/javascript">
setInterval(async () => {
const items = document.querySelectorAll("#recaptcha-anchor, .recaptcha-checkbox, #rc-anchor-container span[role=checkbox]")
items.forEach(x => {try { x.click() } catch (e) {} })
}, 500)
</script>"""

View File

@ -1,55 +1,24 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime.dto
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonTransformingSerializer
@Serializable
data class ChangePlayerDto(
val frameLink: String? = null,
)
data class ChangePlayerDto(val frameLink: String? = null)
@Serializable
data class LivewireResponseDto(
val effects: LivewireEffects,
)
data class ComponentsDto(val components: List<LivewireResponseDto>)
@Serializable
data class LivewireEffects(
val html: String? = null,
)
data class LivewireResponseDto(val effects: LivewireEffects)
@ExperimentalSerializationApi
@Serializable
data class PayloadItem(
val payload: PayloadData,
val type: String,
)
data class LivewireEffects(val html: String? = null)
@ExperimentalSerializationApi
@Serializable
data class PayloadData(
val name: String = "",
val method: String = "",
@Serializable(with = ValueSerializer::class)
val value: List<String> = emptyList<String>(),
@EncodeDefault val params: List<JsonElement> = emptyList<JsonElement>(),
@EncodeDefault val id: String = "",
@EncodeDefault val params: List<JsonElement> = emptyList(),
@EncodeDefault val path: String = "",
)
object ValueSerializer : JsonTransformingSerializer<List<String>>(
ListSerializer(String.serializer()),
) {
override fun transformSerialize(element: JsonElement): JsonElement {
require(element is JsonArray)
if (element.size > 1) {
return JsonArray(element.drop(1))
}
return element.first()
}
}