From 411ae6934a9aa72399c7ec4d21cef6ca4201c0a3 Mon Sep 17 00:00:00 2001 From: Biglongs1 Date: Tue, 18 Aug 2026 00:23:19 -0300 Subject: [PATCH 1/2] Yomu Comics: Fix NullPointerException on encrypted payloads The site now ships its library, chapter and page payloads encrypted with CryptoJS AES, so the old plain-text lookups returned null. Decrypts them with lib:cryptoaes, reads the series page from the flight response and migrates the source to KeiSource. --- src/pt/sssscanlator/build.gradle.kts | 10 +- .../extension/pt/sssscanlator/Dto.kt | 197 +++++++++---- .../extension/pt/sssscanlator/Filters.kt | 130 +++++---- .../extension/pt/sssscanlator/Utils.kt | 87 ------ .../extension/pt/sssscanlator/YomuComics.kt | 258 +++++------------- 5 files changed, 278 insertions(+), 404 deletions(-) delete mode 100644 src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Utils.kt diff --git a/src/pt/sssscanlator/build.gradle.kts b/src/pt/sssscanlator/build.gradle.kts index bd4b8100619..efc2da001ae 100644 --- a/src/pt/sssscanlator/build.gradle.kts +++ b/src/pt/sssscanlator/build.gradle.kts @@ -6,9 +6,9 @@ plugins { keiyoushi { name = "Yomu Comics" - versionCode = 52 - contentWarning = ContentWarning.NSFW // or MIXED, please confirm - libVersion = "1.4" + versionCode = 53 + contentWarning = ContentWarning.MIXED + libVersion = "1.6" source { lang = "pt-BR" @@ -16,3 +16,7 @@ keiyoushi { id = 1497838059713668619L } } + +dependencies { + implementation(project(":lib:cryptoaes")) +} diff --git a/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Dto.kt b/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Dto.kt index 40e04c801bf..5008be53f5f 100644 --- a/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Dto.kt +++ b/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Dto.kt @@ -1,95 +1,186 @@ package eu.kanade.tachiyomi.extension.pt.sssscanlator +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 keiyoushi.utils.tryParse +import keiyoushi.lib.cryptoaes.CryptoAES +import keiyoushi.utils.extractNextJsRsc +import keiyoushi.utils.parseAs import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.text.SimpleDateFormat +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter import java.util.Locale -import java.util.TimeZone +import kotlin.time.Instant -@Serializable -class LibraryResponseDto( - val pagination: LibraryPaginationDto = LibraryPaginationDto(), -) +private const val PASSPHRASE = "yomu_trolling_scrapers_v1" +private const val ENCRYPTED_PREFIX = "U2FsdGVkX1" + +internal fun String.decryptPayload(): String = CryptoAES.decrypt(this, PASSPHRASE) @Serializable class LibraryPaginationDto( - val page: Int = 1, - val totalPages: Int = 1, -) + private val page: Int = 1, + private val totalPages: Int = 1, +) { + val hasNextPage get() = page < totalPages +} @Serializable class LibraryMangaDto( - val title: String, - val cover: String? = null, - val slug: String, - val type: String? = null, + private val title: String, + private val slug: String, + private val cover: String? = null, ) { - fun toSManga(): SManga = SManga.create().apply { - title = this@LibraryMangaDto.title - thumbnail_url = cover?.takeUnless(String::isBlank) + fun toSManga() = SManga.create().apply { url = "/obra/$slug" + title = this@LibraryMangaDto.title + thumbnail_url = cover?.takeIf(String::isNotBlank) } } +internal fun JsonObject.toMangasPage(): MangasPage { + val mangas = values.asSequence() + .mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + .filter { it.startsWith(ENCRYPTED_PREFIX) } + .map { it.decryptPayload() } + .firstOrNull(String::isNotEmpty) + ?.parseAs>() + ?: throw Exception("Não foi possível ler a lista de obras") + + val pagination = get("pagination")?.parseAs() ?: LibraryPaginationDto() + return MangasPage(mangas.map(LibraryMangaDto::toSManga), pagination.hasNextPage) +} + @Serializable class SeriesPayloadDto( + val slug: String, + private val encryptedChapters: String, val description: String? = null, val author: String? = null, val artist: String? = null, val coverImage: String? = null, - @SerialName("capitulos_lista") - val chapters: List = emptyList(), - private val slug: String? = null, + val status: String? = null, +) { + val chapters: List + get() = encryptedChapters.decryptPayload() + .takeIf(String::isNotEmpty) + ?.parseAs>() + ?.map { it.toSChapter(slug) } + ?: emptyList() +} + +@Serializable +class SeriesHeaderDto( + val seriesId: String, + val title: String, ) +class SeriesPage(val manga: SManga, val chapters: List) + +internal fun String.parseSeriesPage(): SeriesPage { + var payload: SeriesPayloadDto? = null + var title: String? = null + val genres = mutableListOf() + + extractNextJsRsc { element -> + when (element) { + is JsonObject -> { + if (payload == null && "encryptedChapters" in element && "slug" in element) { + payload = element.parseAs() + } + if (title == null && "seriesId" in element && "title" in element) { + title = element.parseAs().title + } + } + is JsonArray -> element.genreBadgeOrNull()?.let(genres::add) + else -> {} + } + false + } + + val series = payload ?: throw Exception("Não foi possível ler os dados da obra") + + val manga = SManga.create().apply { + url = "/obra/${series.slug}" + this.title = title ?: throw Exception("Título da obra não encontrado") + thumbnail_url = series.coverImage?.takeIf(String::isNotBlank) + description = series.description?.takeIf(String::isNotBlank) + author = series.author?.takeIf(String::isNotBlank) + artist = series.artist?.takeIf(String::isNotBlank) + genre = genres.distinct().joinToString() + status = when (series.status?.uppercase()) { + "ONGOING" -> SManga.ONGOING + "COMPLETED" -> SManga.COMPLETED + "HIATUS" -> SManga.ON_HIATUS + "CANCELED", "CANCELLED" -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + } + + return SeriesPage(manga, series.chapters) +} + +private fun JsonArray.genreBadgeOrNull(): String? { + if (size != 4 || (this[1] as? JsonPrimitive)?.contentOrNull != "span") return null + if ((this[2] as? JsonPrimitive)?.contentOrNull.isNullOrEmpty()) return null + + val props = this[3] as? JsonObject ?: return null + if ((props["data-slot"] as? JsonPrimitive)?.contentOrNull != "badge") return null + + return (props["children"] as? JsonPrimitive)?.contentOrNull?.takeIf(String::isNotBlank) +} + @Serializable class SeriesChapterDto( - val number: Double, - val title: String? = null, - val releaseDate: String? = null, - @SerialName("id") - val chapterId: String, - val releaseAt: String? = null, + private val id: String, + private val number: Double, + private val title: String? = null, + private val releaseDate: String? = null, + private val releaseAt: String? = null, ) { - fun toSChapter(mangaSlug: String): SChapter = SChapter.create().apply { - val chapterNumberLabel = number.toChapterNumberString() - - url = "/ler/$mangaSlug/$chapterNumberLabel?chapterId=$chapterId" - name = title?.takeUnless { it.isBlank() } ?: "Capítulo $chapterNumberLabel" + fun toSChapter(mangaSlug: String) = SChapter.create().apply { + val label = number.formatted() + url = "/ler/$mangaSlug/$label?chapterId=$id" + name = title?.takeIf(String::isNotBlank) ?: "Capítulo $label" chapter_number = number.toFloat() - date_upload = parseChapterDate(releaseAt, releaseDate) + date_upload = releaseAt?.let(Instant::parseOrNull)?.toEpochMilliseconds() ?: releaseDate.toTimestamp() } - companion object { - private val RELEASE_AT_MILLIS by lazy { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - } + private fun Double.formatted(): String = toString().removeSuffix(".0") - private val RELEASE_DATE by lazy { - SimpleDateFormat("dd/MM/yyyy", Locale.ROOT) - } + private fun String?.toTimestamp(): Long = runCatching { + LocalDate.parse(this, DATE_FORMAT).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + }.getOrDefault(0L) - private fun parseChapterDate(releaseAt: String?, releaseDate: String?): Long { - RELEASE_AT_MILLIS.tryParse(releaseAt).takeIf { it != 0L }?.let { return it } - return RELEASE_DATE.tryParse(releaseDate) - } + companion object { + private val DATE_FORMAT = DateTimeFormatter.ofPattern("dd/MM/yyyy", Locale.ROOT) } } @Serializable -class ChapterPageDto( - val chapter: ChapterImagesDto, -) +class ChapterPayloadDto( + val seriesSlug: String, + private val encryptedChapter: String, +) { + val pages: List + get() = encryptedChapter.decryptPayload() + .takeIf(String::isNotEmpty) + ?.parseAs() + ?.toPageList() + ?: emptyList() +} @Serializable class ChapterImagesDto( - @SerialName("imagens_lista") - val images: List, -) - -private fun Double.toChapterNumberString(): String = toString().removeSuffix(".0") + @SerialName("imagens_lista") private val images: List = emptyList(), +) { + fun toPageList() = images.mapIndexed { index, imageUrl -> Page(index, imageUrl = imageUrl) } +} diff --git a/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Filters.kt b/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Filters.kt index 3222abfb327..adf7faaaae4 100644 --- a/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Filters.kt +++ b/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Filters.kt @@ -3,79 +3,75 @@ package eu.kanade.tachiyomi.extension.pt.sssscanlator import eu.kanade.tachiyomi.source.model.Filter +import okhttp3.HttpUrl -class GenreFilter : Filter.Select("Gêneros", genreOptions.map { it.first }.toTypedArray()) { - val selectedValue: String - get() = genreOptions[state].second +interface UrlFilter { + fun addToUrl(builder: HttpUrl.Builder) } -class TypeFilter : Filter.Select("Tipo", typeOptions.map { it.first }.toTypedArray()) { - val selectedValue: String - get() = typeOptions[state].second -} - -class StatusFilter : Filter.Select("Status", statusOptions.map { it.first }.toTypedArray()) { - val selectedValue: String - get() = statusOptions[state].second -} +open class SelectFilter( + name: String, + private val parameter: String, + private val options: List>, +) : Filter.Select(name, options.map { it.first }.toTypedArray()), + UrlFilter { + val selectedValue get() = options[state].second -class SortFilter : Filter.Select("Ordenar por", sortOptions.map { it.first }.toTypedArray()) { - val selectedValue: String - get() = sortOptions[state].second + override fun addToUrl(builder: HttpUrl.Builder) { + selectedValue.takeIf(String::isNotEmpty)?.let { builder.addQueryParameter(parameter, it) } + } } -private val genreOptions = listOf( - "Gêneros" to "", - "Ação" to "Ação", - "Aventura" to "Aventura", - "Artes Marciais" to "Artes Marciais", - "Comédia" to "Comédia", - "Drama" to "Drama", - "Ecchi" to "Ecchi", - "Fantasia" to "Fantasia", - "Ficção Científica" to "Ficção Científica", - "Harem" to "Harem", - "Histórico" to "Histórico", - "Maduro" to "Maduro", - "Mistério" to "Mistério", - "Psicológico" to "Psicológico", - "Romance" to "Romance", - "Seinen" to "Seinen", - "Shoujo" to "Shoujo", - "Shounen" to "Shounen", - "Sobrenatural" to "Sobrenatural", - "Tragédia" to "Tragédia", - "Vida Escolar" to "Vida Escolar", -) +class SortFilter : + SelectFilter( + "Ordenar por", + "sort", + listOf( + "Mais populares" to "popular", + "Maior avaliação" to "rating", + "Atualizados recentemente" to "recent", + "Novidades" to "new", + "A-Z" to "alphabetical", + ), + ) -private val typeOptions = listOf( - "Todos" to "all", - "Mangá" to "manga", - "Manhwa" to "manhwa", - "Manhua" to "manhua", - "Novel" to "novel", - "Yaoi" to "yaoi", - "Yuri" to "yuri", - "Shoujo" to "shoujo", - "English" to "english", - "Webtoon" to "webtoon", - "Doujinshi" to "doujinshi", - "Hentai" to "hentai", - "Pornhwa" to "pornhwa", -) +class TypeFilter : + SelectFilter( + "Tipo", + "type", + listOf( + "Todos" to "", + "Mangá" to "manga", + "Manhwa" to "manhwa", + "Manhua" to "manhua", + "Novel" to "novel", + "Yaoi" to "yaoi", + "Yuri" to "yuri", + "Shoujo" to "shoujo", + "English" to "english", + "Webtoon" to "webtoon", + "Doujinshi" to "doujinshi", + "Hentai" to "hentai", + "Pornhwa" to "pornhwa", + ), + ) -private val statusOptions = listOf( - "Status" to "all", - "Em lançamento" to "ONGOING", - "Completo" to "COMPLETED", - "Hiato" to "HIATUS", - "Cancelado" to "CANCELED", -) +class StatusFilter : + SelectFilter( + "Status", + "status", + listOf( + "Todos" to "", + "Em lançamento" to "ONGOING", + "Completo" to "COMPLETED", + "Hiato" to "HIATUS", + "Cancelado" to "CANCELED", + ), + ) -private val sortOptions = listOf( - "Mais Populares" to "popular", - "Maior Avaliação" to "rating", - "Mais Recentes" to "recent", - "Novidades" to "new", - "A-Z" to "alphabetical", -) +class GenreFilter(genres: List) : + SelectFilter( + "Gênero", + "genre", + listOf("Todos" to "") + genres.map { it to it }, + ) diff --git a/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Utils.kt b/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Utils.kt deleted file mode 100644 index 219aa7a1b18..00000000000 --- a/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Utils.kt +++ /dev/null @@ -1,87 +0,0 @@ -package eu.kanade.tachiyomi.extension.pt.sssscanlator - -import eu.kanade.tachiyomi.source.model.SManga -import keiyoushi.utils.extractNextJs -import keiyoushi.utils.parseAs -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonPrimitive -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element - -internal fun extractSeriesPayload(document: Document, mangaSlug: String): SeriesPayloadDto { - require(mangaSlug.isNotBlank()) { "Slug da obra nao encontrado na URL" } - - val matches = mutableListOf() - document.extractNextJs { element -> - if (element.matchesSeriesPayload(mangaSlug)) matches.add(element) - false - } - - val chapterArrays = matches.takeIf { it.isNotEmpty() }?.map { it.parseAs()["capitulos_lista"]!!.parseAs() } - ?: error("Payload da obra nao encontrado para slug=$mangaSlug") - - return matches[selectRealArray(chapterArrays)!!].parseAs() -} - -internal fun extractBadgeTexts(titleElement: Element?): List { - val nearbyElements = listOfNotNull( - titleElement, - titleElement?.previousElementSibling(), - titleElement?.nextElementSibling(), - titleElement?.nextElementSibling()?.nextElementSibling(), - titleElement?.parent(), - titleElement?.parent()?.parent(), - ) - - return nearbyElements - .flatMap { element -> element.select("span[data-slot=badge]") } - .map { it.text() } - .filter(String::isNotEmpty) - .distinct() -} - -internal fun isStatusBadge(text: String): Boolean = parseStatus(text) != SManga.UNKNOWN - -internal fun parseStatus(statusText: String?): Int = when (statusText?.lowercase()) { - "em lancamento", "em lançamento", "ongoing" -> SManga.ONGOING - "completo", "concluido", "concluído", "completed" -> SManga.COMPLETED - "hiato", "hiatus" -> SManga.ON_HIATUS - "cancelado", "canceled", "cancelled" -> SManga.CANCELLED - else -> SManga.UNKNOWN -} - -private fun JsonElement.matchesSeriesPayload(expectedSlug: String): Boolean { - val payload = this as? JsonObject ?: return false - if (payload["slug"]?.jsonPrimitive?.contentOrNull != expectedSlug) return false - - val chapters = payload["capitulos_lista"] as? JsonArray ?: return false - val hasValidChapterShape = chapters.isEmpty() || chapters.any { chapter -> - val chapterObject = chapter as? JsonObject ?: return@any false - "id" in chapterObject && "number" in chapterObject - } - - return hasValidChapterShape && - ( - "chapterTotal" in payload || - "refId" in payload || - "coverImage" in payload || - "description" in payload - ) -} - -// ignore fake matches with dummy fields -fun selectRealArray(arrays: List): Int? { - if (arrays.isEmpty()) return null - if (arrays.size == 1) return 0 - - val emptyIndex = arrays.indexOfFirst { it.isEmpty() } - if (emptyIndex != -1) return emptyIndex - - return arrays.indices.maxByOrNull { index -> - val array = arrays[index] - array.sumOf { it.toString().length }.toDouble() / array.size - } -} diff --git a/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/YomuComics.kt b/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/YomuComics.kt index def21eee3c7..e3239be805f 100644 --- a/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/YomuComics.kt +++ b/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/YomuComics.kt @@ -1,241 +1,111 @@ package eu.kanade.tachiyomi.extension.pt.sssscanlator -import android.util.Base64 -import eu.kanade.tachiyomi.network.GET 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 eu.kanade.tachiyomi.util.asJsoup +import eu.kanade.tachiyomi.source.model.SMangaUpdate import keiyoushi.annotation.Source +import keiyoushi.network.get import keiyoushi.network.rateLimit +import keiyoushi.source.KeiSource import keiyoushi.utils.extractNextJs -import keiyoushi.utils.firstInstanceOrNull import keiyoushi.utils.parseAs -import kotlinx.serialization.json.JsonArray +import keiyoushi.utils.toJsonElement import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject +import okhttp3.Headers +import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response @Source -abstract class YomuComics : HttpSource() { +abstract class YomuComics : KeiSource() { - override val supportsLatest = true + private val siteHost: String by lazy { baseUrl.toHttpUrl().host } - override val client: OkHttpClient = network.client.newBuilder() - .rateLimit(5) - .build() + override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder = rateLimit(5) { it.host == siteHost } - override fun headersBuilder() = super.headersBuilder() - .add("Referer", "$baseUrl/") + private val rscHeaders: Headers by lazy { headers.newBuilder().set("RSC", "1").build() } - // Popular + override suspend fun getPopularManga(page: Int): MangasPage = getMangaList(page, sort = "popular") - override fun popularMangaRequest(page: Int): Request { - val url = "$baseUrl/api/library".toHttpUrl().newBuilder() - .addQueryParameter("page", page.toString()) - .addQueryParameter("limit", PAGE_SIZE.toString()) - .addQueryParameter("sort", "popular") - .addQueryParameter("type", DEFAULT_TYPE) - .build() - - return GET(url, bibliotecaHeaders) - } + override suspend fun getLatestUpdates(page: Int): MangasPage = getMangaList(page, sort = "recent") - override fun popularMangaParse(response: Response): MangasPage = parseLibraryResponse(response) + override suspend fun getSearchMangaList(page: Int, query: String, filters: FilterList): MangasPage = getMangaList(page, query, filters) - // Latest - - override fun latestUpdatesRequest(page: Int): Request { + private suspend fun getMangaList( + page: Int, + query: String = "", + filters: FilterList = FilterList(), + sort: String? = null, + ): MangasPage { val url = "$baseUrl/api/library".toHttpUrl().newBuilder() .addQueryParameter("page", page.toString()) .addQueryParameter("limit", PAGE_SIZE.toString()) - .addQueryParameter("sort", "recent") - .addQueryParameter("type", DEFAULT_TYPE) - .build() - - return GET(url, bibliotecaHeaders) - } - - override fun latestUpdatesParse(response: Response): MangasPage = parseLibraryResponse(response) - - // Search - - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val genre = filters.firstInstanceOrNull()?.selectedValue.orEmpty() - val type = filters.firstInstanceOrNull()?.selectedValue ?: DEFAULT_TYPE - val status = filters.firstInstanceOrNull()?.selectedValue ?: DEFAULT_STATUS - val sort = filters.firstInstanceOrNull()?.selectedValue ?: DEFAULT_SORT - - val url = "$baseUrl/api/library".toHttpUrl().newBuilder().apply { - addQueryParameter("page", page.toString()) - addQueryParameter("limit", PAGE_SIZE.toString()) - addQueryParameter("sort", sort) - addQueryParameter("type", type) - - if (genre.isNotBlank()) { - addQueryParameter("genre", genre) - } - - if (status != DEFAULT_STATUS) { - addQueryParameter("status", status) - } - - if (query.isNotBlank()) { - addQueryParameter("search", query) + .apply { + sort?.let { addQueryParameter("sort", it) } + if (query.isNotBlank()) { + addQueryParameter("search", query) + } + filters.filterIsInstance() + .filterNot { sort != null && it is SortFilter } + .forEach { it.addToUrl(this) } } - }.build() - - return GET(url, bibliotecaHeaders) - } - - override fun searchMangaParse(response: Response): MangasPage = parseLibraryResponse(response) - - // Details - - override fun mangaDetailsParse(response: Response): SManga = parseSeriesPage(response).manga - - override fun getChapterUrl(chapter: SChapter): String = baseUrl + chapter.url.substringBefore('?') - - // Chapters - - override fun chapterListParse(response: Response): List = parseSeriesPage(response).chapters - - // Pages - - override fun pageListRequest(chapter: SChapter): Request { - val chapterPageUrl = getChapterUrl(chapter) - - val requestHeaders = headers.newBuilder() - .set("Referer", chapterPageUrl) - .set("RSC", "1") .build() - return GET(chapterPageUrl, requestHeaders) + return client.get(url).parseAs().toMangasPage() } - override fun pageListParse(response: Response): List { - val matches = mutableListOf() - response.extractNextJs { element -> - val chapter = (element as? JsonObject)?.get("chapter") as? JsonObject - if (chapter?.get("imagens_lista") is JsonArray) matches.add(element) - false - } - - val chapterArrays = matches.map { ((it as JsonObject)["chapter"] as JsonObject)["imagens_lista"] as JsonArray } - val data = selectRealArray(chapterArrays)?.let { matches[it].parseAs() } + override suspend fun getMangaByUrl(url: HttpUrl): SManga? { + if (url.pathSegments.firstOrNull() !in MANGA_PATH_SEGMENTS) return null + val slug = url.pathSegments.getOrNull(1)?.takeIf(String::isNotEmpty) ?: return null + val manga = SManga.create().apply { this.url = "/obra/$slug" } - if (data != null && data.chapter.images.isNotEmpty()) { - return data.chapter.images.mapIndexed { index, imageUrl -> - Page(index, imageUrl = imageUrl) - } - } + return fetchMangaUpdate(manga, emptyList(), fetchDetails = true, fetchChapters = false) + .manga + .apply { initialized = true } + } - error("Nenhuma pagina encontrada para este capitulo") + override suspend fun fetchMangaUpdate( + manga: SManga, + chapters: List, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate { + val series = client.get(baseUrl + manga.url, rscHeaders) + .use { it.body.string() } + .parseSeriesPage() + + return SMangaUpdate(manga = series.manga, chapters = series.chapters) } - override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() + override suspend fun getPageList(chapter: SChapter): List { + val payload = client.get(getChapterUrl(chapter), rscHeaders) + .extractNextJs() + ?: throw Exception("Não foi possível ler as páginas do capítulo") - override fun imageRequest(page: Page): Request { - val requestHeaders = headers.newBuilder() - .set("Referer", "$baseUrl/") - .build() - return GET(page.imageUrl!!, requestHeaders) + return payload.pages } - // Filters - - override fun getFilterList() = FilterList( - GenreFilter(), - TypeFilter(), - StatusFilter(), - SortFilter(), - ) - - // Utils - - private fun parseLibraryResponse(response: Response): MangasPage { - val resultString = response.body.string() - val pagination = resultString.parseAs().pagination - - // mangas field name changes frequently - val allArrays = resultString - .parseAs() - .jsonObject.values - .mapNotNull { value -> - when (value) { - is JsonArray -> value - is JsonPrimitive -> value.contentOrNull?.let { base64Str -> - runCatching { - Base64.decode(base64Str, Base64.DEFAULT) - .toString(Charsets.UTF_8) - .parseAs() - }.getOrNull() - } - else -> null - } - } + override val supportsFilterFetching: Boolean get() = true - val rIndex = selectRealArray(allArrays) - val mangasList = allArrays[rIndex!!].map { it.parseAs() } + override suspend fun fetchFilterData(): JsonElement = client.get("$baseUrl/api/genres") + .parseAs>() + .toJsonElement() - val mangas = mangasList.filter { it.type != "novel" }.map(LibraryMangaDto::toSManga) - val hasNextPage = pagination.page < pagination.totalPages - return MangasPage(mangas, hasNextPage) - } + override fun getFilterList(data: JsonElement?): FilterList { + val genres = data?.parseAs>() + ?: return FilterList(SortFilter(), TypeFilter(), StatusFilter()) - private fun parseSeriesPage(response: Response): SeriesPageData { - val mangaSlug = response.request.url.pathSegments.lastOrNull().orEmpty() - val document = response.asJsoup() - val payload = extractSeriesPayload(document, mangaSlug) - - val titleElement = document.selectFirst("h1") - val title = titleElement!!.text() - val badgeTexts = extractBadgeTexts(titleElement) - val statusText = badgeTexts.firstOrNull(::isStatusBadge) - val genres = badgeTexts.filterNot(::isStatusBadge) - - val manga = SManga.create().apply { - this.title = title - thumbnail_url = payload.coverImage?.takeUnless(String::isBlank) - description = payload.description?.takeUnless(String::isBlank) - author = payload.author?.takeUnless(String::isBlank) - artist = payload.artist?.takeUnless(String::isBlank) - genre = genres.joinToString().takeUnless(String::isBlank) - status = parseStatus(statusText) - url = "/obra/$mangaSlug" - } - - val chapters = payload.chapters.map { chapter -> - chapter.toSChapter(mangaSlug) - } - - return SeriesPageData(manga, chapters) + return FilterList(SortFilter(), TypeFilter(), StatusFilter(), GenreFilter(genres)) } - private class SeriesPageData( - val manga: SManga, - val chapters: List, - ) - - private companion object { - const val PAGE_SIZE = 20 - const val DEFAULT_TYPE = "all" - const val DEFAULT_STATUS = "all" - const val DEFAULT_SORT = "popular" - } + override fun getChapterUrl(chapter: SChapter): String = baseUrl + chapter.url.substringBefore('?') - private val bibliotecaHeaders by lazy { - headers.newBuilder() - .set("Referer", "$baseUrl/biblioteca") - .build() + companion object { + private const val PAGE_SIZE = 30 + private val MANGA_PATH_SEGMENTS = listOf("obra", "ler") } } From 5596f2a8721b403fd36cff396763598f5d10b647 Mon Sep 17 00:00:00 2001 From: Biglongs1 Date: Tue, 18 Aug 2026 18:49:15 -0300 Subject: [PATCH 2/2] Yomu Comics: Address review feedback Use the date utils, store the chapter id in the url with the rest in memo, and turn siteHost/rscHeaders into getters. --- .../tachiyomi/extension/pt/sssscanlator/Dto.kt | 18 ++++++++++-------- .../extension/pt/sssscanlator/YomuComics.kt | 15 ++++++++++----- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Dto.kt b/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Dto.kt index 5008be53f5f..1c4992bbfe9 100644 --- a/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Dto.kt +++ b/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/Dto.kt @@ -7,15 +7,17 @@ import eu.kanade.tachiyomi.source.model.SManga import keiyoushi.lib.cryptoaes.CryptoAES import keiyoushi.utils.extractNextJsRsc import keiyoushi.utils.parseAs +import keiyoushi.utils.tryParse +import keiyoushi.utils.tryParseDate import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull -import java.time.LocalDate -import java.time.ZoneOffset +import kotlinx.serialization.json.put import java.time.format.DateTimeFormatter import java.util.Locale import kotlin.time.Instant @@ -148,18 +150,18 @@ class SeriesChapterDto( ) { fun toSChapter(mangaSlug: String) = SChapter.create().apply { val label = number.formatted() - url = "/ler/$mangaSlug/$label?chapterId=$id" + url = id name = title?.takeIf(String::isNotBlank) ?: "Capítulo $label" chapter_number = number.toFloat() - date_upload = releaseAt?.let(Instant::parseOrNull)?.toEpochMilliseconds() ?: releaseDate.toTimestamp() + date_upload = Instant.tryParse(releaseAt).takeIf { it != 0L } ?: DATE_FORMAT.tryParseDate(releaseDate) + memo = buildJsonObject { + put("slug", mangaSlug) + put("number", label) + } } private fun Double.formatted(): String = toString().removeSuffix(".0") - private fun String?.toTimestamp(): Long = runCatching { - LocalDate.parse(this, DATE_FORMAT).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() - }.getOrDefault(0L) - companion object { private val DATE_FORMAT = DateTimeFormatter.ofPattern("dd/MM/yyyy", Locale.ROOT) } diff --git a/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/YomuComics.kt b/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/YomuComics.kt index e3239be805f..9e4b94aac03 100644 --- a/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/YomuComics.kt +++ b/src/pt/sssscanlator/src/eu/kanade/tachiyomi/extension/pt/sssscanlator/YomuComics.kt @@ -12,6 +12,7 @@ import keiyoushi.network.rateLimit import keiyoushi.source.KeiSource import keiyoushi.utils.extractNextJs import keiyoushi.utils.parseAs +import keiyoushi.utils.stringOrNull import keiyoushi.utils.toJsonElement import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -23,11 +24,9 @@ import okhttp3.OkHttpClient @Source abstract class YomuComics : KeiSource() { - private val siteHost: String by lazy { baseUrl.toHttpUrl().host } + override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder = rateLimit(5) { it.host == baseUrl.toHttpUrl().host } - override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder = rateLimit(5) { it.host == siteHost } - - private val rscHeaders: Headers by lazy { headers.newBuilder().set("RSC", "1").build() } + private val rscHeaders: Headers get() = headersBuilder().set("RSC", "1").build() override suspend fun getPopularManga(page: Int): MangasPage = getMangaList(page, sort = "popular") @@ -102,7 +101,13 @@ abstract class YomuComics : KeiSource() { return FilterList(SortFilter(), TypeFilter(), StatusFilter(), GenreFilter(genres)) } - override fun getChapterUrl(chapter: SChapter): String = baseUrl + chapter.url.substringBefore('?') + override fun getChapterUrl(chapter: SChapter): String { + val slug = chapter.memo["slug"]?.stringOrNull + val number = chapter.memo["number"]?.stringOrNull + if (slug == null || number == null) throw Exception("Atualize a lista de capítulos") + + return "$baseUrl/ler/$slug/$number" + } companion object { private const val PAGE_SIZE = 30