ReadMangas: Fix loading content (#8239)

* Fix details, chapter and page

* Fix popular and latest

* Fix search

* Add change suggestion
This commit is contained in:
Chopper 2025-03-27 17:08:38 -03:00 committed by Draff
parent 5de9ae2485
commit fa09f8122d
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
3 changed files with 227 additions and 148 deletions

View File

@ -1,7 +1,7 @@
ext {
extName = 'Read Mangas'
extClass = '.ReadMangas'
extVersionCode = 38
extVersionCode = 39
}
apply from: "$rootDir/common.gradle"

View File

@ -1,9 +1,13 @@
package eu.kanade.tachiyomi.extension.pt.readmangas
import android.annotation.SuppressLint
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import app.cash.quickjs.QuickJs
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.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
@ -11,79 +15,70 @@ import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.TimeUnit
class ReadMangas() : HttpSource() {
override val name = "Read Mangas"
override val baseUrl = "https://app.loobyt.com"
override val baseUrl = "https://mangalivre.one"
override val lang = "pt-BR"
override val supportsLatest = true
private val json: Json by injectLazy()
override val client = network.cloudflareClient.newBuilder()
.readTimeout(2, TimeUnit.MINUTES)
.rateLimit(1, 2)
.build()
override val versionId = 2
private val application: Application = Injekt.get<Application>()
// =========================== Popular ================================
private var popularNextCursorPage = ""
private val popularMangaToken: String by lazy {
getToken(popularMangaRequest(1), "script[src*='projects/page-']")
}
override fun popularMangaRequest(page: Int): Request {
val url = "$baseUrl/projects"
if (page == 1) {
popularNextCursorPage = ""
return GET(url, headers)
}
val input = buildJsonObject {
put(
"0",
val payload = buildJsonArray {
add(
buildJsonObject {
put(
"json",
buildJsonObject {
put("direction", "forward")
if (popularNextCursorPage.isNotBlank()) {
put("cursor", popularNextCursorPage)
}
},
)
put("cursor", popularNextCursorPage)
},
)
}
val url = "$baseUrl/api/deprecated/manga.getAllManga?batch=1".toHttpUrl().newBuilder()
.addQueryParameter("batch", "1")
.addQueryParameter("input", input.toString())
val newHeaders = headers.newBuilder()
.set("Next-Action", popularMangaToken)
.build()
return GET(url, headers)
return POST(url, newHeaders, payload.toRequestBody())
}
override fun popularMangaParse(response: Response): MangasPage {
val (mangaPage, nextCursor) = mangasPageParse(response)
val (mangaPage, nextCursor) = response.mangasPageParse<PopularResultDto>()
popularNextCursorPage = nextCursor
return mangaPage
}
@ -92,59 +87,57 @@ class ReadMangas() : HttpSource() {
private var latestNextCursorPage = ""
override fun latestUpdatesRequest(page: Int): Request {
if (page == 1) {
latestNextCursorPage = Date().let { latestUpdateDateFormat.format(it) }
}
private val latestMangaToken: String by lazy {
getToken(latestUpdatesRequest(1), "script[src*='updates/page-']")
}
val input = buildJsonObject {
put(
"0",
override fun latestUpdatesRequest(page: Int): Request {
val url = "$baseUrl/updates"
if (page == 1) {
return GET(url, headers)
}
val payload = buildJsonArray {
add(
buildJsonObject {
put(
"json",
buildJsonObject {
put("direction", "forward")
put("limit", 20)
put("cursor", latestNextCursorPage)
},
)
put("limit", 20)
put("cursor", latestNextCursorPage)
},
)
}
val url = "$baseUrl/api/deprecated/discover.updated".toHttpUrl().newBuilder()
.addQueryParameter("batch", "1")
.addQueryParameter("input", input.toString())
val newHeaders = headers.newBuilder()
.set("Next-Action", latestMangaToken)
.build()
return GET(url, headers)
return POST(url, newHeaders, payload.toRequestBody())
}
override fun latestUpdatesParse(response: Response): MangasPage {
val (mangaPage, nextCursor) = mangasPageParse(response)
val (mangaPage, nextCursor) = response.mangasPageParse<LatestResultDto>()
latestNextCursorPage = nextCursor
return mangaPage
}
// =========================== Search =================================
private val searchMangaToken: String by lazy {
getToken(latestUpdatesRequest(1), "script[src*='app/layout-']")
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/api/deprecated/discover.search?batch=1"
val payload = buildJsonObject {
put(
"0",
val payload = buildJsonArray {
add(
buildJsonObject {
put(
"json",
buildJsonObject {
put("name", query)
},
)
put("name", query)
},
)
}.toString().toRequestBody("application/json".toMediaType())
}
return POST(url, headers, payload)
val newHeaders = headers.newBuilder()
.set("Next-Action", searchMangaToken)
.build()
return POST(baseUrl, newHeaders, payload.toRequestBody())
}
override fun searchMangaParse(response: Response) = latestUpdatesParse(response)
@ -152,23 +145,15 @@ class ReadMangas() : HttpSource() {
// =========================== Details =================================
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
return SManga.create().apply {
title = document.selectFirst("h1")!!.text()
thumbnail_url = document.selectFirst("img.w-full")?.absUrl("src")
genre = document.select("div > label + div > div").joinToString { it.text() }
description = document.select("script").map { it.data() }
.firstOrNull { it.contains("description", ignoreCase = true) }
?.let {
val jsonObject = JSONObject(it)
jsonObject.optString("description", "")
}
document.selectFirst("div.flex > div.inline-flex.items-center:last-child")?.text()?.let {
status = it.toStatus()
val json = response.parseScriptToJson()!!
return with(json.parseAs<MangaDetailsDto>()) {
SManga.create().apply {
title = details.title
thumbnail_url = details.thumbnailUrl
description = details.description
genre = details.genres.joinToString { it.name }
status = details.status.toStatus()
url = "/title/$slug#${details.id}"
}
}
}
@ -179,62 +164,63 @@ class ReadMangas() : HttpSource() {
override fun chapterListParse(response: Response) = throw UnsupportedOperationException()
private fun chapterListRequest(manga: SManga, page: Int): Request {
private fun chapterListRequest(manga: SManga, page: Int, chapterToken: String): Request {
val id = manga.url.substringAfterLast("#")
val input = buildJsonObject {
put(
"0",
val payload = buildJsonArray {
add(
buildJsonObject {
put(
"json",
buildJsonObject {
put("id", id)
put("page", page)
put("limit", 50)
put("sort", "desc")
put("search", "")
},
)
put("id", id)
put("page", page)
put("limit", 50)
put("sort", "desc")
put("search", "\$undefined")
},
)
}
val url = "$baseUrl/api/deprecated/chapter.publicAllChapters".toHttpUrl().newBuilder()
.addQueryParameter("batch", "1")
.addQueryParameter("input", input.toString())
val newHeaders = headers.newBuilder()
.set("Next-Action", chapterToken)
.build()
val encodedUrl = URLEncoder.encode(manga.url.substringBeforeLast("#"), "UTF-8")
val apiHeaders = headers.newBuilder()
.set("Referer", "$baseUrl$encodedUrl")
.set("Content-Type", "application/json")
.set("Cache-Control", "no-cache")
.build()
return GET(url, apiHeaders)
return POST("$baseUrl/title/$id", newHeaders, payload.toRequestBody())
}
private fun findChapterToken(manga: SManga): String {
var response = client.newCall(super.chapterListRequest(manga)).execute()
val document = response.asJsoup()
val scriptUlr = document.selectFirst("""script[src*="%5Boid%5D/page"]""")
?.absUrl("src")
?: throw Exception("Token não encontrado")
response = client.newCall(GET(scriptUlr, headers)).execute()
return TOKEN_REGEX.find(response.body.string())?.groups?.get(1)?.value
?: throw Exception("Não foi possivel obter token")
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val chapterToken = findChapterToken(manga)
val chapters = mutableListOf<SChapter>()
var page = 1
do {
val response = tryFetchChapterPage(manga, page++)
val dto = response
.parseAs<List<WrapperResult<ChapterListDto>>>()
.firstNotNullOf { it.result }
.data.json
chapters += chapterListParse(dto.chapters)
} while (dto.hasNext())
try {
do {
val response = tryFetchChapterPage(manga, page++, chapterToken)
val json = CHAPTERS_REGEX.find(response.body.string())?.groups?.get(0)?.value!!
val dto = json.parseAs<ChapterListDto>()
chapters += chapterListParse(dto.chapters)
} while (dto.hasNext())
} catch (e: Exception) {
showToast(e.message!!)
}
return Observable.just(chapters)
}
private val attempts = 3
private val attempts = 2
private fun tryFetchChapterPage(manga: SManga, page: Int): Response {
private fun tryFetchChapterPage(manga: SManga, page: Int, chapterToken: String): Response {
repeat(attempts) { index ->
try { return client.newCall(this.chapterListRequest(manga, page)).execute() } catch (e: Exception) { /* do nothing */ }
try { return client.newCall(this.chapterListRequest(manga, page, chapterToken)).execute() } catch (e: Exception) { /* do nothing */ }
}
throw Exception("Não foi possivel obter os capitulos da página: $page")
}
@ -244,7 +230,7 @@ class ReadMangas() : HttpSource() {
SChapter.create().apply {
name = it.title
chapter_number = it.number.toFloat()
date_upload = it.createdAt.toDate()
date_upload = dateFormat.tryParse(it.createdAt)
url = "/readme/${it.id}"
}
}
@ -266,11 +252,19 @@ class ReadMangas() : HttpSource() {
// =========================== Utilities ===============================
private fun mangasPageParse(response: Response): Pair<MangasPage, String> {
val dto = response.parseAs<List<WrapperResult<MangaListDto>>>().first()
val data = dto.result?.data?.json ?: return MangasPage(emptyList(), false) to ""
private inline fun <reified T : ResultDto> Response.mangasPageParse(): Pair<MangasPage, String> {
val json = when (request.method) {
"GET" -> parseScriptToJson()
else -> JSON_REGEX.find(body.string())?.groups?.get(0)?.value
}
val mangas = data.mangas.map {
if (json.isNullOrBlank()) {
return MangasPage(emptyList(), false) to ""
}
val dto = json.parseAs<T>()
val mangas = dto.mangas.map {
SManga.create().apply {
title = it.title
thumbnail_url = it.thumbnailUrl
@ -279,33 +273,74 @@ class ReadMangas() : HttpSource() {
url = "/title/${it.slug}#${it.id}"
}
}
return MangasPage(mangas, data.nextCursor != null) to (data.nextCursor ?: "")
return MangasPage(mangas, dto.hasNextPage) to dto.nextCursor
}
private inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString(body.string())
}
private fun String.toDate() =
try { dateFormat.parse(this)!!.time } catch (_: Exception) { 0L }
private fun String.toStatus() = when (lowercase()) {
"ongoing" -> SManga.ONGOING
"hiatus" -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}
private fun Response.parseScriptToJson(): String? {
val document = asJsoup()
val script = document.select("script")
.map(Element::data)
.filter {
it.contains("self.__next_f")
}
.joinToString("\n")
val content = QuickJs.create().use {
it.evaluate(
"""
globalThis.self = globalThis;
$script
self.__next_f.map(it => it[it.length - 1]).join('')
""".trimIndent(),
) as String
}
return JSON_REGEX.find(content)?.groups?.get(0)?.value
}
private fun JsonElement.toRequestBody() = toString().toRequestBody(APPLICATION_JSON)
private fun getToken(request: Request, selector: String): String {
var document = client.newCall(request).execute().asJsoup()
val url = document.selectFirst(selector)?.absUrl("src")
?: return ""
val script = client.newCall(GET(url, headers))
.execute().body.string()
return TOKEN_REGEX.find(script)?.groups?.get(1)?.value ?: ""
}
private val handler = Handler(Looper.getMainLooper())
private fun showToast(message: String) {
handler.post {
Toast.makeText(application, message, Toast.LENGTH_LONG).show()
}
}
@SuppressLint("SimpleDateFormat")
companion object {
val IMAGE_URL_REGEX = """url\\":\\"([^(\\")]+)""".toRegex()
val POPULAR_REGEX = """\{"(?:cursor|mangas)".+?\}{2}""".toRegex()
val LATEST_REGEX = """\{"(?:items|mangas)".+?(?:hasNextPage[^,]+|query.+\})""".toRegex()
val DETAILS_REGEX = """\{"oId".+\}{3}""".toRegex()
val CHAPTERS_REGEX = """\{"count".+totalPages.+\}""".toRegex()
val TOKEN_REGEX = """\("([^\)]+)",[^"]+"(?:getChapters|getProjects|getUpdatedProjects|searchProjects)""".toRegex()
val JSON_REGEX = listOf(
POPULAR_REGEX,
LATEST_REGEX,
DETAILS_REGEX,
).joinToString("|").toRegex()
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
val dateFormat = SimpleDateFormat("'\$D'yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
val latestUpdateDateFormat = SimpleDateFormat(
"EEE MMM dd yyyy HH:mm:ss 'GMT'Z '(Coordinated Universal Time)'",
Locale.ENGLISH,
).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
val APPLICATION_JSON = "application/json".toMediaType()
}
}

View File

@ -4,22 +4,66 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
interface ResultDto {
val mangas: List<MangaDto>
val nextCursor: String
val hasNextPage: Boolean
}
@Serializable
class WrapperResult<T>(
val result: Result<T>? = null,
class PopularResultDto(
@JsonNames("initialData")
val result: MangaListDto?,
@SerialName("mangas")
val list: List<MangaDto> = emptyList(),
override val nextCursor: String = result?.nextCursor ?: "",
) : ResultDto {
override val mangas: List<MangaDto> get() = result?.mangas ?: list
override val hasNextPage: Boolean = nextCursor.isNotBlank()
}
@Serializable
class LatestResultDto(
@SerialName("items")
override val mangas: List<MangaDto>,
override val nextCursor: String = "",
override val hasNextPage: Boolean = false,
) : ResultDto
@Serializable
class MangaDetailsDto(
@SerialName("oId")
val slug: String,
@SerialName("data")
val details: MangaDto,
) {
@Serializable
class Result<T>(val `data`: Data<T>)
@Serializable
class Data<T>(val json: T)
class MangaDto(
val id: String,
@SerialName("title")
val titles: List<Map<String, String>>,
val description: String,
@SerialName("coverImage")
val thumbnailUrl: String,
val status: String,
val genres: List<Genre>,
) {
val title: String get() = titles.first().values.first()
}
@Serializable
class Genre(
val name: String,
)
}
@Serializable
class MangaListDto(
@JsonNames("items")
@JsonNames("pages")
val mangas: List<MangaDto>,
val nextCursor: String?,
@JsonNames("pageParams")
val nextCursor: String = "",
)
@Serializable