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
dependencies {
compileOnly("com.github.tachiyomiorg:image-decoder:e08e9be535")
implementation(project(":lib:zipinterceptor"))
}

View File

@ -1,12 +1,7 @@
package eu.kanade.tachiyomi.multisrc.peachscan
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.app.Application
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import android.util.Base64
import eu.kanade.tachiyomi.lib.zipinterceptor.ZipInterceptor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
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.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
import java.util.zip.ZipInputStream
@SuppressLint("WrongConstant")
abstract class PeachScan(
@ -53,7 +41,7 @@ abstract class PeachScan(
override val client = network.cloudflareClient
.newBuilder()
.addInterceptor(::zipImageInterceptor)
.addInterceptor(ZipInterceptor()::zipImageInterceptor)
.build()
private val json: Json by injectLazy()
@ -192,90 +180,6 @@ abstract class PeachScan(
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 {
const val URL_SEARCH_PREFIX = "slug:"
}

View File

@ -5,6 +5,7 @@ import android.util.Base64
import java.security.MessageDigest
import java.util.Arrays
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
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.Canvas
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 uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.lang.reflect.Method
import java.util.zip.ZipInputStream
/**
* 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 {
open class ZipInterceptor {
private var decodeMethod: Method
private var newInstanceMethod: Method
private var classSignature = ClassSignature.Newest
private val dataUriRegex = Regex("""base64,([0-9a-zA-Z/+=\s]+)""")
private enum class ClassSignature {
Old, New, Newest
}
@ -121,4 +126,95 @@ object PeachScanUtils {
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 {
extName = 'Lura Toon'
extClass = '.LuraToon'
themePkg = 'peachscan'
baseUrl = 'https://luratoons.com'
overrideVersionCode = 45
extVersionCode = 55
}
apply from: "$rootDir/common.gradle"
dependencies {
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.content.SharedPreferences
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.getPrefCustomUA
import eu.kanade.tachiyomi.lib.randomua.getPrefUAType
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.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 kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Response
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.Injekt
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 :
PeachScan(
"Lura Toon",
"https://luratoons.com",
"pt-BR",
),
ConfigurableSource {
class LuraToon : HttpSource(), ConfigurableSource {
override val baseUrl = "https://luratoons.com"
override val name = "Lura Toon"
override val lang = "pt-BR"
override val supportsLatest = true
override val versionId = 2
private val json: Json by injectLazy()
private val preferences: SharedPreferences by lazy {
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)
.setRandomUserAgent(
preferences.getPrefUAType(),
@ -37,27 +59,133 @@ class LuraToon :
)
.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) {
addRandomUAPreferenceToScreen(screen)
}
override fun chapterFromElement(element: Element): SChapter {
val mangaUrl = element.ownerDocument()!!.location()
return super.chapterFromElement(element).apply {
val num = url.removeSuffix("/")
.substringAfterLast("/")
val chapUrl = mangaUrl.removeSuffix("/") + "/$num/"
setUrlWithoutDomain(chapUrl)
override fun mangaDetailsParse(response: Response) = SManga.create().apply {
val data = response.parseAs<Manga>()
title = data.titulo
author = data.autor
artist = data.artista
genre = data.generos.joinToString(", ") { it.name }
status = when (data.status) {
"Em Lançamento" -> SManga.ONGOING
"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> {
val capitulo = response.parseAs<CapituloPagina>()
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")
}
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>,
)