diff --git a/lib-multisrc/greenshit/build.gradle.kts b/lib-multisrc/greenshit/build.gradle.kts new file mode 100644 index 000000000..dc076cc37 --- /dev/null +++ b/lib-multisrc/greenshit/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("lib-multisrc") +} + +baseVersionCode = 1 diff --git a/lib-multisrc/greenshit/res/mipmap-hdpi/ic_launcher.png b/lib-multisrc/greenshit/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..0a47efcc5 Binary files /dev/null and b/lib-multisrc/greenshit/res/mipmap-hdpi/ic_launcher.png differ diff --git a/lib-multisrc/greenshit/res/mipmap-mdpi/ic_launcher.png b/lib-multisrc/greenshit/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..ed4b4ac65 Binary files /dev/null and b/lib-multisrc/greenshit/res/mipmap-mdpi/ic_launcher.png differ diff --git a/lib-multisrc/greenshit/res/mipmap-xhdpi/ic_launcher.png b/lib-multisrc/greenshit/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..eb9760088 Binary files /dev/null and b/lib-multisrc/greenshit/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/lib-multisrc/greenshit/res/mipmap-xxhdpi/ic_launcher.png b/lib-multisrc/greenshit/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..034b2e34a Binary files /dev/null and b/lib-multisrc/greenshit/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/lib-multisrc/greenshit/res/mipmap-xxxhdpi/ic_launcher.png b/lib-multisrc/greenshit/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..4ef8fd154 Binary files /dev/null and b/lib-multisrc/greenshit/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/lib-multisrc/greenshit/src/eu/kanade/tachiyomi/multisrc/greenshit/GreenShit.kt b/lib-multisrc/greenshit/src/eu/kanade/tachiyomi/multisrc/greenshit/GreenShit.kt new file mode 100644 index 000000000..12c72020c --- /dev/null +++ b/lib-multisrc/greenshit/src/eu/kanade/tachiyomi/multisrc/greenshit/GreenShit.kt @@ -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>>().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>>() + 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>>() + 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>().results.toSManga() + } + + // ============================= Chapters ================================= + + override fun chapterListParse(response: Response): List { + val json = response.parseScriptToJson().let(DETAILS_CHAPTER_REGEX::find) + ?.groups?.get(0)?.value + ?: return emptyList() + return json.parseAs>().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 { + val document = response.asJsoup() + + pageListParse(document).takeIf(List::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 { + 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() } + ?: throw Exception("Failed to extract JSON from script") + } + + private fun parseJsonToChapterPageDto(jsonContent: String): ChapterPageDto { + return try { + jsonContent.parseAs>().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) + } +} diff --git a/src/pt/sussyscan/src/eu/kanade/tachiyomi/extension/pt/sussyscan/SussyToonsDto.kt b/lib-multisrc/greenshit/src/eu/kanade/tachiyomi/multisrc/greenshit/GreenShitDto.kt similarity index 83% rename from src/pt/sussyscan/src/eu/kanade/tachiyomi/extension/pt/sussyscan/SussyToonsDto.kt rename to lib-multisrc/greenshit/src/eu/kanade/tachiyomi/multisrc/greenshit/GreenShitDto.kt index 254010c08..b3d6e4843 100644 --- a/src/pt/sussyscan/src/eu/kanade/tachiyomi/extension/pt/sussyscan/SussyToonsDto.kt +++ b/lib-multisrc/greenshit/src/eu/kanade/tachiyomi/multisrc/greenshit/GreenShitDto.kt @@ -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( @@ -20,8 +21,18 @@ class ResultDto( fun hasNextPage() = currentPage < lastPage - fun toSMangaList() = (results as List) - .filterNot { it.slug.isNullOrBlank() }.map { it.toSManga() } + fun toSMangaList(): List = (results as List) + .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") diff --git a/src/pt/aurorascan/build.gradle b/src/pt/aurorascan/build.gradle new file mode 100644 index 000000000..64b392189 --- /dev/null +++ b/src/pt/aurorascan/build.gradle @@ -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" diff --git a/src/pt/aurorascan/res/mipmap-hdpi/ic_launcher.png b/src/pt/aurorascan/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..38a787bab Binary files /dev/null and b/src/pt/aurorascan/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/aurorascan/res/mipmap-mdpi/ic_launcher.png b/src/pt/aurorascan/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..87dc7a98a Binary files /dev/null and b/src/pt/aurorascan/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/aurorascan/res/mipmap-xhdpi/ic_launcher.png b/src/pt/aurorascan/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..cbd7be623 Binary files /dev/null and b/src/pt/aurorascan/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/aurorascan/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/aurorascan/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..add909d1b Binary files /dev/null and b/src/pt/aurorascan/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/aurorascan/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/aurorascan/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..37d4d0919 Binary files /dev/null and b/src/pt/aurorascan/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/aurorascan/src/eu/kanade/tachiyomi/extension/pt/aurorascan/AuroraScan.kt b/src/pt/aurorascan/src/eu/kanade/tachiyomi/extension/pt/aurorascan/AuroraScan.kt new file mode 100644 index 000000000..9b777b1a8 --- /dev/null +++ b/src/pt/aurorascan/src/eu/kanade/tachiyomi/extension/pt/aurorascan/AuroraScan.kt @@ -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() +} diff --git a/src/pt/maidscan/build.gradle b/src/pt/maidscan/build.gradle index 167690fc7..915611944 100644 --- a/src/pt/maidscan/build.gradle +++ b/src/pt/maidscan/build.gradle @@ -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 } diff --git a/src/pt/maidscan/src/eu/kanade/tachiyomi/extension/pt/maidscan/MaidScan.kt b/src/pt/maidscan/src/eu/kanade/tachiyomi/extension/pt/maidscan/MaidScan.kt index c20cb1fd1..3d23cad7f 100644 --- a/src/pt/maidscan/src/eu/kanade/tachiyomi/extension/pt/maidscan/MaidScan.kt +++ b/src/pt/maidscan/src/eu/kanade/tachiyomi/extension/pt/maidscan/MaidScan.kt @@ -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 } diff --git a/src/pt/mediocretoons/build.gradle b/src/pt/mediocretoons/build.gradle new file mode 100644 index 000000000..bc30e188e --- /dev/null +++ b/src/pt/mediocretoons/build.gradle @@ -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" diff --git a/src/pt/mediocretoons/res/mipmap-hdpi/ic_launcher.png b/src/pt/mediocretoons/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..ea349814f Binary files /dev/null and b/src/pt/mediocretoons/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/mediocretoons/res/mipmap-mdpi/ic_launcher.png b/src/pt/mediocretoons/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..385c31546 Binary files /dev/null and b/src/pt/mediocretoons/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/mediocretoons/res/mipmap-xhdpi/ic_launcher.png b/src/pt/mediocretoons/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..d63070c64 Binary files /dev/null and b/src/pt/mediocretoons/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/mediocretoons/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/mediocretoons/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..e5be98998 Binary files /dev/null and b/src/pt/mediocretoons/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/mediocretoons/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/mediocretoons/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..ba2306e48 Binary files /dev/null and b/src/pt/mediocretoons/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/mediocretoons/src/eu/kanade/tachiyomi/extension/pt/sussyscan/MediocreToons.kt b/src/pt/mediocretoons/src/eu/kanade/tachiyomi/extension/pt/sussyscan/MediocreToons.kt new file mode 100644 index 000000000..a0cebb03f --- /dev/null +++ b/src/pt/mediocretoons/src/eu/kanade/tachiyomi/extension/pt/sussyscan/MediocreToons.kt @@ -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() +} diff --git a/src/pt/sussyscan/build.gradle b/src/pt/sussyscan/build.gradle index a7b45f736..37b93f942 100644 --- a/src/pt/sussyscan/build.gradle +++ b/src/pt/sussyscan/build.gradle @@ -1,7 +1,9 @@ ext { extName = 'Sussy Toons' extClass = '.SussyToons' - extVersionCode = 53 + themePkg = 'greenshit' + baseUrl = 'https://www.sussytoons.wtf' + overrideVersionCode = 54 isNsfw = true } diff --git a/src/pt/sussyscan/src/eu/kanade/tachiyomi/extension/pt/sussyscan/SussyToons.kt b/src/pt/sussyscan/src/eu/kanade/tachiyomi/extension/pt/sussyscan/SussyToons.kt index d1221729d..f3165a846 100644 --- a/src/pt/sussyscan/src/eu/kanade/tachiyomi/extension/pt/sussyscan/SussyToons.kt +++ b/src/pt/sussyscan/src/eu/kanade/tachiyomi/extension/pt/sussyscan/SussyToons.kt @@ -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().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() - 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>>() - 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>().results.toSManga() - } - - // ============================= Chapters ================================= - - override fun chapterListParse(response: Response): List { - val json = response.parseScriptToJson() ?: return emptyList() - return json.parseAs>().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 { - val document = response.asJsoup() - - pageListParse(document).takeIf(List::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 { - 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("\"$it\"") } - ?: throw Exception("Failed to extract JSON from script") - } - - private fun parseJsonToChapterPageDto(jsonContent: String): ChapterPageDto { - return try { - jsonContent.parseAs>().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) }