Initial commit

This commit is contained in:
almightyhak 2024-06-20 11:54:12 +07:00
commit 98ed7e8839
2263 changed files with 108711 additions and 0 deletions

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".pt.betteranime.BAUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="betteranime.net"
android:pathPattern="/..*/..*/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,128 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
object BAFilters {
open class QueryPartFilter(
displayName: String,
val vals: Array<Pair<String, String>>,
) : AnimeFilter.Select<String>(
displayName,
vals.map { it.first }.toTypedArray(),
) {
fun toQueryPart() = vals[state].second
}
open class CheckBoxFilterList(name: String, values: List<CheckBox>) : AnimeFilter.Group<AnimeFilter.CheckBox>(name, values)
private class CheckBoxVal(name: String, state: Boolean = false) : AnimeFilter.CheckBox(name, state)
private inline fun <reified R> AnimeFilterList.parseCheckbox(
options: Array<Pair<String, String>>,
): List<String> {
return (first { it is R } as CheckBoxFilterList).state
.asSequence()
.filter { it.state }
.map { checkbox -> options.find { it.first == checkbox.name }!!.second }
.toList()
}
private inline fun <reified R> AnimeFilterList.asQueryPart(): String {
return (first { it is R } as QueryPartFilter).toQueryPart()
}
class LanguageFilter : QueryPartFilter("Idioma", BAFiltersData.LANGUAGES)
class YearFilter : QueryPartFilter("Ano", BAFiltersData.YEARS)
class GenresFilter : CheckBoxFilterList(
"Gêneros",
BAFiltersData.GENRES.map { CheckBoxVal(it.first, false) },
)
val FILTER_LIST get() = AnimeFilterList(
LanguageFilter(),
YearFilter(),
GenresFilter(),
)
data class FilterSearchParams(
val language: String = "",
val year: String = "",
val genres: List<String> = emptyList<String>(),
)
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
if (filters.isEmpty()) return FilterSearchParams()
return FilterSearchParams(
filters.asQueryPart<LanguageFilter>(),
filters.asQueryPart<YearFilter>(),
filters.parseCheckbox<GenresFilter>(BAFiltersData.GENRES),
)
}
private object BAFiltersData {
val EVERY = Pair("Qualquer um", "")
val LANGUAGES = arrayOf(
EVERY,
Pair("Legendado", "legendado"),
Pair("Dublado", "dublado"),
)
val YEARS = arrayOf(EVERY) + (2024 downTo 1976).map {
Pair(it.toString(), it.toString())
}.toTypedArray()
val GENRES = arrayOf(
Pair("Ação", "acao"),
Pair("Artes Marciais", "artes-marciais"),
Pair("Aventura", "aventura"),
Pair("Comédia", "comedia"),
Pair("Cotidiano", "cotidiano"),
Pair("Demência", "demencia"),
Pair("Demônios", "demonios"),
Pair("Drama", "drama"),
Pair("Ecchi", "ecchi"),
Pair("Escolar", "escolar"),
Pair("Espacial", "espacial"),
Pair("Esportes", "esportes"),
Pair("Fantasia", "fantasia"),
Pair("Ficção Científica", "ficcao-cientifica"),
Pair("Game", "game"),
Pair("Harém", "harem"),
Pair("Histórico", "historico"),
Pair("Horror", "horror"),
Pair("Infantil", "infantil"),
Pair("Josei", "josei"),
Pair("Magia", "magia"),
Pair("Mecha", "mecha"),
Pair("Militar", "militar"),
Pair("Mistério", "misterio"),
Pair("Musical", "musical"),
Pair("Paródia", "parodia"),
Pair("Policial", "policial"),
Pair("Psicológico", "psicologico"),
Pair("Romance", "romance"),
Pair("Samurai", "samurai"),
Pair("Sci-Fi", "sci-fi"),
Pair("Seinen", "seinen"),
Pair("Shoujo-Ai", "shoujo-ai"),
Pair("Shoujo", "shoujo"),
Pair("Shounen-Ai", "shounen-ai"),
Pair("Shounen", "shounen"),
Pair("Slice of Life", "slice-of-life"),
Pair("Sobrenatural", "sobrenatural"),
Pair("Super Poderes", "super-poderes"),
Pair("Suspense", "suspense"),
Pair("Terror", "terror"),
Pair("Thriller", "thriller"),
Pair("Tragédia", "tragedia"),
Pair("Vampiros", "vampiros"),
Pair("Vida Escolar", "vida-escolar"),
Pair("Yaoi", "yaoi"),
Pair("Yuri", "yuri"),
)
}
}

View file

@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://betteranime.net/<type>/<lang>/<item> intents
* and redirects them to the main Aniyomi process.
*/
class BAUrlActivity : Activity() {
private val tag = "BAUrlActivity"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 2) {
val type = pathSegments[0]
val lang = pathSegments[1]
val item = pathSegments[2]
val searchQuery = "$type/$lang/$item"
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.ANIMESEARCH"
putExtra("query", "${BetterAnime.PREFIX_SEARCH_PATH}$searchQuery")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e(tag, e.toString())
}
} else {
Log.e(tag, "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}

View file

@ -0,0 +1,12 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime
// Terrible way to reinvent the wheel, i just didnt wanted to use apache commons.
fun String.unescape(): String {
return UNICODE_REGEX.replace(this) {
it.groupValues[1]
.toInt(16)
.toChar()
.toString()
}.replace("\\", "")
}
private val UNICODE_REGEX = "\\\\u(\\d+)".toRegex()

View file

@ -0,0 +1,293 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime
import android.app.Application
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
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.extractors.BetterAnimeExtractor
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.ParsedAnimeHttpSource
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.builtins.serializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
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
import okhttp3.Response
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 uy.kohesive.injekt.injectLazy
class BetterAnime : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "Better Anime"
override val baseUrl = "https://betteranime.net"
override val lang = "pt-BR"
override val supportsLatest = true
override val client = network.client.newBuilder()
.addInterceptor(LoginInterceptor(network.client, baseUrl, headers))
.build()
private val json: Json by injectLazy()
private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun headersBuilder() = super.headersBuilder()
.add("Referer", baseUrl)
.add("Accept-Language", ACCEPT_LANGUAGE)
// ============================== Popular ===============================
// The site doesn't have a true popular anime tab,
// so we use the latest added anime page instead.
override fun popularAnimeParse(response: Response) = latestUpdatesParse(response)
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()
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/ultimosLancamentos?page=$page", headers)
override fun latestUpdatesSelector() = "div.list-animes article"
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 latestUpdatesNextPageSelector() = "ul.pagination li.page-item:contains():not(.disabled)"
// =============================== Search ===============================
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", headers))
.awaitSuccess()
.use(::searchAnimeByPathParse)
} else {
super.getSearchAnime(page, query, filters)
}
}
private fun searchAnimeByPathParse(response: Response): AnimesPage {
val details = animeDetailsParse(response)
return AnimesPage(listOf(details), false)
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val calls = buildJsonArray {
val payloadSerializer = PayloadData.serializer()
add(json.encodeToJsonElement(payloadSerializer, PayloadData(method = "search")))
add(
json.encodeToJsonElement(
payloadSerializer,
PayloadData(
method = "gotoPage",
params = listOf(
JsonPrimitive(page),
JsonPrimitive("page"),
),
),
),
)
}
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)
setUrlWithoutDomain(doc.location())
val infos = doc.selectFirst("div.infos_left > div.anime-info")!!
val img = doc.selectFirst("div.infos-img > img")!!
thumbnail_url = "https:" + img.attr("src")
title = img.attr("alt")
genre = infos.select("div.anime-genres > a")
.eachText()
.joinToString()
author = infos.getInfo("Produtor")
artist = infos.getInfo("Estúdio")
status = parseStatus(infos.getInfo("Estado"))
description = buildString {
append(infos.selectFirst("div.anime-description")!!.text() + "\n\n")
infos.select(">p").eachText().forEach { append("$it\n") }
}
}
// ============================== Episodes ==============================
override fun episodeListSelector() = "ul#episodesList > li.list-group-item-action > a"
override fun episodeListParse(response: Response) =
super.episodeListParse(response).reversed()
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) {
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = PREF_QUALITY_TITLE
entries = PREF_QUALITY_ENTRIES
entryValues = PREF_QUALITY_ENTRIES
setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
}
// ============================= Utilities ==============================
private fun getRealDoc(document: Document): Document {
return document.selectFirst("div.anime-title a")?.let { link ->
client.newCall(GET(link.attr("href"), headers))
.execute()
.asJsoup()
} ?: document
}
private fun parseStatus(statusString: String?): Int {
return when (statusString?.trim()) {
"Completo" -> SAnime.COMPLETED
else -> SAnime.ONGOING
}
}
private fun Element.getInfo(key: String): String? {
return selectFirst("p:containsOwn($key) > span")
?.text()
?.trim()
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
return sortedWith(
compareBy { it.quality.contains(quality) },
).reversed()
}
companion object {
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 PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_TITLE = "Qualidade preferida"
private const val PREF_QUALITY_DEFAULT = "720p"
private val PREF_QUALITY_ENTRIES = arrayOf("480p", "720p", "1080p")
}
}

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,178 @@
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.HttpUrl.Companion.toHttpUrl
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 shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
if (url != null) {
if (url.startsWith("data:")) return false
if (url.startsWith("intent:")) return true
val domain = url.toHttpUrl().host
return !ALLOWED_HOSTS.contains(domain)
}
return true
}
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") || url.contains("/recaptcha/api2/bframe")) {
// 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") && !url.startsWith("intent")) {
// 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("content-security-policy")
remove("cross-origin-embedder-policy")
remove("cross-origin-resource-policy")
remove("report-to")
remove("x-xss-protection")
}
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-checkbox-checkmark, #recaptcha-anchor, .recaptcha-checkbox, #rc-anchor-container span[role=checkbox]")
items.forEach(x => {try { x.click() } catch (e) {} })
}, 500)
</script>"""
private val ALLOWED_HOSTS = listOf(
"www.google.com",
"betteranime.net",
"fonts.googleapis.com",
"cdnjs.cloudflare.com",
"cdn.jsdelivr.net",
"www.gstatic.com",
)

View file

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime.dto
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class ChangePlayerDto(val frameLink: String? = null)
@Serializable
data class ComponentsDto(val components: List<LivewireResponseDto>)
@Serializable
data class LivewireResponseDto(val effects: LivewireEffects)
@Serializable
data class LivewireEffects(val html: String? = null)
@Serializable
data class PayloadData(
val method: String = "",
@EncodeDefault val params: List<JsonElement> = emptyList(),
@EncodeDefault val path: String = "",
)

View file

@ -0,0 +1,66 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime.extractors
import eu.kanade.tachiyomi.animeextension.pt.betteranime.dto.ChangePlayerDto
import eu.kanade.tachiyomi.animeextension.pt.betteranime.unescape
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.util.parallelMapNotNullBlocking
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
class BetterAnimeExtractor(
private val client: OkHttpClient,
private val baseUrl: String,
private val json: Json,
) {
private val headers = Headers.headersOf("Referer", baseUrl)
fun videoListFromHtml(html: String): List<Video> {
val qualities = REGEX_QUALITIES.findAll(html).map {
Pair(it.groupValues[1], it.groupValues[2])
}.toList()
val token = html.substringAfter("_token:\"").substringBefore("\"")
return qualities.parallelMapNotNullBlocking { (quality, qtoken) ->
videoUrlFromToken(qtoken, token)?.let { videoUrl ->
Video(videoUrl, quality, videoUrl)
}
}
}
private suspend fun videoUrlFromToken(qtoken: String, token: String): String? {
val body = """
{
"_token": "$token",
"info": "$qtoken"
}
""".trimIndent()
val reqBody = body.toRequestBody("application/json".toMediaType())
val request = POST("$baseUrl/changePlayer", headers, reqBody)
return runCatching {
val response = client.newCall(request).await().body.string()
val resJson = json.decodeFromString<ChangePlayerDto>(response)
videoUrlFromPlayer(resJson.frameLink!!)
}.getOrNull()
}
private suspend fun videoUrlFromPlayer(url: String): String {
val html = client.newCall(GET(url, headers)).await().body.string()
val videoUrl = html.substringAfter("file\":")
.substringAfter("\"")
.substringBefore("\"")
.unescape()
return videoUrl
}
companion object {
private val REGEX_QUALITIES = """qualityString\["(\w+)"\] = "(\S+)"""".toRegex()
}
}