SussyToons: Fix loading pages (#7093)

* Fix loading pages

* Remove 'named capture groups' and add configuration

* Fix

* Use dynamic api url
This commit is contained in:
Chopper 2025-01-10 20:48:26 -03:00 committed by Draff
parent 699740accb
commit 4f0481f71d
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
2 changed files with 295 additions and 39 deletions

View File

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

View File

@ -2,45 +2,51 @@ package eu.kanade.tachiyomi.extension.pt.sussyscan
import android.annotation.SuppressLint
import android.app.Application
import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.internal.http.HTTP_BAD_GATEWAY
import org.jsoup.Jsoup
import rx.Observable
import org.jsoup.select.Elements
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.net.SocketTimeoutException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class SussyToons : HttpSource() {
class SussyToons : HttpSource(), ConfigurableSource {
override val name = "Sussy Toons"
override val baseUrl = "https://new.sussytoons.site"
private val apiUrl = "https://api-dev.sussytoons.site"
override val lang = "pt-BR"
override val supportsLatest = true
@ -52,12 +58,60 @@ class SussyToons : HttpSource() {
private val json: Json by injectLazy()
private val isCi = System.getenv("CI") == "true"
private val preferences: SharedPreferences =
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
private var _apiUrlCache: String? = null
private var apiUrl: String
get() = _apiUrlCache ?: preferences.prefApiUrl.also { _apiUrlCache = it }
set(value) { _apiUrlCache = value }
override val baseUrl: String get() = when {
isCi -> defaultBaseUrl
else -> preferences.prefBaseUrl
}
private val SharedPreferences.prefBaseUrl: String get() = getString(BASE_URL_PREF, defaultBaseUrl)!!
private val SharedPreferences.prefApiUrl: String get() = getString(API_BASE_URL_PREF, defaultApiUrl)!!
private fun SharedPreferences.prefApiUrlUpSet(url: String): String {
edit().putString(API_BASE_URL_PREF, url)
.apply()
return url
}
private val defaultBaseUrl: String = "https://www.sussytoons.site"
private val defaultApiUrl: String = "https://api-dev.sussytoons.site"
override val client = network.cloudflareClient.newBuilder()
.rateLimit(1, 2, TimeUnit.SECONDS)
.rateLimit(2)
.addInterceptor(::findApiUrl)
.addInterceptor(::findChapterUrl)
.addInterceptor(::chapterPages)
.addInterceptor(::imageLocation)
.build()
init {
preferences.getString(DEFAULT_BASE_URL_PREF, null).let { domain ->
if (domain != defaultBaseUrl) {
preferences.edit()
.putString(BASE_URL_PREF, defaultBaseUrl)
.putString(DEFAULT_BASE_URL_PREF, defaultBaseUrl)
.apply()
}
}
preferences.getString(API_DEFAULT_BASE_URL_PREF, null).let { domain ->
if (domain != defaultApiUrl) {
preferences.edit()
.putString(API_BASE_URL_PREF, defaultApiUrl)
.putString(API_DEFAULT_BASE_URL_PREF, defaultApiUrl)
.apply()
}
}
}
override fun headersBuilder() = super.headersBuilder()
.set("scan-id", "1") // Required header for requests
@ -109,6 +163,7 @@ class SussyToons : HttpSource() {
override fun mangaDetailsRequest(manga: SManga): Request {
val url = "$apiUrl/obras".toHttpUrl().newBuilder()
.addPathSegment(manga.id)
.fragment("$mangaPagePrefix${getMangaUrl(manga)}")
.build()
return GET(url, headers)
}
@ -125,13 +180,6 @@ class SussyToons : HttpSource() {
// ============================= Chapters =================================
override fun getChapterUrl(chapter: SChapter): String {
return "$baseUrl/capitulo".toHttpUrl().newBuilder()
.addPathSegment(chapter.id)
.build()
.toString()
}
override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga)
override fun chapterListParse(response: Response): List<SChapter> {
@ -141,47 +189,75 @@ class SussyToons : HttpSource() {
it.chapterNumber?.let {
chapter_number = it
}
val chapterApiUrl = "$apiUrl/capitulos".toHttpUrl().newBuilder()
val chapterApiUrl = apiUrl.toHttpUrl().newBuilder()
.addEncodedPathSegments(chapterUrl!!)
.addPathSegment(it.id.toString())
.build()
setUrlWithoutDomain(chapterApiUrl.toString())
date_upload = it.updateAt.toDate()
}
}
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return super.fetchChapterList(manga)
.map { it.sortedBy(SChapter::chapter_number).reversed() }
}
private val SChapter.id: String get() {
val chapterApiUrl = apiUrl.toHttpUrl().newBuilder()
.addPathSegments(url)
.build()
return chapterApiUrl.pathSegments.last()
}.sortedBy(SChapter::chapter_number).reversed()
}
// ============================= Pages ====================================
override fun pageListRequest(chapter: SChapter): Request {
val url = "$apiUrl${chapter.url}".toHttpUrl().newBuilder()
.fragment(getChapterUrl(chapter))
.build()
return GET(url, headers)
return super.pageListRequest(chapter).let { request ->
val url = request.url.newBuilder()
.fragment("$pageImagePrefix${chapter.url}")
.build()
request.newBuilder()
.url(url)
.build()
}
}
private var pageUrl: String? = null
override fun pageListParse(response: Response): List<Page> {
val dto = response.parseAs<WrapperDto<ChapterPageDto>>().results
pageUrl = pageUrl ?: findPageUrl(response)
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(pageUrl!!)
.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 ->
val imageUrl = CDN_URL.toHttpUrl().newBuilder()
.addPathSegments("wp-content/uploads/WP-manga/data")
.addPathSegments(image.src)
.addPathSegments(image.src.toPathSegment())
.build().toString()
Page(index, imageUrl = imageUrl)
}
}
/**
* Get the dynamic path segment of the chapter page
*/
private fun findPageUrl(response: Response): String {
val document = response.asJsoup()
val scriptUrl = document.select("script[src]")
.map { it.absUrl("src") }
.firstOrNull { it.contains("app/capitulo", ignoreCase = true) }
?: throw IOException("Não foi possivel encontrar a URL da página")
return client.newCall(GET(scriptUrl, headers)).execute().use {
pageUrlRegex.find(it.body.string())?.groups?.get(1)?.value?.toPathSegment()
} ?: throw IOException("Não foi possivel extrair a URL da página")
}
override fun imageUrlParse(response: Response): String = ""
override fun imageUrlRequest(page: Page): Request {
@ -195,6 +271,109 @@ class SussyToons : HttpSource() {
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: SocketTimeoutException) {
chain.createTimeoutResponse(request)
}
if (request.url.toString().contains(apiUrl).not()) {
return response
}
if (response.isSuccessful) {
return response
}
response.close()
fetchApiUrl(chain).forEach { urlCandidate ->
val url = request.url.toString()
.replace(apiUrl, urlCandidate)
.toHttpUrl()
val newRequest = request.newBuilder()
.url(url)
.build()
return chain.proceed(newRequest).takeIf(Response::isSuccessful).also {
apiUrl = preferences.prefApiUrlUpSet(urlCandidate)
} ?: return@forEach
}
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.createTimeoutResponse(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 document = chain.proceed(GET(mangaUrl, headers)).asJsoup()
val scriptUrl = document.select("script[src]")
.map { it.absUrl("src") }
.firstOrNull { it.contains("app/obra", ignoreCase = true) }
?: throw IOException("Não foi possivel encontrar a URL do capitulo")
chapterUrl = chain.proceed(GET(scriptUrl, headers)).use { response ->
response.body.string().let {
chapterUrlRegex.find(it)?.groups?.get(1)?.value?.toPathSegment()
} ?: throw IOException("Não foi possivel extrair a URL do capitulo")
}
return chain.proceed(request)
}
private fun imageLocation(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
@ -215,9 +394,15 @@ class SussyToons : HttpSource() {
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("capitulo") }
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()
@ -249,7 +434,6 @@ class SussyToons : HttpSource() {
val headers = originRequest.headers.newBuilder()
var webView: WebView? = null
val looper = Handler(Looper.getMainLooper())
looper.post {
webView = WebView(Injekt.get<Application>())
webView?.let {
@ -279,7 +463,7 @@ class SussyToons : HttpSource() {
private fun emptyResource() = WebResourceResponse(null, null, null)
}
webView?.loadUrl(baseRequest.url.toString())
webView?.loadUrl(baseRequest.url.toString(), headers.build().toMap())
}
latch.await(client.readTimeoutMillis.toLong(), TimeUnit.MILLISECONDS)
@ -304,6 +488,48 @@ class SussyToons : HttpSource() {
}
}
// ============================= Settings ====================================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val fields = listOf(
EditTextPreference(screen.context).apply {
key = BASE_URL_PREF
title = BASE_URL_PREF_TITLE
summary = URL_PREF_SUMMARY
dialogTitle = BASE_URL_PREF_TITLE
dialogMessage = "URL padrão:\n$defaultBaseUrl"
setDefaultValue(defaultBaseUrl)
setOnPreferenceChangeListener { _, _ ->
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
true
}
},
EditTextPreference(screen.context).apply {
key = API_BASE_URL_PREF
title = API_BASE_URL_PREF_TITLE
summary = buildString {
append("Se não souber como verificar a URL da API, ")
append("busque suporte no Discord do repositório de extensões.")
appendLine(URL_PREF_SUMMARY)
append("\n⚠ A fonte não oferece suporte para essa extensão.")
}
dialogTitle = BASE_URL_PREF_TITLE
dialogMessage = "URL da API padrão:\n$defaultApiUrl"
setDefaultValue(defaultApiUrl)
setOnPreferenceChangeListener { _, _ ->
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
true
}
},
)
fields.forEach(screen::addPreference)
}
// ============================= Utilities ====================================
private fun MangaDto.toSManga(): SManga {
@ -331,9 +557,39 @@ class SussyToons : HttpSource() {
private fun String.toDate() =
try { dateFormat.parse(this)!!.time } catch (_: Exception) { 0L }
/**
* Normalizes path segments:
* Ex: [ "/a/b/", "/a/b", "a/b/", "a/b" ]
* Result: "a/b"
*/
private fun String.toPathSegment() = this.trim().split("/")
.filter(String::isNotEmpty)
.joinToString("/")
companion object {
const val CDN_URL = "https://usc1.contabostorage.com/23b45111d96c42c18a678c1d8cba7123:cdn"
const val CDN_URL = "https://cdn.sussytoons.site"
const val OLDI_URL = "https://oldi.sussytoons.site"
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 BASE_URL_PREF = "overrideBaseUrl"
private const val BASE_URL_PREF_TITLE = "Editar URL da fonte"
private const val DEFAULT_BASE_URL_PREF = "defaultBaseUrl"
private const val RESTART_APP_MESSAGE = "Reinicie o aplicativo para aplicar as alterações"
private const val API_BASE_URL_PREF = "overrideApiUrl"
private const val API_BASE_URL_PREF_TITLE = "Editar URL da API da fonte"
private const val API_DEFAULT_BASE_URL_PREF = "defaultApiUrl"
val chapterUrlRegex = """push\("([^"]*capitulo[^"]*)/?"\.concat""".toRegex()
val pageUrlRegex = """\.get\("([^"]*capitulo[^(/?")]*)/?"\.concat""".toRegex()
val apiUrlRegex = """(?<=production",)(.*?)(?=;function)""".toRegex()
val urlRegex = """https?://[\w\-]+(\.[\w\-]+)+[/#?]?.*$""".toRegex()
val stringsRegex = """"(.*?)"""".toRegex()
@SuppressLint("SimpleDateFormat")
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)