SussyToons: Fixes (#7294)

Fixes
This commit is contained in:
Chopper 2025-01-23 15:09:29 -03:00 committed by Draff
parent ff790752e0
commit 24aa61ed77
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
2 changed files with 30 additions and 373 deletions

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'Sussy Toons' extName = 'Sussy Toons'
extClass = '.SussyToons' extClass = '.SussyToons'
extVersionCode = 48 extVersionCode = 49
isNsfw = true isNsfw = true
} }

View File

@ -3,18 +3,11 @@ package eu.kanade.tachiyomi.extension.pt.sussyscan
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Application import android.app.Application
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast import android.widget.Toast
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
@ -22,26 +15,18 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Protocol
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.internal.http.HTTP_BAD_GATEWAY
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.select.Elements
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class SussyToons : HttpSource(), ConfigurableSource { class SussyToons : HttpSource(), ConfigurableSource {
@ -67,13 +52,9 @@ class SussyToons : HttpSource(), ConfigurableSource {
get() = preferences.getString(API_BASE_URL_PREF, defaultApiUrl)!! get() = preferences.getString(API_BASE_URL_PREF, defaultApiUrl)!!
set(value) = preferences.edit().putString(API_BASE_URL_PREF, value).apply() set(value) = preferences.edit().putString(API_BASE_URL_PREF, value).apply()
private var chapterScriptUrl: String private var restoreDefaultEnable: Boolean
get() = preferences.getString(CHAPTER_SCRIPT_URL_PREF, "")!! get() = preferences.getBoolean(DEFAULT_PREF, false)
set(value) = preferences.edit().putString(CHAPTER_SCRIPT_URL_PREF, value).apply() set(value) = preferences.edit().putBoolean(DEFAULT_PREF, value).apply()
private var pageScriptUrl: String
get() = preferences.getString(PAGE_SCRIPT_URL_PREF, "")!!
set(value) = preferences.edit().putString(PAGE_SCRIPT_URL_PREF, value).apply()
override val baseUrl: String get() = when { override val baseUrl: String get() = when {
isCi -> defaultBaseUrl isCi -> defaultBaseUrl
@ -84,14 +65,16 @@ class SussyToons : HttpSource(), ConfigurableSource {
private val defaultApiUrl: String = "https://api-dev.sussytoons.site" private val defaultApiUrl: String = "https://api-dev.sussytoons.site"
override val client = network.cloudflareClient.newBuilder() override val client = network.cloudflareClient.newBuilder()
.rateLimit(2)
.addInterceptor(::findApiUrl)
.addInterceptor(::findChapterUrl)
.addInterceptor(::chapterPages)
.addInterceptor(::imageLocation) .addInterceptor(::imageLocation)
.build() .build()
init { init {
if (restoreDefaultEnable) {
restoreDefaultEnable = false
preferences.edit().putString(DEFAULT_BASE_URL_PREF, null).apply()
preferences.edit().putString(API_DEFAULT_BASE_URL_PREF, null).apply()
}
preferences.getString(DEFAULT_BASE_URL_PREF, null).let { domain -> preferences.getString(DEFAULT_BASE_URL_PREF, null).let { domain ->
if (domain != defaultBaseUrl) { if (domain != defaultBaseUrl) {
preferences.edit() preferences.edit()
@ -187,11 +170,7 @@ class SussyToons : HttpSource(), ConfigurableSource {
it.chapterNumber?.let { it.chapterNumber?.let {
chapter_number = it chapter_number = it
} }
val chapterApiUrl = apiUrl.toHttpUrl().newBuilder() setUrlWithoutDomain("$baseUrl/capitulo/${it.id}")
.addEncodedPathSegments(chapterUrl!!)
.addPathSegment(it.id.toString())
.build()
setUrlWithoutDomain(chapterApiUrl.toString())
date_upload = it.updateAt.toDate() date_upload = it.updateAt.toDate()
} }
}.sortedBy(SChapter::chapter_number).reversed() }.sortedBy(SChapter::chapter_number).reversed()
@ -200,38 +179,13 @@ class SussyToons : HttpSource(), ConfigurableSource {
// ============================= Pages ==================================== // ============================= Pages ====================================
override fun pageListRequest(chapter: SChapter): Request { override fun pageListRequest(chapter: SChapter): Request {
return super.pageListRequest(chapter).let { request -> val request = super.pageListRequest(chapter)
val url = request.url.newBuilder() val chapterPageId = request.url.pathSegments.last()
.fragment("$pageImagePrefix${chapter.url}") return GET("$apiUrl/capitulos/$chapterPageId", headers)
.build()
request.newBuilder()
.url(url)
.build()
} }
}
private var pageUrlSegment: String? = null
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
pageUrlSegment = pageUrlSegment ?: findPageUrlSegment(response) val dto = response.parseAs<WrapperDto<ChapterPageDto>>().results
val chapterPageId = response.request.url.pathSegments.last()
val chapterUrl = response.request.url.fragment
?.substringAfter(pageImagePrefix)
?: throw Exception("Não foi possivel carregar as páginas")
val url = apiUrl.toHttpUrl().newBuilder()
.addEncodedPathSegments(pageUrlSegment!!)
.addPathSegment(chapterPageId)
.fragment(
"$chapterPagePrefix${"$baseUrl$chapterUrl"}",
)
.build()
val res = client.newCall(GET(url, headers)).execute()
val dto = res.parseAs<WrapperDto<ChapterPageDto>>().results
return dto.pages.mapIndexed { index, image -> return dto.pages.mapIndexed { index, image ->
val imageUrl = when { val imageUrl = when {
image.isWordPressContent() -> { image.isWordPressContent() -> {
@ -249,28 +203,6 @@ class SussyToons : HttpSource(), ConfigurableSource {
} }
} }
/**
* Get the dynamic path segment of the chapter page
*/
private fun findPageUrlSegment(response: Response): String {
val scriptUrls = when {
pageScriptUrl.isNotBlank() -> listOf(pageScriptUrl to headers)
else -> emptyList()
}
val script = loadJsScript(
urls = scriptUrls,
doRequest = { client.newCall(it).execute() },
pattern = pageUrlRegex,
fallback = { fetchAllNextJsScriptUrls(response.request) },
)
pageScriptUrl = script.url
return pageUrlRegex.find(script.body)?.groups?.get(2)?.value?.toPathSegment()
?: throw IOException("Não foi encontrar o caminho das páginas")
}
override fun imageUrlParse(response: Response): String = "" override fun imageUrlParse(response: Response): String = ""
override fun imageUrlRequest(page: Page): Request { override fun imageUrlRequest(page: Page): Request {
@ -282,135 +214,6 @@ class SussyToons : HttpSource(), ConfigurableSource {
// ============================= Interceptors ================================= // ============================= Interceptors =================================
private var chapterPageHeaders: Headers? = null
private var chapterUrl: String? = null
private fun findApiUrl(chain: Interceptor.Chain): Response {
val request = chain.request()
val response: Response = try {
chain.proceed(request)
} catch (ex: Exception) {
chain.createBadGatewayResponse(request)
}
if (response.isSuccessful || request.url.toString().contains(apiUrl).not()) {
return response
}
response.close()
fetchApiUrl(chain).forEach { urlCandidate ->
val url = request.url.toString()
.replace(apiUrl, urlCandidate)
.toHttpUrl()
val newRequest = request.newBuilder()
.url(url)
.build()
val localResponse = chain.proceed(newRequest)
if (localResponse.isSuccessful.not()) {
localResponse.close()
return@forEach
}
apiUrl = urlCandidate
return localResponse
}
throw IOException(
buildString {
append("Não foi possível encontrar a URL da API.")
append("Troque manualmente nas configurações da extensão")
},
)
}
private fun Interceptor.Chain.createBadGatewayResponse(request: Request): Response {
return Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.message("")
.code(HTTP_BAD_GATEWAY)
.build()
}
private fun fetchApiUrl(chain: Interceptor.Chain): List<String> {
val scripts = chain.proceed(GET(baseUrl, headers)).asJsoup()
.select("script[src*=next]:not([nomodule]):not([src*=app])")
val script = getScriptBodyWithUrls(scripts, chain)
?: throw Exception("Não foi possivel localizar a URL da API")
return apiUrlRegex.findAll(script)
.flatMap { stringsRegex.findAll(it.value).map { match -> match.groupValues[1] } }
.filter(urlRegex::containsMatchIn)
.toList()
}
private fun getScriptBodyWithUrls(scripts: Elements, chain: Interceptor.Chain): String? {
val elements = scripts.toList().reversed()
for (element in elements) {
val scriptUrl = element.absUrl("src")
val script = chain.proceed(GET(scriptUrl, headers)).body.string()
if (apiUrlRegex.containsMatchIn(script)) {
return script
}
}
return null
}
/**
* Get the dynamic path segment of the chapter list
*/
private fun findChapterUrl(chain: Interceptor.Chain): Response {
val request = chain.request()
val mangaUrl = request.url.fragment
?.takeIf {
it.contains(mangaPagePrefix, ignoreCase = true) && chapterUrl.isNullOrBlank()
}?.substringAfter(mangaPagePrefix)
?: return chain.proceed(request)
val scriptUrls = when {
chapterScriptUrl.isNotBlank() -> listOf(chapterScriptUrl to headers)
else -> emptyList()
}
val script = loadJsScript(
urls = scriptUrls,
doRequest = chain::proceed,
pattern = chapterUrlRegex,
fallback = { fetchAllNextJsScriptUrls(GET(mangaUrl, headers)) },
)
chapterScriptUrl = script.url
chapterUrl = chapterUrlRegex.find(script.body)?.groups?.get(1)?.value?.toPathSegment()
?: throw IOException("Não foi possivel extrair a URL do capitulo")
return chain.proceed(request)
}
private fun loadJsScript(
urls: List<Pair<String, Headers>>,
doRequest: (request: Request) -> Response,
pattern: Regex,
fallback: (() -> List<Pair<String, Headers>>)? = null,
): Script {
val script = urls.map { pair ->
val request = GET(pair.first, pair.second)
Script(
url = request.url.toString(),
body = doRequest(request).use { response -> response.body.string() },
)
}.firstOrNull { pattern.containsMatchIn(it.body) }
return script ?: fallback?.let { urlList ->
loadJsScript(urlList(), doRequest = doRequest, pattern = pattern)
} ?: throw IOException("Não foi possivel encontrar a URL do capitulo")
}
private fun imageLocation(chain: Interceptor.Chain): Response { private fun imageLocation(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
val response = chain.proceed(request) val response = chain.proceed(request)
@ -431,146 +234,6 @@ class SussyToons : HttpSource(), ConfigurableSource {
return response return response
} }
/**
* Resolve the dynamic headers of the chapter page
*/
private fun chapterPages(chain: Interceptor.Chain): Response {
val request = chain.request()
val chapterUrl = request.url.fragment
?.takeIf { it.contains(chapterPagePrefix) }
?.substringAfter(chapterPagePrefix)?.toHttpUrl()?.newBuilder()?.fragment(null)
?.build()
?: return chain.proceed(request)
val originUrl = request.url.newBuilder()
.fragment(null)
.build()
val newRequest = request.newBuilder()
.url(originUrl)
chapterPageHeaders?.let { headers ->
newRequest.headers(headers)
val response = chain.proceed(newRequest.build())
if (response.isSuccessful) {
return response
}
response.close()
}
val chapterPageRequest = request.newBuilder()
.url(chapterUrl)
.build()
return chain.proceed(fetchChapterPagesHeaders(chapterPageRequest, newRequest.build()))
}
@SuppressLint("SetJavaScriptEnabled")
private fun fetchChapterPagesHeaders(baseRequest: Request, originRequest: Request): Request {
fun WebResourceRequest.isOriginRequest() =
originRequest.url.toString().equals(this.url.toString(), ignoreCase = true)
chapterPageHeaders = handlingWithWebResourceRequest(
baseRequest,
initial = headersBuilder(),
stopCondition = { _, _, resource ->
resource.isOriginRequest() && resource.method.equals("GET", true)
},
fold = { headers, _, resource ->
headers.apply {
if (resource.isOriginRequest().not() || resource.method.equals("GET", true).not()) {
return@apply
}
fill(resource.requestHeaders)
}
},
).build()
return originRequest.newBuilder()
.headers(chapterPageHeaders!!)
.build()
}
@SuppressLint("SetJavaScriptEnabled")
private fun fetchAllNextJsScriptUrls(baseRequest: Request): List<Pair<String, Headers>> {
fun WebResourceRequest.isNextJSUrl() = this.url.toString().contains("_next", ignoreCase = true) &&
this.url.toString().contains(".js", ignoreCase = true)
return handlingWithWebResourceRequest(
baseRequest,
initial = mutableListOf(),
stopCondition = { urls, _, _ ->
val minUrlsAvailable = 24
urls.size > minUrlsAvailable
},
fold = { urls, base, resource ->
urls.apply {
if (resource.isNextJSUrl().not()) {
return@apply
}
val headers = base.headers.newBuilder().apply {
fill(resource.requestHeaders)
}
add(resource.url.toString() to headers.build())
}
},
)
}
@SuppressLint("SetJavaScriptEnabled")
private fun <T> handlingWithWebResourceRequest(
baseRequest: Request,
initial: T,
stopCondition: (T, Request, WebResourceRequest) -> Boolean,
fold: (T, Request, WebResourceRequest) -> T,
): T {
val latch = CountDownLatch(1)
var webView: WebView? = null
val looper = Handler(Looper.getMainLooper())
var state = initial
looper.post {
webView = WebView(Injekt.get<Application>())
webView?.let {
with(it.settings) {
javaScriptEnabled = true
domStorageEnabled = true
useWideViewPort = true
loadWithOverviewMode = true
cacheMode = WebSettings.LOAD_DEFAULT
}
}
webView?.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest,
): WebResourceResponse? {
state = fold(state, baseRequest, request)
if (stopCondition(state, baseRequest, request)) {
latch.countDown()
}
return super.shouldInterceptRequest(view, request)
}
}
webView?.loadUrl(baseRequest.url.toString(), baseRequest.headers.toMap())
}
latch.await(client.readTimeoutMillis.toLong(), TimeUnit.MILLISECONDS)
looper.post {
webView?.run {
stopLoading()
destroy()
}
}
return state
}
private fun Headers.Builder.fill(from: Map<String, String>): Headers.Builder {
return from.entries.fold(this) { builder, entry ->
builder.set(entry.key, entry.value)
}
}
// ============================= Settings ==================================== // ============================= Settings ====================================
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
@ -584,10 +247,6 @@ class SussyToons : HttpSource(), ConfigurableSource {
dialogMessage = "URL padrão:\n$defaultBaseUrl" dialogMessage = "URL padrão:\n$defaultBaseUrl"
setDefaultValue(defaultBaseUrl) setDefaultValue(defaultBaseUrl)
setOnPreferenceChangeListener { _, _ ->
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
true
}
}, },
EditTextPreference(screen.context).apply { EditTextPreference(screen.context).apply {
key = API_BASE_URL_PREF key = API_BASE_URL_PREF
@ -603,6 +262,19 @@ class SussyToons : HttpSource(), ConfigurableSource {
dialogMessage = "URL da API padrão:\n$defaultApiUrl" dialogMessage = "URL da API padrão:\n$defaultApiUrl"
setDefaultValue(defaultApiUrl) setDefaultValue(defaultApiUrl)
},
SwitchPreferenceCompat(screen.context).apply {
key = DEFAULT_PREF
title = "Redefinir configurações"
summary = buildString {
append("Habilite para redefinir as configurações padrões no próximo reinicialização da aplicação.")
appendLine("Você pode limpar os dados da extensão em Configurações > Avançado:")
appendLine("\t - Limpar os cookies")
appendLine("\t - Limpar os dados da WebView")
appendLine("\t - Limpar o banco de dados (Procure a '$name' e remova os dados)")
}
setDefaultValue(false)
setOnPreferenceChangeListener { _, _ -> setOnPreferenceChangeListener { _, _ ->
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show() Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
true true
@ -615,11 +287,6 @@ class SussyToons : HttpSource(), ConfigurableSource {
// ============================= Utilities ==================================== // ============================= Utilities ====================================
class Script(
val url: String,
val body: String,
)
private fun MangaDto.toSManga(): SManga { private fun MangaDto.toSManga(): SManga {
val sManga = SManga.create().apply { val sManga = SManga.create().apply {
title = name title = name
@ -658,8 +325,6 @@ class SussyToons : HttpSource(), ConfigurableSource {
const val CDN_URL = "https://cdn.sussytoons.site" const val CDN_URL = "https://cdn.sussytoons.site"
const val OLDI_URL = "https://oldi.sussytoons.site" const val OLDI_URL = "https://oldi.sussytoons.site"
const val mangaPagePrefix = "mangaPage:" const val mangaPagePrefix = "mangaPage:"
const val chapterPagePrefix = "chapterPage:"
const val pageImagePrefix = "pageImage:"
private const val URL_PREF_SUMMARY = "Para uso temporário, se a extensão for atualizada, a alteração será perdida." private const val URL_PREF_SUMMARY = "Para uso temporário, se a extensão for atualizada, a alteração será perdida."
@ -672,15 +337,7 @@ class SussyToons : HttpSource(), ConfigurableSource {
private const val API_BASE_URL_PREF_TITLE = "Editar URL da API da fonte" private const val API_BASE_URL_PREF_TITLE = "Editar URL da API da fonte"
private const val API_DEFAULT_BASE_URL_PREF = "defaultApiUrl" private const val API_DEFAULT_BASE_URL_PREF = "defaultApiUrl"
private const val CHAPTER_SCRIPT_URL_PREF = "chapterScriptUrl" private const val DEFAULT_PREF = "defaultPref"
private const val PAGE_SCRIPT_URL_PREF = "pageScriptUrl"
val chapterUrlRegex = """push\("([^"]*capitulo[^"]*)\/?"\.concat""".toRegex()
val pageUrlRegex = """\.(get|post)\("([^"]*capitulo[^"]*)\/?"\.concat""".toRegex()
val apiUrlRegex = """(?<=production",)(.*?)(?=;function)""".toRegex()
val urlRegex = """https?://[\w\-]+(\.[\w\-]+)+[/#?]?.*$""".toRegex()
val stringsRegex = """"(.*?)"""".toRegex()
@SuppressLint("SimpleDateFormat") @SuppressLint("SimpleDateFormat")
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)