New source: BetterAnime (#463)

* New source: BetterAnime

* BetterAnime: update serialization version
This commit is contained in:
Claudemirovsky
2022-04-05 20:25:11 -03:00
committed by GitHub
parent f9267d4237
commit 3a145058eb
12 changed files with 600 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.animeextension"/>

View File

@ -0,0 +1,17 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'Better Anime'
pkgNameSuffix = 'pt.betteranime'
extClass = '.BetterAnime'
extVersionCode = 1
libVersion = '12'
}
dependencies {
compileOnly 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
}
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,130 @@
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.getFirst(): R {
return this.filterIsInstance<R>().first()
}
private inline fun <reified R> AnimeFilterList.asQueryPart(): String {
return this.filterIsInstance<R>().joinToString("") {
(it 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 filterList = 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 {
val genres = listOf("") + filters.getFirst<GenresFilter>().state
.mapNotNull { genre ->
if (genre.state) {
BAFiltersData.genres.find { it.first == genre.name }!!.second
} else { null }
}.toList()
return FilterSearchParams(
filters.asQueryPart<LanguageFilter>(),
filters.asQueryPart<YearFilter>(),
genres
)
}
private object BAFiltersData {
val every = Pair("Qualquer um", "")
val languages = arrayOf(
every,
Pair("Legendado", "legendado"),
Pair("Dublado", "dublado")
)
val years = arrayOf(every) + (2022 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,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,319 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime
import android.app.Application
import android.content.SharedPreferences
import android.util.Log
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.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
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.asObservableSuccess
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
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 rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.lang.Exception
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: OkHttpClient = network.cloudflareClient
private val json = Json {
ignoreUnknownKeys = true
}
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun headersBuilder(): Headers.Builder = Headers.Builder()
.add("Referer", baseUrl)
.add("Accept-Language", ACCEPT_LANGUAGE)
// ============================== Popular ===============================
private fun nextPageSelector(): String = "ul.pagination li.page-item:contains()"
override fun popularAnimeNextPageSelector() = throw Exception("not used")
override fun popularAnimeSelector(): String = "div.list-animes article"
override fun popularAnimeFromElement(element: Element): SAnime {
val anime = SAnime.create()
val img = element.selectFirst("img")!!
val url = element.selectFirst("a")?.attr("href")!!
anime.setUrlWithoutDomain(url)
anime.title = element.selectFirst("h3")?.text()!!
anime.thumbnail_url = "https:" + img.attr("src")
return anime
}
// The site doesn't have a popular anime tab, so we use the latest anime page instead.
override fun popularAnimeRequest(page: Int): Request =
GET("$baseUrl/ultimosAdicionados?page=$page")
override fun popularAnimeParse(response: Response): AnimesPage {
val document = response.asJsoup()
val animes = document.select(popularAnimeSelector()).map { element ->
popularAnimeFromElement(element)
}
val hasNextPage = hasNextPage(document)
return AnimesPage(animes, hasNextPage)
}
// ============================== Episodes ==============================
override fun episodeListSelector(): String = "ul#episodesList > li.list-group-item-action > a"
override fun episodeListParse(response: Response): List<SEpisode> {
val document = response.asJsoup()
val episodes = document.select(episodeListSelector()).map { element ->
episodeFromElement(element)
}
return episodes.reversed()
}
override fun episodeFromElement(element: Element): SEpisode {
val episode = SEpisode.create()
val episodeName = element.text()
episode.setUrlWithoutDomain(element.attr("href"))
episode.name = episodeName
episode.episode_number = try {
episodeName.substringAfterLast(" ").toFloat()
} catch (e: NumberFormatException) { 0F }
return episode
}
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val html = response.body?.string().orEmpty()
val extractor = BetterAnimeExtractor(client, baseUrl, json)
return extractor.videoListFromHtml(html)
}
override fun videoListSelector() = throw Exception("not used")
override fun videoFromElement(element: Element) = throw Exception("not used")
override fun videoUrlParse(document: Document) = throw Exception("not used")
// =============================== Search ===============================
override fun searchAnimeFromElement(element: Element) = popularAnimeFromElement(element)
override fun searchAnimeSelector() = popularAnimeSelector()
override fun searchAnimeNextPageSelector() = throw Exception("not used")
override fun searchAnimeParse(response: Response): AnimesPage {
val body = response.body?.string().orEmpty()
val data = json.decodeFromString<LivewireResponseDto>(body)
val html = data.effects.html?.unescape() ?: ""
val document = Jsoup.parse(html)
val animes = document.select(searchAnimeSelector()).map { element ->
searchAnimeFromElement(element)
}
return AnimesPage(animes, hasNextPage(document))
}
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
val params = BAFilters.getSearchParameters(filters)
return client.newCall(searchAnimeRequest(page, query, params))
.asObservableSuccess()
.map { response ->
searchAnimeParse(response)
}
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request = throw Exception("not used")
private fun searchAnimeRequest(page: Int, query: String, filters: BAFilters.FilterSearchParams): Request {
if (page == 1)
updateInitialData(GET("$baseUrl/pesquisa"))
val searchParams = mutableListOf<PayloadItem>()
searchParams.add(PayloadItem(PayloadData(method = "search"), "callMethod"))
searchParams.add(
PayloadItem(
PayloadData(
method = "gotoPage",
params = listOf(JsonPrimitive(page), JsonPrimitive("page"))
),
"callMethod"
)
)
val data = mutableListOf<PayloadData>()
if (filters.genres.size > 1)
data.add(PayloadData(name = "byGenres", value = filters.genres))
if (!filters.year.isBlank())
data.add(PayloadData(name = "byYear", value = listOf(filters.year)))
if (!filters.language.isBlank())
data.add(PayloadData(name = "byLanguage", value = listOf(filters.language)))
if (!query.isBlank())
data.add(PayloadData(name = "searchTerm", value = listOf(query)))
searchParams += data.map { PayloadItem(it, "syncInput") }
return wireRequest("anime-search", searchParams)
}
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime {
val anime = SAnime.create()
val doc = getRealDoc(document)
val infos = doc.selectFirst("div.infos_left > div.anime-info")
val img = doc.selectFirst("div.infos-img > img")
anime.thumbnail_url = "https:" + img.attr("src")
anime.title = img.attr("alt")
val genres = infos.select("div.anime-genres > a")
.joinToString(", ") {
it.text()
}
anime.genre = genres
anime.author = infos.getInfo("Produtor")
anime.artist = infos.getInfo("Estúdio")
anime.status = parseStatus(infos.getInfo("Estado"))
var desc = infos.selectFirst("div.anime-description").text() + "\n\n"
desc += infos.select(">p").joinToString("\n") { it.text() }
anime.description = desc
return anime
}
// =============================== Latest ===============================
override fun latestUpdatesNextPageSelector() = throw Exception("not used")
override fun latestUpdatesSelector() = throw Exception("not used")
override fun latestUpdatesFromElement(element: Element) = throw Exception("not used")
override fun latestUpdatesRequest(page: Int): Request =
GET("$baseUrl/ultimosLancamentos?page=$page")
override fun latestUpdatesParse(response: Response) = popularAnimeParse(response)
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val videoQualityPref = ListPreference(screen.context).apply {
key = PREFERRED_QUALITY
title = "Qualidade preferida"
entries = QUALITY_LIST
entryValues = QUALITY_LIST
setDefaultValue(QUALITY_LIST.last())
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()
}
}
screen.addPreference(videoQualityPref)
}
override fun getFilterList(): AnimeFilterList = BAFilters.filterList
// ============================= Utilities ==============================
private fun getRealDoc(document: Document): Document {
val link = document.selectFirst("div.anime-title a")
if (link != null) {
val req = client.newCall(GET(link.attr("href"))).execute()
return req.asJsoup()
} else {
return document
}
}
private fun parseStatus(statusString: String?): Int {
return when (statusString?.trim()) {
"Completo" -> SAnime.COMPLETED
else -> SAnime.ONGOING
}
}
private fun hasNextPage(document: Document): Boolean {
val next = document.selectFirst(nextPageSelector())
if (next == null) return false
return !next.hasClass("disabled")
}
private fun updateInitialData(request: Request) {
val document = client.newCall(request).execute().asJsoup()
val wireElement = document.selectFirst("[wire:id]")
WIRE_TOKEN = document.html()
.substringAfter("livewire_token")
.substringAfter("'")
.substringBefore("'")
INITIAL_DATA = wireElement.attr("wire:initial-data")!!.dropLast(1)
}
private fun wireRequest(path: String, updates: List<PayloadItem>): Request {
val url = "$baseUrl/livewire/message/$path"
val items = updates.joinToString(",") { json.encodeToString(it) }
val data = "$INITIAL_DATA, \"updates\": [$items]}"
Log.d("wireData", data)
val reqBody = data.toRequestBody("application/json".toMediaType())
val headers = headersBuilder()
.add("x-livewire", "true")
.add("x-csrf-token", WIRE_TOKEN)
.build()
return POST(url, headers, reqBody)
}
private fun Element.getInfo(key: String): String? {
val element = this.selectFirst("p:containsOwn($key) > span")
if (element == null)
return element
return element.text().trim()
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREFERRED_QUALITY, null)
if (quality != null) {
val newList = mutableListOf<Video>()
var preferred = 0
for (video in this) {
if (video.quality.equals(quality)) {
newList.add(preferred, video)
preferred++
} else {
newList.add(video)
}
}
return newList
}
return this
}
companion object {
private const val ACCEPT_LANGUAGE = "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7"
private const val PREFERRED_QUALITY = "preferred_quality"
private val QUALITY_LIST = arrayOf("480p", "720p", "1080p")
private var INITIAL_DATA: String = ""
private var WIRE_TOKEN: String = ""
}
}

View File

@ -0,0 +1,51 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime.dto
import kotlinx.serialization.EncodeDefault
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
)
@Serializable
data class LivewireResponseDto(
val effects: LivewireEffects
)
@Serializable
data class LivewireEffects(
val html: String? = null
)
@Serializable
data class PayloadItem(
val payload: PayloadData,
val type: String
)
@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 = ""
)
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()
}
}

View File

@ -0,0 +1,69 @@
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 kotlinx.serialization.decodeFromString
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])
}
val token = html.substringAfter("_token:\"").substringBefore("\"")
return qualities.mapNotNull {
val videoUrl = videoUrlFromToken(it.second, token)
if (videoUrl == null)
null
else
Video(videoUrl, it.first, videoUrl, null)
}.toList()
}
private 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)
val response = client.newCall(request).execute()
val resJson = json.decodeFromString<ChangePlayerDto>(response.body?.string().orEmpty())
return videoUrlFromPlayer(resJson.frameLink)
}
private fun videoUrlFromPlayer(url: String?): String? {
if (url == null)
return null
val html = client.newCall(GET(url, headers)).execute().body
?.string().orEmpty()
val videoUrl = html.substringAfter("file\":")
.substringAfter("\"")
.substringBefore("\"")
.unescape()
return videoUrl
}
companion object {
private val REGEX_QUALITIES = """qualityString\["(\w+)"\] = "(\S+)"""".toRegex()
}
}