SakuraMangas: Fix loading content (#10685)

* Fix loading content

* Remove @RequiresApi
This commit is contained in:
Chopper 2025-09-26 02:34:24 -03:00 committed by Draff
parent d22070e11c
commit d824aa0a17
Signed by: Draff
GPG Key ID: E8A89F3211677653
3 changed files with 183 additions and 23 deletions

View File

@ -1,8 +1,12 @@
ext { ext {
extName = 'Sakura Mangás' extName = 'Sakura Mangás'
extClass = '.SakuraMangas' extClass = '.SakuraMangas'
extVersionCode = 1 extVersionCode = 2
isNsfw = true isNsfw = true
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(":lib:synchrony"))
}

View File

@ -1,8 +1,11 @@
package eu.kanade.tachiyomi.extension.pt.sakuramangas package eu.kanade.tachiyomi.extension.pt.sakuramangas
import android.util.Base64
import android.util.Log import android.util.Log
import eu.kanade.tachiyomi.lib.synchrony.Deobfuscator
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST 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.Filter
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
@ -16,8 +19,12 @@ import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okio.IOException
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
import java.util.Calendar import java.util.Calendar
import kotlin.concurrent.thread import kotlin.concurrent.thread
@ -30,6 +37,10 @@ class SakuraMangas : HttpSource() {
override val baseUrl = "https://sakuramangas.org" override val baseUrl = "https://sakuramangas.org"
override val client = network.cloudflareClient.newBuilder()
.rateLimit(3, 2)
.build()
private var genresSet: Set<Genre> = emptySet() private var genresSet: Set<Genre> = emptySet()
private var demographyOptions: List<Pair<String, String>> = listOf( private var demographyOptions: List<Pair<String, String>> = listOf(
"Todos" to "", "Todos" to "",
@ -55,7 +66,7 @@ class SakuraMangas : HttpSource() {
// ================================ Latest ======================================= // ================================ Latest =======================================
override fun latestUpdatesRequest(page: Int): Request = 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 { override fun latestUpdatesParse(response: Response): MangasPage {
val result = response.parseAs<List<String>>() val result = response.parseAs<List<String>>()
@ -76,7 +87,7 @@ class SakuraMangas : HttpSource() {
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val form = FormBody.Builder() val form = FormBody.Builder()
.add("seach", query) .add("search", query)
.add("order", "3") .add("order", "3")
.add("offset", ((page - 1) * 15).toString()) .add("offset", ((page - 1) * 15).toString())
.add("limit", "15") .add("limit", "15")
@ -112,7 +123,7 @@ class SakuraMangas : HttpSource() {
classification?.let { form.add("classification", it) } classification?.let { form.add("classification", it) }
orderBy?.let { form.add("order", 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 { fun searchMangaFromElement(element: Element) = SManga.create().apply {
@ -125,8 +136,8 @@ class SakuraMangas : HttpSource() {
override fun searchMangaParse(response: Response): MangasPage { override fun searchMangaParse(response: Response): MangasPage {
val result = response.parseAs<SakuraMangasResultDto>() val result = response.parseAs<SakuraMangasResultDto>()
val seriesList = val document = result.asJsoup("$baseUrl/obras/")
result.asJsoup("$baseUrl/obras/").select(".result-item").map(::searchMangaFromElement) val seriesList = document.select(".result-item").map(::searchMangaFromElement)
return MangasPage(seriesList, result.hasMore) return MangasPage(seriesList, result.hasMore)
} }
@ -134,42 +145,91 @@ class SakuraMangas : HttpSource() {
override fun getMangaUrl(manga: SManga): String = "$baseUrl${manga.url}" 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() val form = FormBody.Builder()
.add("manga_id", mangaId) .add("manga_id", mangaId)
.add("dataType", "json") .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 { override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup() val document = response.asJsoup()
val mangaId = document.selectFirst("meta[manga-id]")!!.attr("manga-id") 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<SakuraMangaInfoDto>().toSManga(document.baseUri()) .parseAs<SakuraMangaInfoDto>().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 ======================================= // ================================ 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() val form = FormBody.Builder()
.add("manga_id", mangaId) .add("manga_id", mangaId)
.add("offset", ((page - 1) * 90).toString()) .add("offset", ((page - 1) * 90).toString())
.add("order", "desc") .add("order", "desc")
.add("limit", "90") .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<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup() val document = response.asJsoup()
val mangaId = document.selectFirst("meta[manga-id]")!!.attr("manga-id") 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 var page = 1
val chapters = mutableListOf<SChapter>() val chapters = mutableListOf<SChapter>()
do { 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 { val chapterGroup = doc.select(".capitulo-item").map(::chapterFromElement).also {
chapters += it chapters += it
@ -202,14 +262,28 @@ class SakuraMangas : HttpSource() {
// ================================ Pages ======================================= // ================================ 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() val form = FormBody.Builder()
.add("chapter_id", chapterId) .add("chapter_id", chapterId)
.add("token", token) .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( return POST(
"$baseUrl/dist/sakura/models/capitulo/capitulos_read.php", "$baseUrl/dist/sakura/models/capitulo/__obf.__capltulos_read.php",
headers, pageHeaders,
form.build(), form.build(),
) )
} }
@ -219,18 +293,20 @@ class SakuraMangas : HttpSource() {
val chapterId = document.selectFirst("meta[chapter-id]")!!.attr("chapter-id") val chapterId = document.selectFirst("meta[chapter-id]")!!.attr("chapter-id")
val token = document.selectFirst("meta[token]")!!.attr("token") 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<SakuraMangaChapterReadDto>() .parseAs<SakuraMangaChapterReadDto>()
val baseUrl = document.baseUri().trimEnd('/') val baseUrl = document.baseUri().trimEnd('/')
return response.imageUrls.mapIndexed { index, url -> return vortexDecipherV2(response.imageUrls, subtoken)
Page( .parseAs<List<String>>()
index, .mapIndexed { index, url ->
imageUrl = "$baseUrl/$url".toHttpUrl().toString(), Page(index, imageUrl = "$baseUrl/$url".toHttpUrl().toString())
) }
}
} }
override fun imageUrlParse(response: Response): String = "" override fun imageUrlParse(response: Response): String = ""
@ -319,4 +395,84 @@ class SakuraMangas : HttpSource() {
return now.timeInMillis 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))
}
} }

View File

@ -56,5 +56,5 @@ class SakuraMangaInfoDto(
@Serializable @Serializable
class SakuraMangaChapterReadDto( class SakuraMangaChapterReadDto(
val imageUrls: List<String>, val imageUrls: String,
) )