From d824aa0a17234c553f270ec66f0860218c2eb9ec Mon Sep 17 00:00:00 2001 From: Chopper <156493704+choppeh@users.noreply.github.com> Date: Fri, 26 Sep 2025 02:34:24 -0300 Subject: [PATCH] SakuraMangas: Fix loading content (#10685) * Fix loading content * Remove @RequiresApi --- src/pt/sakuramangas/build.gradle | 6 +- .../extension/pt/sakuramangas/SakuraMangas.kt | 198 ++++++++++++++++-- .../pt/sakuramangas/SakuraMangasDto.kt | 2 +- 3 files changed, 183 insertions(+), 23 deletions(-) diff --git a/src/pt/sakuramangas/build.gradle b/src/pt/sakuramangas/build.gradle index ca190b001..c37322332 100644 --- a/src/pt/sakuramangas/build.gradle +++ b/src/pt/sakuramangas/build.gradle @@ -1,8 +1,12 @@ ext { extName = 'Sakura Mangás' extClass = '.SakuraMangas' - extVersionCode = 1 + extVersionCode = 2 isNsfw = true } apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(":lib:synchrony")) +} diff --git a/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangas.kt b/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangas.kt index a1f1522be..684444551 100644 --- a/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangas.kt +++ b/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangas.kt @@ -1,8 +1,11 @@ package eu.kanade.tachiyomi.extension.pt.sakuramangas +import android.util.Base64 import android.util.Log +import eu.kanade.tachiyomi.lib.synchrony.Deobfuscator import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.interceptor.rateLimit import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage @@ -16,8 +19,12 @@ import okhttp3.FormBody import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Request import okhttp3.Response +import okio.IOException import org.jsoup.Jsoup import org.jsoup.nodes.Element +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest import java.util.Calendar import kotlin.concurrent.thread @@ -30,6 +37,10 @@ class SakuraMangas : HttpSource() { override val baseUrl = "https://sakuramangas.org" + override val client = network.cloudflareClient.newBuilder() + .rateLimit(3, 2) + .build() + private var genresSet: Set = emptySet() private var demographyOptions: List> = listOf( "Todos" to "", @@ -55,7 +66,7 @@ class SakuraMangas : HttpSource() { // ================================ Latest ======================================= override fun latestUpdatesRequest(page: Int): Request = - GET("$baseUrl/dist/sakura/models/home/home_ultimos.php", headers) + GET("$baseUrl/dist/sakura/models/home/__.home_ultimos.php", headers) override fun latestUpdatesParse(response: Response): MangasPage { val result = response.parseAs>() @@ -76,7 +87,7 @@ class SakuraMangas : HttpSource() { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { val form = FormBody.Builder() - .add("seach", query) + .add("search", query) .add("order", "3") .add("offset", ((page - 1) * 15).toString()) .add("limit", "15") @@ -112,7 +123,7 @@ class SakuraMangas : HttpSource() { classification?.let { form.add("classification", it) } orderBy?.let { form.add("order", it) } - return POST("$baseUrl/dist/sakura/models/obras/obras_buscar.php", headers, form.build()) + return POST("$baseUrl/dist/sakura/models/obras/__.obras_buscar.php", headers, form.build()) } fun searchMangaFromElement(element: Element) = SManga.create().apply { @@ -125,8 +136,8 @@ class SakuraMangas : HttpSource() { override fun searchMangaParse(response: Response): MangasPage { val result = response.parseAs() - val seriesList = - result.asJsoup("$baseUrl/obras/").select(".result-item").map(::searchMangaFromElement) + val document = result.asJsoup("$baseUrl/obras/") + val seriesList = document.select(".result-item").map(::searchMangaFromElement) return MangasPage(seriesList, result.hasMore) } @@ -134,42 +145,91 @@ class SakuraMangas : HttpSource() { override fun getMangaUrl(manga: SManga): String = "$baseUrl${manga.url}" - private fun mangaDetailsApiRequest(mangaId: String): Request { + private fun mangaDetailsApiRequest(mangaId: String, challenge: String, token: String): Request { + val proof = generateHeaderProof(challenge, keys.mangaInfo)!! + val form = FormBody.Builder() .add("manga_id", mangaId) .add("dataType", "json") + .add("challenge", challenge) + .add("proof", proof) - return POST("$baseUrl/dist/sakura/models/manga/manga_info.php", headers, form.build()) + val detailsHeaders = headers.newBuilder() + .add("X-Verification-Key-1", keys.xVerificationKey1) + .add("X-Verification-Key-2", keys.xVerificationKey2) + .add("X-CSRF-Token", token) + .build() + + return POST("$baseUrl/dist/sakura/models/manga/__obf__manga_info.php", detailsHeaders, form.build()) } override fun mangaDetailsParse(response: Response): SManga { val document = response.asJsoup() val mangaId = document.selectFirst("meta[manga-id]")!!.attr("manga-id") + val challenge = document.selectFirst("meta[name=header-challenge]")!!.attr("content") + val token = document.selectFirst("meta[name=csrf-token]")!!.attr("content") - return client.newCall(mangaDetailsApiRequest(mangaId)).execute() + return client.newCall(mangaDetailsApiRequest(mangaId, challenge, token)).execute() .parseAs().toSManga(document.baseUri()) } + private val keys: Keys by lazy { + val mangaInfoRegex = """(?:manga_info:\s+)(\d+)""".toRegex() + val chapterReadRegex = """(?:chapter_read:\s+)(\d+)""".toRegex() + val key1Regex = """(?:key1:\s+')([^']+)""".toRegex() + val key2Regex = """(?:key2:\s+')([^']+)""".toRegex() + + val script = client.newCall(GET("$baseUrl/dist/sakura/global/security.obf.js", headers)) + .execute().body.string() + + val deobfuscated = Deobfuscator.deobfuscateScript(script)!! + + Keys( + mangaInfo = mangaInfoRegex.find(deobfuscated)?.groupValues?.last()?.toLong() ?: 0L, + chapterRead = chapterReadRegex.find(deobfuscated)?.groupValues?.last()?.toLong() ?: 0L, + xVerificationKey1 = key1Regex.find(deobfuscated)?.groupValues?.last() ?: "", + xVerificationKey2 = key2Regex.find(deobfuscated)?.groupValues?.last() ?: "", + ) + } + + class Keys( + val mangaInfo: Long, + val chapterRead: Long, + val xVerificationKey1: String, + val xVerificationKey2: String, + ) + // ================================ Chapters ======================================= - private fun chapterListApiRequest(mangaId: String, page: Int): Request { + private fun chapterListApiRequest(mangaId: String, challenge: String, token: String, page: Int): Request { + val proof = generateHeaderProof(challenge, keys.mangaInfo)!! val form = FormBody.Builder() .add("manga_id", mangaId) .add("offset", ((page - 1) * 90).toString()) .add("order", "desc") .add("limit", "90") + .add("challenge", challenge) + .add("proof", proof) - return POST("$baseUrl/dist/sakura/models/manga/manga_capitulos.php", headers, form.build()) + val chapterHeaders = headers.newBuilder() + .add("X-Verification-Key-1", keys.xVerificationKey1) + .add("X-Verification-Key-2", keys.xVerificationKey2) + .add("X-CSRF-Token", token) + .build() + + return POST("$baseUrl/dist/sakura/models/manga/__obf__manga_capitulos.php", chapterHeaders, form.build()) } override fun chapterListParse(response: Response): List { val document = response.asJsoup() val mangaId = document.selectFirst("meta[manga-id]")!!.attr("manga-id") + val challenge = document.selectFirst("meta[name=header-challenge]")!!.attr("content") + val token = document.selectFirst("meta[name=csrf-token]")!!.attr("content") var page = 1 val chapters = mutableListOf() do { - val doc = client.newCall(chapterListApiRequest(mangaId, page++)).execute().asJsoup() + val doc = client.newCall(chapterListApiRequest(mangaId, challenge, token, page++)).execute().asJsoup() val chapterGroup = doc.select(".capitulo-item").map(::chapterFromElement).also { chapters += it @@ -202,14 +262,28 @@ class SakuraMangas : HttpSource() { // ================================ Pages ======================================= - private fun pageListApiRequest(chapterId: String, token: String): Request { + private fun pageListApiRequest( + chapterId: String, + token: String, + challenge: String, + csrf: String, + ): Request { + val proof = generateHeaderProof(challenge, keys.chapterRead)!! val form = FormBody.Builder() .add("chapter_id", chapterId) .add("token", token) + .add("challenge", challenge) + .add("proof", proof) + + val pageHeaders = headers.newBuilder() + .add("X-Verification-Key-1", keys.xVerificationKey1) + .add("X-Verification-Key-2", keys.xVerificationKey2) + .add("X-CSRF-Token", csrf) + .build() return POST( - "$baseUrl/dist/sakura/models/capitulo/capitulos_read.php", - headers, + "$baseUrl/dist/sakura/models/capitulo/__obf.__capltulos_read.php", + pageHeaders, form.build(), ) } @@ -219,18 +293,20 @@ class SakuraMangas : HttpSource() { val chapterId = document.selectFirst("meta[chapter-id]")!!.attr("chapter-id") val token = document.selectFirst("meta[token]")!!.attr("token") + val subtoken = document.selectFirst("meta[token]")!!.attr("subtoken") + val challenge = document.selectFirst("meta[name=header-challenge]")!!.attr("content") + val csrf = document.selectFirst("meta[name=csrf-token]")!!.attr("content") - val response = client.newCall(pageListApiRequest(chapterId, token)).execute() + val response = client.newCall(pageListApiRequest(chapterId, token, challenge, csrf)).execute() .parseAs() val baseUrl = document.baseUri().trimEnd('/') - return response.imageUrls.mapIndexed { index, url -> - Page( - index, - imageUrl = "$baseUrl/$url".toHttpUrl().toString(), - ) - } + return vortexDecipherV2(response.imageUrls, subtoken) + .parseAs>() + .mapIndexed { index, url -> + Page(index, imageUrl = "$baseUrl/$url".toHttpUrl().toString()) + } } override fun imageUrlParse(response: Response): String = "" @@ -319,4 +395,84 @@ class SakuraMangas : HttpSource() { return now.timeInMillis } + + // Function extracted from https://sakuramangas.org/dist/sakura/pages/capitulo/capitulo.v100w.obs.js + private fun generateHeaderProof(base64: String?, key: Long?): String? { + val userAgent = headers["User-Agent"] + if (base64 == null || key == null || userAgent == null) { + return null + } + + return try { + val decoded = String(Base64.decode(base64, Base64.DEFAULT), Charsets.UTF_8) + + val parts = decoded.split('/') + if (parts.size != 3) { + return null + } + + val address = parts.first() + val pathSegment = parts.last() + + var result = address + userAgent + key + pathSegment + + val digest = MessageDigest.getInstance("SHA-256") + repeat(29) { + val data = result.toByteArray(Charsets.UTF_8) + val hashBytes = digest.digest(data) + digest.reset() + result = hashBytes.joinToString("") { byte -> + String.format("%02x", byte) + } + } + result + } catch (_: Exception) { + throw IOException("Falha ao gerar token") + } + } + + // Function extracted from https://sakuramangas.org/dist/sakura/pages/capitulo/capitulo.v100w.obs.js + private fun vortexDecipherV2(dataBase64: String, key: String): String { + try { + val digest = MessageDigest.getInstance("SHA-256").digest(key.toByteArray(Charsets.UTF_8)) + val buffer = ByteBuffer.wrap(digest).order(ByteOrder.LITTLE_ENDIAN) + + var v1 = buffer.int.toUInt() + var v2 = buffer.int.toUInt() + var v3 = buffer.int.toUInt() + var v4 = buffer.int.toUInt() + val c1 = buffer.int.toUInt() + val c2 = buffer.int.toUInt() + + val decoded = Base64.decode(dataBase64, Base64.DEFAULT) + + val output = ByteArray(decoded.size) + + for (i in decoded.indices) { + if (i % 2 == 0) { + v1 = (v1 + 2654435769u) + v2 = (v2 xor c1) + v3 = (v3 + v2) + val shift = (v1 and 31u).toInt() + v4 = (((v4 xor v3).rotateLeft(shift))) + } else { + v3 = (v3 + 1640531527u) + v4 = (v4 xor c2) + v1 = (v1 + v4) + val shift = (v3 and 31u).toInt() + v2 = (((v2 xor v1).rotateLeft(shift))) + } + val mask = (v1 xor v2 xor v3 xor v4).toInt() and 0xFF + output[i] = (decoded[i].toInt() xor mask).toByte() + } + + return output.toString(Charsets.UTF_8) + } catch (e: Exception) { + throw IOException("Não foi possível descriptografar os dados do capítulo.", e) + } + } + + private fun UInt.rotateLeft(bits: Int): UInt { + return (this shl bits) or (this shr (32 - bits)) + } } diff --git a/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangasDto.kt b/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangasDto.kt index 898e0b178..9dcadb837 100644 --- a/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangasDto.kt +++ b/src/pt/sakuramangas/src/eu/kanade/tachiyomi/extension/pt/sakuramangas/SakuraMangasDto.kt @@ -56,5 +56,5 @@ class SakuraMangaInfoDto( @Serializable class SakuraMangaChapterReadDto( - val imageUrls: List, + val imageUrls: String, )