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 {
extName = 'Sakura Mangás'
extClass = '.SakuraMangas'
extVersionCode = 1
extVersionCode = 2
isNsfw = true
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(":lib:synchrony"))
}

View File

@ -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<Genre> = emptySet()
private var demographyOptions: List<Pair<String, String>> = 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<List<String>>()
@ -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<SakuraMangasResultDto>()
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<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 =======================================
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<SChapter> {
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<SChapter>()
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<SakuraMangaChapterReadDto>()
val baseUrl = document.baseUri().trimEnd('/')
return response.imageUrls.mapIndexed { index, url ->
Page(
index,
imageUrl = "$baseUrl/$url".toHttpUrl().toString(),
)
}
return vortexDecipherV2(response.imageUrls, subtoken)
.parseAs<List<String>>()
.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))
}
}

View File

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