Add GreenShit (#8745)

* Add GreenShit

* Replace icons

* Add icons

* Use latestUpdate as popularManga

* Remove dependencies unused

* Use lib-utils

* Change apiUrl visibilty
This commit is contained in:
Chopper 2025-05-10 11:24:36 -03:00 committed by Draff
parent 3e1688e565
commit db840dd353
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
26 changed files with 451 additions and 382 deletions

View File

@ -0,0 +1,5 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,362 @@
package eu.kanade.tachiyomi.multisrc.greenshit
import android.annotation.SuppressLint
import android.content.SharedPreferences
import android.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import app.cash.quickjs.QuickJs
import eu.kanade.tachiyomi.network.GET
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 keiyoushi.utils.getPreferences
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Locale
abstract class GreenShit(
override val name: String,
val url: String,
override val lang: String,
val scanId: Long = 1,
) : HttpSource(), ConfigurableSource {
override val supportsLatest = true
private val isCi = System.getenv("CI") == "true"
private val preferences: SharedPreferences = getPreferences()
private var apiUrl: String
get() = preferences.getString(API_BASE_URL_PREF, defaultApiUrl)!!
set(value) = preferences.edit().putString(API_BASE_URL_PREF, value).apply()
private var restoreDefaultEnable: Boolean
get() = preferences.getBoolean(DEFAULT_PREF, false)
set(value) = preferences.edit().putBoolean(DEFAULT_PREF, value).apply()
override val baseUrl: String get() = when {
isCi -> defaultBaseUrl
else -> preferences.getString(BASE_URL_PREF, defaultBaseUrl)!!
}
private val defaultBaseUrl: String = url
private val defaultApiUrl: String = "https://api.sussytoons.wtf"
override val client = network.cloudflareClient.newBuilder()
.addInterceptor(::imageLocation)
.build()
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 ->
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", scanId.toString())
// ============================= Popular ==================================
override fun popularMangaRequest(page: Int) = GET(baseUrl, headers)
override fun popularMangaParse(response: Response): MangasPage {
val json = response.parseScriptToJson().let(POPULAR_JSON_REGEX::find)
?.groups?.get(1)?.value
?: return MangasPage(emptyList(), false)
val mangas = json.parseAs<ResultDto<List<MangaDto>>>().toSMangaList()
return MangasPage(mangas, false)
}
// ============================= Latest ===================================
override fun latestUpdatesRequest(page: Int): Request {
val url = "$apiUrl/obras/novos-capitulos".toHttpUrl().newBuilder()
.addQueryParameter("pagina", page.toString())
.addQueryParameter("limite", "24")
.addQueryParameter("gen_id", "4")
.build()
return GET(url, headers)
}
override fun latestUpdatesParse(response: Response): MangasPage {
val dto = response.parseAs<ResultDto<List<MangaDto>>>()
val mangas = dto.toSMangaList()
return MangasPage(mangas, dto.hasNextPage())
}
// ============================= Search ===================================
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$apiUrl/obras".toHttpUrl().newBuilder()
.addQueryParameter("obr_nome", query)
.addQueryParameter("limite", "8")
.addQueryParameter("pagina", page.toString())
.addQueryParameter("todos_generos", "true")
.build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val dto = response.parseAs<ResultDto<List<MangaDto>>>()
return MangasPage(dto.toSMangaList(), dto.hasNextPage())
}
// ============================= Details ==================================
override fun mangaDetailsParse(response: Response): SManga {
val json = response.parseScriptToJson().let(DETAILS_CHAPTER_REGEX::find)
?.groups?.get(0)?.value
?: throw IOException("Details do mangá não foi encontrado")
return json.parseAs<ResultDto<MangaDto>>().results.toSManga()
}
// ============================= Chapters =================================
override fun chapterListParse(response: Response): List<SChapter> {
val json = response.parseScriptToJson().let(DETAILS_CHAPTER_REGEX::find)
?.groups?.get(0)?.value
?: return emptyList()
return json.parseAs<ResultDto<WrapperChapterDto>>().results.chapters.map {
SChapter.create().apply {
name = it.name
it.chapterNumber?.let {
chapter_number = it
}
setUrlWithoutDomain("$baseUrl/capitulo/${it.id}")
date_upload = dateFormat.tryParse(it.updateAt)
}
}.sortedByDescending(SChapter::chapter_number)
}
// ============================= Pages ====================================
private val pageUrlSelector = "img.chakra-image"
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
pageListParse(document).takeIf(List<Page>::isNotEmpty)?.let { return it }
val dto = extractScriptData(document)
.let(::extractJsonContent)
.let(::parseJsonToChapterPageDto)
return dto.pages.mapIndexed { index, image ->
val imageUrl = when {
image.isWordPressContent() -> {
CDN_URL.toHttpUrl().newBuilder()
.addPathSegments("wp-content/uploads/WP-manga/data")
.addPathSegments(image.src.toPathSegment())
.build()
}
else -> {
"$CDN_URL/scans/${dto.manga.scanId}/obras/${dto.manga.id}/capitulos/${dto.chapterNumber}/${image.src}"
.toHttpUrl()
}
}
Page(index, imageUrl = imageUrl.toString())
}
}
private fun pageListParse(document: Document): List<Page> {
return document.select(pageUrlSelector).mapIndexed { index, element ->
Page(index, document.location(), element.absUrl("src"))
}
}
private fun extractScriptData(document: Document): String {
return document.select("script").map(Element::data)
.firstOrNull(pageRegex::containsMatchIn)
?: throw Exception("Failed to load pages: Script data not found")
}
private fun extractJsonContent(scriptData: String): String {
return pageRegex.find(scriptData)
?.groups?.get(1)?.value
?.let { "\"$it\"".parseAs<String>() }
?: throw Exception("Failed to extract JSON from script")
}
private fun parseJsonToChapterPageDto(jsonContent: String): ChapterPageDto {
return try {
jsonContent.parseAs<ResultDto<ChapterPageDto>>().results
} catch (e: Exception) {
throw Exception("Failed to load pages: ${e.message}")
}
}
override fun imageUrlParse(response: Response): String = ""
override fun imageUrlRequest(page: Page): Request {
val imageHeaders = headers.newBuilder()
.add("Referer", "$baseUrl/")
.build()
return GET(page.url, imageHeaders)
}
// ============================= Interceptors =================================
private fun imageLocation(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
if (response.isSuccessful) {
return response
}
response.close()
val url = request.url.newBuilder()
.dropPathSegment(4)
.build()
val newRequest = request.newBuilder()
.url(url)
.build()
return chain.proceed(newRequest)
}
// ============================= 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)
},
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)
},
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 { _, _ ->
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
true
}
},
)
fields.forEach(screen::addPreference)
}
// ============================= Utilities ====================================
private fun Response.parseScriptToJson(): String {
val document = asJsoup()
val script = document.select("script")
.map(Element::data)
.filter(String::isNotEmpty)
.joinToString("\n")
return QuickJs.create().use {
it.evaluate(
"""
globalThis.self = globalThis;
$script
self.__next_f.map(it => it[it.length - 1]).join('')
""".trimIndent(),
) as String
}
}
private fun HttpUrl.Builder.dropPathSegment(count: Int): HttpUrl.Builder {
repeat(count) {
removePathSegment(0)
}
return this
}
/**
* 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://cdn.sussytoons.site"
val pageRegex = """capituloInicial.{3}(.*?)(\}\]\})""".toRegex()
val POPULAR_JSON_REGEX = """(?:"dataTop":)(\{.+totalPaginas":\d+\})(?:.+"dataF)""".toRegex()
val DETAILS_CHAPTER_REGEX = """(\{\"resultado.+"\}{3})""".toRegex()
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"
private const val DEFAULT_PREF = "defaultPref"
@SuppressLint("SimpleDateFormat")
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
}
}

View File

@ -1,11 +1,12 @@
package eu.kanade.tachiyomi.extension.pt.sussyscan
package eu.kanade.tachiyomi.multisrc.greenshit
import eu.kanade.tachiyomi.extension.pt.sussyscan.SussyToons.Companion.CDN_URL
import eu.kanade.tachiyomi.multisrc.greenshit.GreenShit.Companion.CDN_URL
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import org.jsoup.Jsoup
import java.text.Normalizer
@Serializable
class ResultDto<T>(
@ -20,8 +21,18 @@ class ResultDto<T>(
fun hasNextPage() = currentPage < lastPage
fun toSMangaList() = (results as List<MangaDto>)
.filterNot { it.slug.isNullOrBlank() }.map { it.toSManga() }
fun toSMangaList(): List<SManga> = (results as List<MangaDto>)
.map { it.apply { slug = it.slug ?: name.createSlug() } }
.map(MangaDto::toSManga)
private fun String.createSlug(): String {
return Normalizer.normalize(this, Normalizer.Form.NFD)
.trim()
.replace("\\p{InCombiningDiacriticalMarks}+".toRegex(), "")
.replace("\\p{Punct}".toRegex(), "")
.replace("\\s+".toRegex(), "-")
.lowercase()
}
}
@Serializable
@ -46,7 +57,7 @@ class MangaDto(
@SerialName("obr_nome")
val name: String,
@SerialName("obr_slug")
val slug: String?,
var slug: String?,
@SerialName("status")
val status: MangaStatus,
@SerialName("scan_id")

View File

@ -0,0 +1,10 @@
ext {
extName = 'Aurora Scan'
extClass = '.AuroraScan'
themePkg = 'greenshit'
baseUrl = 'https://aurorascan.net'
overrideVersionCode = 0
isNsfw = false
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.extension.pt.aurorascan
import eu.kanade.tachiyomi.multisrc.greenshit.GreenShit
import eu.kanade.tachiyomi.network.interceptor.rateLimit
class AuroraScan : GreenShit(
"Aurora Scan",
"https://aurorascan.net",
"pt-BR",
scanId = 4,
) {
override val client = super.client.newBuilder()
.rateLimit(2)
.build()
}

View File

@ -1,9 +1,9 @@
ext {
extName = 'Maid Scan'
extClass = '.MaidScan'
themePkg = 'madara'
baseUrl = 'https://empreguetes.site'
overrideVersionCode = 2
themePkg = 'greenshit'
baseUrl = 'https://novo.empreguetes.site'
overrideVersionCode = 45
isNsfw = true
}

View File

@ -1,22 +1,16 @@
package eu.kanade.tachiyomi.extension.pt.maidscan
import eu.kanade.tachiyomi.multisrc.madara.Madara
import eu.kanade.tachiyomi.multisrc.greenshit.GreenShit
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import okhttp3.OkHttpClient
import java.text.SimpleDateFormat
import java.util.Locale
class MaidScan : Madara(
class MaidScan : GreenShit(
"Maid Scan",
"https://empreguetes.site",
"https://novo.empreguetes.site",
"pt-BR",
SimpleDateFormat("dd/MM/yyyy", Locale("pt", "BR")),
scanId = 3,
) {
override val client: OkHttpClient = super.client.newBuilder()
.rateLimit(2)
.build()
override val useLoadMoreRequest = LoadMoreStrategy.Never
override val useNewChapterEndpoint = true
}

View File

@ -0,0 +1,10 @@
ext {
extName = 'Mediocre Toons'
extClass = '.MediocreToons'
themePkg = 'greenshit'
baseUrl = 'https://mediocretoons.com'
overrideVersionCode = 0
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.extension.pt.mediocretoons
import eu.kanade.tachiyomi.multisrc.greenshit.GreenShit
import eu.kanade.tachiyomi.network.interceptor.rateLimit
class MediocreToons : GreenShit(
"Mediocre Toons",
"https://mediocretoons.com",
"pt-BR",
scanId = 2,
) {
override val client = super.client.newBuilder()
.rateLimit(2)
.build()
}

View File

@ -1,7 +1,9 @@
ext {
extName = 'Sussy Toons'
extClass = '.SussyToons'
extVersionCode = 53
themePkg = 'greenshit'
baseUrl = 'https://www.sussytoons.wtf'
overrideVersionCode = 54
isNsfw = true
}

View File

@ -1,372 +1,17 @@
package eu.kanade.tachiyomi.extension.pt.sussyscan
import android.annotation.SuppressLint
import android.content.SharedPreferences
import android.widget.Toast
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import app.cash.quickjs.QuickJs
import eu.kanade.tachiyomi.network.GET
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 keiyoushi.utils.getPreferences
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Locale
class SussyToons : HttpSource(), ConfigurableSource {
override val name = "Sussy Toons"
override val lang = "pt-BR"
override val supportsLatest = true
import eu.kanade.tachiyomi.multisrc.greenshit.GreenShit
class SussyToons : GreenShit(
"Sussy Toons",
"https://www.sussytoons.wtf",
"pt-BR",
) {
override val id = 6963507464339951166
// Moved from Madara
override val versionId = 2
private val json: Json by injectLazy()
override val supportsLatest = false
private val isCi = System.getenv("CI") == "true"
private val preferences: SharedPreferences = getPreferences()
private var apiUrl: String
get() = preferences.getString(API_BASE_URL_PREF, defaultApiUrl)!!
set(value) = preferences.edit().putString(API_BASE_URL_PREF, value).apply()
private var restoreDefaultEnable: Boolean
get() = preferences.getBoolean(DEFAULT_PREF, false)
set(value) = preferences.edit().putBoolean(DEFAULT_PREF, value).apply()
override val baseUrl: String get() = when {
isCi -> defaultBaseUrl
else -> preferences.getString(BASE_URL_PREF, defaultBaseUrl)!!
}
private val defaultBaseUrl: String = "https://www.sussytoons.wtf"
private val defaultApiUrl: String = "https://api.sussytoons.wtf"
override val client = network.cloudflareClient.newBuilder()
.addInterceptor(::imageLocation)
.build()
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 ->
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
// ============================= Popular ==================================
override fun popularMangaRequest(page: Int) = GET(baseUrl, headers)
override fun popularMangaParse(response: Response): MangasPage {
val json = response.parseScriptToJson()
?: return MangasPage(emptyList(), false)
val mangas = json.parseAs<WrapperDto>().popular?.toSMangaList()
?: emptyList()
return MangasPage(mangas, false)
}
// ============================= Latest ===================================
override fun latestUpdatesRequest(page: Int): Request {
val url = "$baseUrl/atualizacoes".toHttpUrl().newBuilder()
.addQueryParameter("pagina", page.toString())
.build()
return GET(url, headers)
}
override fun latestUpdatesParse(response: Response): MangasPage {
val json = response.parseScriptToJson()
?: return MangasPage(emptyList(), false)
val dto = json.parseAs<WrapperDto>()
val mangas = dto.latest.toSMangaList()
return MangasPage(mangas, dto.latest.hasNextPage())
}
// ============================= Search ===================================
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$apiUrl/obras".toHttpUrl().newBuilder()
.addQueryParameter("obr_nome", query)
.addQueryParameter("limite", "8")
.addQueryParameter("pagina", page.toString())
.addQueryParameter("todos_generos", "true")
.build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val dto = response.parseAs<ResultDto<List<MangaDto>>>()
return MangasPage(dto.toSMangaList(), dto.hasNextPage())
}
// ============================= Details ==================================
override fun mangaDetailsParse(response: Response): SManga {
val json = response.parseScriptToJson()
?: throw IOException("Details do mangá não foi encontrado")
return json.parseAs<ResultDto<MangaDto>>().results.toSManga()
}
// ============================= Chapters =================================
override fun chapterListParse(response: Response): List<SChapter> {
val json = response.parseScriptToJson() ?: return emptyList()
return json.parseAs<ResultDto<WrapperChapterDto>>().results.chapters.map {
SChapter.create().apply {
name = it.name
it.chapterNumber?.let {
chapter_number = it
}
setUrlWithoutDomain("$baseUrl/capitulo/${it.id}")
date_upload = dateFormat.tryParse(it.updateAt)
}
}.sortedByDescending(SChapter::chapter_number)
}
// ============================= Pages ====================================
private val pageUrlSelector = "img.chakra-image"
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
pageListParse(document).takeIf(List<Page>::isNotEmpty)?.let { return it }
val dto = extractScriptData(document)
.let(::extractJsonContent)
.let(::parseJsonToChapterPageDto)
return dto.pages.mapIndexed { index, image ->
val imageUrl = when {
image.isWordPressContent() -> {
CDN_URL.toHttpUrl().newBuilder()
.addPathSegments("wp-content/uploads/WP-manga/data")
.addPathSegments(image.src.toPathSegment())
.build()
}
else -> {
"$CDN_URL/scans/${dto.manga.scanId}/obras/${dto.manga.id}/capitulos/${dto.chapterNumber}/${image.src}"
.toHttpUrl()
}
}
Page(index, imageUrl = imageUrl.toString())
}
}
private fun pageListParse(document: Document): List<Page> {
return document.select(pageUrlSelector).mapIndexed { index, element ->
Page(index, document.location(), element.absUrl("src"))
}
}
private fun extractScriptData(document: Document): String {
return document.select("script").map(Element::data)
.firstOrNull(pageRegex::containsMatchIn)
?: throw Exception("Failed to load pages: Script data not found")
}
private fun extractJsonContent(scriptData: String): String {
return pageRegex.find(scriptData)
?.groups?.get(1)?.value
?.let { json.decodeFromString<String>("\"$it\"") }
?: throw Exception("Failed to extract JSON from script")
}
private fun parseJsonToChapterPageDto(jsonContent: String): ChapterPageDto {
return try {
jsonContent.parseAs<ResultDto<ChapterPageDto>>().results
} catch (e: Exception) {
throw Exception("Failed to load pages: ${e.message}")
}
}
override fun imageUrlParse(response: Response): String = ""
override fun imageUrlRequest(page: Page): Request {
val imageHeaders = headers.newBuilder()
.add("Referer", "$baseUrl/")
.build()
return GET(page.url, imageHeaders)
}
// ============================= Interceptors =================================
private fun imageLocation(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
if (response.isSuccessful) {
return response
}
response.close()
val url = request.url.newBuilder()
.dropPathSegment(4)
.build()
val newRequest = request.newBuilder()
.url(url)
.build()
return chain.proceed(newRequest)
}
// ============================= 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)
},
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)
},
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 { _, _ ->
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
true
}
},
)
fields.forEach(screen::addPreference)
}
// ============================= Utilities ====================================
private fun Response.parseScriptToJson(): String? {
val document = asJsoup()
val script = document.select("script")
.map(Element::data)
.filter(String::isNotEmpty)
.joinToString("\n")
val content = QuickJs.create().use {
it.evaluate(
"""
globalThis.self = globalThis;
$script
self.__next_f.map(it => it[it.length - 1]).join('')
""".trimIndent(),
) as String
}
return PAGE_JSON_REGEX.find(content)?.groups?.get(0)?.value
}
private fun HttpUrl.Builder.dropPathSegment(count: Int): HttpUrl.Builder {
repeat(count) {
removePathSegment(0)
}
return this
}
/**
* 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://cdn.sussytoons.site"
val pageRegex = """capituloInicial.{3}(.*?)(\}\]\})""".toRegex()
val POPULAR_JSON_REGEX = """\{\"dataFeatured.+totalPaginas":\d+\}{2}""".toRegex()
val LATEST_JSON_REGEX = """\{\"atualizacoesInicial.+\}\}""".toRegex()
val DETAILS_CHAPTER_REGEX = """\{\"resultado.+"\}{3}""".toRegex()
val PAGE_JSON_REGEX = """$POPULAR_JSON_REGEX|$LATEST_JSON_REGEX|$DETAILS_CHAPTER_REGEX""".toRegex()
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"
private const val DEFAULT_PREF = "defaultPref"
@SuppressLint("SimpleDateFormat")
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
}
override fun fetchPopularManga(page: Int) = fetchLatestUpdates(page)
}