LuraToon: Refactor to use API JSON without scraper (#6029)

* LuraToon: Refactor to use API JSON without scraper

* Bump version code to 46

* LuraToon: fix problems details, search, latest and decrypt zip files images using AES

* LuraToon: fix pagination latest list

* Refactor create lib to zip interceptor and AES decrypt file for LuraToon and PeachScan

* LuraToon: Remove unused code

* LuraToon: fix problem with lint on lura zip interceptor

* Refactor for each list files

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

* Refactor use another method to sort caps

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

* Refactor move code decrypt file from lib CryptoAES to local extension

* Refactor add alert exception if not found list chapters

* Refactor functions to remove redundancy as suggested

* Update version id

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
This commit is contained in:
Samuel Pereira da Silva 2024-11-18 23:57:29 -03:00 committed by GitHub
parent c29330892a
commit 8973569273
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 380 additions and 134 deletions

View File

@ -5,5 +5,5 @@ plugins {
baseVersionCode = 9 baseVersionCode = 9
dependencies { dependencies {
compileOnly("com.github.tachiyomiorg:image-decoder:e08e9be535") implementation(project(":lib:zipinterceptor"))
} }

View File

@ -1,12 +1,7 @@
package eu.kanade.tachiyomi.multisrc.peachscan package eu.kanade.tachiyomi.multisrc.peachscan
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.ActivityManager import eu.kanade.tachiyomi.lib.zipinterceptor.ZipInterceptor
import android.app.Application
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import android.util.Base64
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
@ -21,23 +16,16 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import rx.Observable import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
import java.util.zip.ZipInputStream
@SuppressLint("WrongConstant") @SuppressLint("WrongConstant")
abstract class PeachScan( abstract class PeachScan(
@ -53,7 +41,7 @@ abstract class PeachScan(
override val client = network.cloudflareClient override val client = network.cloudflareClient
.newBuilder() .newBuilder()
.addInterceptor(::zipImageInterceptor) .addInterceptor(ZipInterceptor()::zipImageInterceptor)
.build() .build()
private val json: Json by injectLazy() private val json: Json by injectLazy()
@ -192,90 +180,6 @@ abstract class PeachScan(
return GET(page.imageUrl!!, imgHeaders) return GET(page.imageUrl!!, imgHeaders)
} }
private val dataUriRegex = Regex("""base64,([0-9a-zA-Z/+=\s]+)""")
private fun zipImageInterceptor(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
val filename = request.url.pathSegments.last()
if (request.url.fragment != "page" || !filename.contains(".zip")) {
return response
}
val zis = ZipInputStream(response.body.byteStream())
val images = generateSequence { zis.nextEntry }
.mapNotNull {
val entryName = it.name
val splitEntryName = entryName.split('.')
val entryIndex = splitEntryName.first().toInt()
val entryType = splitEntryName.last()
val imageData = if (entryType == "avif" || splitEntryName.size == 1) {
zis.readBytes()
} else {
val svgBytes = zis.readBytes()
val svgContent = svgBytes.toString(Charsets.UTF_8)
val b64 = dataUriRegex.find(svgContent)?.groupValues?.get(1)
?: return@mapNotNull null
Base64.decode(b64, Base64.DEFAULT)
}
entryIndex to PeachScanUtils.decodeImage(imageData, isLowRamDevice, filename, entryName)
}
.sortedBy { it.first }
.toList()
zis.closeEntry()
zis.close()
val totalWidth = images.maxOf { it.second.width }
val totalHeight = images.sumOf { it.second.height }
val result = Bitmap.createBitmap(totalWidth, totalHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
var dy = 0
images.forEach {
val srcRect = Rect(0, 0, it.second.width, it.second.height)
val dstRect = Rect(0, dy, it.second.width, dy + it.second.height)
canvas.drawBitmap(it.second, srcRect, dstRect, null)
dy += it.second.height
}
val output = ByteArrayOutputStream()
result.compress(Bitmap.CompressFormat.JPEG, 90, output)
val image = output.toByteArray()
val body = image.toResponseBody("image/jpeg".toMediaType())
return response.newBuilder()
.body(body)
.build()
}
/**
* ActivityManager#isLowRamDevice is based on a system property, which isn't
* necessarily trustworthy. 1GB is supposedly the regular threshold.
*
* Instead, we consider anything with less than 3GB of RAM as low memory
* considering how heavy image processing can be.
*/
private val isLowRamDevice by lazy {
val ctx = Injekt.get<Application>()
val activityManager = ctx.getSystemService("activity") as ActivityManager
val memInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memInfo)
memInfo.totalMem < 3L * 1024 * 1024 * 1024
}
companion object { companion object {
const val URL_SEARCH_PREFIX = "slug:" const val URL_SEARCH_PREFIX = "slug:"
} }

View File

@ -5,6 +5,7 @@ import android.util.Base64
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Arrays import java.util.Arrays
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec

View File

@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
compileOnly("com.github.tachiyomiorg:image-decoder:e08e9be535")
}

View File

@ -1,28 +1,33 @@
package eu.kanade.tachiyomi.multisrc.peachscan package eu.kanade.tachiyomi.lib.zipinterceptor
import android.app.ActivityManager
import android.app.Application
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
import android.util.Base64
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import tachiyomi.decoder.ImageDecoder import tachiyomi.decoder.ImageDecoder
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.lang.reflect.Method import java.lang.reflect.Method
import java.util.zip.ZipInputStream
/** open class ZipInterceptor {
* TachiyomiJ2K is on a 2-year-old version of ImageDecoder at the time of writing,
* with a different signature than the one being used as a compile-only dependency.
*
* Because of this, if [ImageDecoder.decode] is called as-is on TachiyomiJ2K, we
* end up with a [NoSuchMethodException].
*
* This is a hack for determining which signature to call when decoding images.
*/
object PeachScanUtils {
private var decodeMethod: Method private var decodeMethod: Method
private var newInstanceMethod: Method private var newInstanceMethod: Method
private var classSignature = ClassSignature.Newest private var classSignature = ClassSignature.Newest
private val dataUriRegex = Regex("""base64,([0-9a-zA-Z/+=\s]+)""")
private enum class ClassSignature { private enum class ClassSignature {
Old, New, Newest Old, New, Newest
} }
@ -121,4 +126,95 @@ object PeachScanUtils {
return bitmap return bitmap
} }
open fun zipGetByteStream(request: Request, response: Response): InputStream {
return response.body.byteStream()
}
open fun requestIsZipImage(request: Request): Boolean {
return request.url.fragment == "page" && request.url.pathSegments.last().contains(".zip")
}
fun zipImageInterceptor(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
val filename = request.url.pathSegments.last()
if (requestIsZipImage(request).not()) {
return response
}
val zis = ZipInputStream(zipGetByteStream(request, response))
val images = generateSequence { zis.nextEntry }
.mapNotNull {
val entryName = it.name
val splitEntryName = entryName.split('.')
val entryIndex = splitEntryName.first().toInt()
val entryType = splitEntryName.last()
val imageData = if (entryType == "avif" || splitEntryName.size == 1) {
zis.readBytes()
} else {
val svgBytes = zis.readBytes()
val svgContent = svgBytes.toString(Charsets.UTF_8)
val b64 = dataUriRegex.find(svgContent)?.groupValues?.get(1)
?: return@mapNotNull null
Base64.decode(b64, Base64.DEFAULT)
}
entryIndex to decodeImage(imageData, isLowRamDevice, filename, entryName)
}
.sortedBy { it.first }
.toList()
zis.closeEntry()
zis.close()
val totalWidth = images.maxOf { it.second.width }
val totalHeight = images.sumOf { it.second.height }
val result = Bitmap.createBitmap(totalWidth, totalHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
var dy = 0
images.forEach {
val srcRect = Rect(0, 0, it.second.width, it.second.height)
val dstRect = Rect(0, dy, it.second.width, dy + it.second.height)
canvas.drawBitmap(it.second, srcRect, dstRect, null)
dy += it.second.height
}
val output = ByteArrayOutputStream()
result.compress(Bitmap.CompressFormat.JPEG, 90, output)
val image = output.toByteArray()
val body = image.toResponseBody("image/jpeg".toMediaType())
return response.newBuilder()
.body(body)
.build()
}
/**
* ActivityManager#isLowRamDevice is based on a system property, which isn't
* necessarily trustworthy. 1GB is supposedly the regular threshold.
*
* Instead, we consider anything with less than 3GB of RAM as low memory
* considering how heavy image processing can be.
*/
private val isLowRamDevice by lazy {
val ctx = Injekt.get<Application>()
val activityManager = ctx.getSystemService("activity") as ActivityManager
val memInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memInfo)
memInfo.totalMem < 3L * 1024 * 1024 * 1024
}
} }

View File

@ -1,13 +1,13 @@
ext { ext {
extName = 'Lura Toon' extName = 'Lura Toon'
extClass = '.LuraToon' extClass = '.LuraToon'
themePkg = 'peachscan'
baseUrl = 'https://luratoons.com' baseUrl = 'https://luratoons.com'
overrideVersionCode = 45 extVersionCode = 55
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"
dependencies { dependencies {
implementation project(':lib:randomua') implementation project(':lib:randomua')
implementation project(':lib:zipinterceptor')
} }

View File

@ -3,33 +3,55 @@ package eu.kanade.tachiyomi.extension.pt.randomscan
import android.app.Application import android.app.Application
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.extension.pt.randomscan.dto.Capitulo
import eu.kanade.tachiyomi.extension.pt.randomscan.dto.CapituloPagina
import eu.kanade.tachiyomi.extension.pt.randomscan.dto.MainPage
import eu.kanade.tachiyomi.extension.pt.randomscan.dto.Manga
import eu.kanade.tachiyomi.extension.pt.randomscan.dto.SearchResponse
import eu.kanade.tachiyomi.lib.randomua.addRandomUAPreferenceToScreen import eu.kanade.tachiyomi.lib.randomua.addRandomUAPreferenceToScreen
import eu.kanade.tachiyomi.lib.randomua.getPrefCustomUA import eu.kanade.tachiyomi.lib.randomua.getPrefCustomUA
import eu.kanade.tachiyomi.lib.randomua.getPrefUAType import eu.kanade.tachiyomi.lib.randomua.getPrefUAType
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
import eu.kanade.tachiyomi.multisrc.peachscan.PeachScan import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservable
import eu.kanade.tachiyomi.network.interceptor.rateLimit import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource 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.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Element import rx.Observable
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
import kotlin.getValue
class LuraToon : class LuraToon : HttpSource(), ConfigurableSource {
PeachScan( override val baseUrl = "https://luratoons.com"
"Lura Toon", override val name = "Lura Toon"
"https://luratoons.com", override val lang = "pt-BR"
"pt-BR", override val supportsLatest = true
), override val versionId = 2
ConfigurableSource {
private val json: Json by injectLazy()
private val preferences: SharedPreferences by lazy { private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
} }
override val client = super.client.newBuilder() override val client = network.cloudflareClient
.newBuilder()
.addInterceptor(::loggedVerifyInterceptor)
.addInterceptor(LuraZipInterceptor()::zipImageInterceptor)
.rateLimit(3) .rateLimit(3)
.setRandomUserAgent( .setRandomUserAgent(
preferences.getPrefUAType(), preferences.getPrefUAType(),
@ -37,27 +59,133 @@ class LuraToon :
) )
.build() .build()
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/api/main/?part=${page - 1}", headers)
override fun popularMangaRequest(page: Int) = GET("$baseUrl/api/main/?part=${page - 1}", headers)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = GET("$baseUrl/api/autocomplete/$query", headers)
override fun chapterListRequest(manga: SManga) = GET("$baseUrl/api/obra/${manga.url.trimStart('/')}", headers)
override fun mangaDetailsRequest(manga: SManga) = chapterListRequest(manga)
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
addRandomUAPreferenceToScreen(screen) addRandomUAPreferenceToScreen(screen)
} }
override fun chapterFromElement(element: Element): SChapter { override fun mangaDetailsParse(response: Response) = SManga.create().apply {
val mangaUrl = element.ownerDocument()!!.location() val data = response.parseAs<Manga>()
title = data.titulo
return super.chapterFromElement(element).apply { author = data.autor
val num = url.removeSuffix("/") artist = data.artista
.substringAfterLast("/") genre = data.generos.joinToString(", ") { it.name }
val chapUrl = mangaUrl.removeSuffix("/") + "/$num/" status = when (data.status) {
"Em Lançamento" -> SManga.ONGOING
setUrlWithoutDomain(chapUrl) "Finalizado" -> SManga.COMPLETED
else -> SManga.UNKNOWN
} }
thumbnail_url = "$baseUrl${data.capa}"
val category = data.tipo
val synopsis = data.sinopse
description = "Tipo: $category\n\n$synopsis"
}
private inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString<T>(body.string())
}
override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.parseAs<MainPage>()
val mangas = document.lancamentos.map {
SManga.create().apply {
title = it.title
thumbnail_url = "$baseUrl${it.capa}"
setUrlWithoutDomain("/${it.slug}/")
}
}
return MangasPage(mangas, document.lancamentos.isNotEmpty())
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return client.newCall(chapterListRequest(manga))
.asObservable()
.map { response ->
chapterListParse(manga, response)
}
}
fun chapterListParse(manga: SManga, response: Response): List<SChapter> {
if (response.code == 404) {
throw Exception("Capitulos não encontrados, tente migrar o manga, alguns nomes da LuraToon mudaram")
}
val comics = response.parseAs<Manga>()
return comics.caps.sortedByDescending {
it.num
}.map { chapterFromElement(manga, it) }
}
private fun chapterFromElement(manga: SManga, capitulo: Capitulo) = SChapter.create().apply {
val capSlug = capitulo.slug.trimStart('/')
val mangaUrl = manga.url.trimEnd('/').trimStart('/')
setUrlWithoutDomain("/api/obra/$mangaUrl/$capSlug")
name = capitulo.num.toString().removeSuffix(".0")
date_upload = runCatching {
dateFormat.parse(capitulo.data)!!.time
}.getOrDefault(0L)
} }
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
val capitulo = response.parseAs<CapituloPagina>()
val pathSegments = response.request.url.pathSegments val pathSegments = response.request.url.pathSegments
if (pathSegments.contains("login") || pathSegments.isEmpty()) { return (0 until capitulo.files).map { i ->
Page(i, baseUrl, "$baseUrl/api/cap-download/${capitulo.obra.id}/${capitulo.id}/$i?obra_id=${capitulo.obra.id}&cap_id=${capitulo.id}&slug=${pathSegments[2]}&cap_slug=${pathSegments[3]}")
}
}
override fun searchMangaParse(response: Response): MangasPage {
val mangas = response.parseAs<SearchResponse>().obras.map {
SManga.create().apply {
title = it.titulo
thumbnail_url = "$baseUrl${it.capa}"
setUrlWithoutDomain("/${it.slug}/")
}
}
return MangasPage(mangas, false)
}
override fun popularMangaParse(response: Response): MangasPage {
val document = response.parseAs<MainPage>()
val mangas = document.top_10.map {
SManga.create().apply {
title = it.title
thumbnail_url = "$baseUrl${it.capa}"
setUrlWithoutDomain("/${it.slug}/")
}
}
return MangasPage(mangas, false)
}
private fun loggedVerifyInterceptor(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
val pathSegments = response.request.url.pathSegments
if (response.request.url.pathSegments.contains("login") || pathSegments.isEmpty()) {
throw Exception("Faça o login na WebView para acessar o contéudo") throw Exception("Faça o login na WebView para acessar o contéudo")
} }
return super.pageListParse(response) if (response.code == 429) {
throw Exception("A LuraToon lhe bloqueou por acessar rápido demais, aguarde por volta de 1 minuto e tente novamente")
} }
return response
}
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).apply {
timeZone = TimeZone.getTimeZone("America/Sao_Paulo")
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
override fun chapterListParse(response: Response) = throw UnsupportedOperationException()
} }

View File

@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.extension.pt.randomscan
import eu.kanade.tachiyomi.lib.zipinterceptor.ZipInterceptor
import okhttp3.Request
import okhttp3.Response
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class LuraZipInterceptor : ZipInterceptor() {
fun decryptFile(encryptedData: ByteArray, keyBytes: ByteArray): ByteArray {
val keyHash = MessageDigest.getInstance("SHA-256").digest(keyBytes)
val key: SecretKey = SecretKeySpec(keyHash, "AES")
val counter = encryptedData.copyOfRange(0, 8)
val iv = IvParameterSpec(counter)
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, key, iv)
val decryptedData = cipher.doFinal(encryptedData.copyOfRange(8, encryptedData.size))
return decryptedData
}
override fun requestIsZipImage(request: Request): Boolean {
return request.url.pathSegments.contains("cap-download")
}
override fun zipGetByteStream(request: Request, response: Response): InputStream {
val keyData = listOf("obra_id", "slug", "cap_id", "cap_slug").joinToString("") {
request.url.queryParameterValues(it).first().toString()
}.toByteArray(StandardCharsets.UTF_8)
val encryptedData = response.body.bytes()
val decryptedData = decryptFile(encryptedData, keyData)
return ByteArrayInputStream(decryptedData)
}
}

View File

@ -0,0 +1,65 @@
package eu.kanade.tachiyomi.extension.pt.randomscan.dto
import kotlinx.serialization.Serializable
@Serializable
data class Genero(
val name: String,
)
@Serializable
data class Capitulo(
val num: Double,
val data: String,
val slug: String,
)
@Serializable
data class Manga(
val capa: String,
val titulo: String,
val autor: String?,
val artista: String?,
val status: String,
val sinopse: String,
val tipo: String,
val generos: List<Genero>,
val caps: List<Capitulo>,
)
@Serializable
data class Obra(
val id: Int,
)
@Serializable
data class CapituloPagina(
val id: Int,
val obra: Obra,
val files: Int,
)
@Serializable
data class MainPageManga(
val title: String,
val capa: String,
val slug: String,
)
@Serializable
data class MainPage(
val lancamentos: List<MainPageManga>,
val top_10: List<MainPageManga>,
)
@Serializable
data class SearchResponseManga(
val titulo: String,
val capa: String,
val slug: String,
)
@Serializable
data class SearchResponse(
val obras: List<SearchResponseManga>,
)