YomuMangas: Restore extension (#8877)

* Revert "Remove YomuMangas (#3803)"

This reverts commit 5e39c4e0f92c7bebc693e1d70d6ce25386c4acf0.

* Fix loading content

* Bump version

* Use tryParse
This commit is contained in:
Chopper 2025-05-21 08:15:35 -03:00 committed by Draff
parent c66abf25b9
commit 8b7d0ea342
Signed by: Draff
GPG Key ID: E8A89F3211677653
9 changed files with 391 additions and 0 deletions

View File

@ -0,0 +1,8 @@
ext {
extName = 'Yomu Mangás'
extClass = '.YomuMangas'
extVersionCode = 4
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,142 @@
package eu.kanade.tachiyomi.extension.pt.yomumangas
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
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 keiyoushi.utils.parseAs
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.util.concurrent.TimeUnit
class YomuMangas : HttpSource() {
override val name = "Yomu Mangás"
override val baseUrl = "https://yomumangas.com"
override val lang = "pt-BR"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.rateLimitHost(baseUrl.toHttpUrl(), 1, 1, TimeUnit.SECONDS)
.rateLimitHost(API_URL.toHttpUrl(), 1, 1, TimeUnit.SECONDS)
.rateLimitHost(CDN_URL.toHttpUrl(), 1, 2, TimeUnit.SECONDS)
.build()
private val apiHeaders: Headers by lazy { apiHeadersBuilder().build() }
override fun headersBuilder(): Headers.Builder = Headers.Builder()
.add("Origin", baseUrl)
.add("Referer", baseUrl)
private fun apiHeadersBuilder(): Headers.Builder = headersBuilder()
.add("Accept", ACCEPT_JSON)
// ================================ Popular =======================================
override fun popularMangaRequest(page: Int): Request = searchMangaRequest(page, "", FilterList())
override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response)
// ================================ Latest =======================================
override fun latestUpdatesRequest(page: Int): Request = GET(baseUrl, headers)
override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup()
val mangas = document.select("[class*=styles_Container]:has(h1:contains(capítulos)) [class*=styles_Card]").map { element ->
SManga.create().apply {
with(element.selectFirst("a[class*=styles_Title]")!!) {
title = text()
setUrlWithoutDomain(absUrl("href"))
}
thumbnail_url = element.selectFirst("img")?.absUrl("src")
}
}
return MangasPage(mangas, hasNextPage = false)
}
// ================================ Search =======================================
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val apiUrl = "$API_URL/mangas".toHttpUrl().newBuilder()
.addQueryParameter("query", query)
.addQueryParameter("page", page.toString())
filters.filterIsInstance<UrlQueryFilter>()
.forEach { it.addQueryParameter(apiUrl) }
return GET(apiUrl.build(), apiHeaders)
}
override fun searchMangaParse(response: Response): MangasPage {
val result = response.parseAs<YomuMangasSearchDto>()
val seriesList = result.mangas.map(YomuMangasSeriesDto::toSManga)
return MangasPage(seriesList, result.hasNextPage)
}
// ================================ Details =======================================
override fun getMangaUrl(manga: SManga): String = "$baseUrl${manga.url}"
override fun mangaDetailsRequest(manga: SManga): Request =
GET("$API_URL${manga.url.substringBeforeLast("/")}", apiHeaders)
override fun mangaDetailsParse(response: Response): SManga =
response.parseAs<YomuMangasDetailsDto>().manga.toSManga()
// ================================ Chapters =======================================
override fun chapterListRequest(manga: SManga): Request = mangaDetailsRequest(manga)
private fun chapterListApiRequest(mangaId: Int): Request {
return GET("$API_URL/mangas/$mangaId/chapters", apiHeaders)
}
override fun chapterListParse(response: Response): List<SChapter> {
val series = response.parseAs<YomuMangasDetailsDto>().manga
return client.newCall(chapterListApiRequest(series.id)).execute()
.parseAs<YomuMangasChaptersDto>().chapters
.sortedByDescending(YomuMangasChapterDto::chapter)
.map { it.toSChapter(series) }
}
// ================================ Pages =======================================
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
return document.select("[class*=reader_Pages] img").mapIndexed { index, element ->
Page(index, imageUrl = element.absUrl("src"))
}
}
override fun imageUrlParse(response: Response): String = ""
// ================================ Filters =======================================
override fun getFilterList(): FilterList = FilterList(
StatusFilter(statusList),
TypeFilter(typesList),
NsfwContentFilter(),
AdultContentFilter(),
GenreFilter(genresList),
)
companion object {
private const val ACCEPT_JSON = "application/json"
private const val API_URL = "https://api.yomumangas.com"
const val CDN_URL = "https://s3.yomumangas.com"
}
}

View File

@ -0,0 +1,125 @@
package eu.kanade.tachiyomi.extension.pt.yomumangas
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import keiyoushi.utils.tryParse
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonTransformingSerializer
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import java.text.SimpleDateFormat
import java.util.Locale
@Serializable
data class YomuMangasHomeDto(
val updates: List<YomuMangasSeriesDto> = emptyList(),
val votes: List<YomuMangasSeriesDto> = emptyList(),
)
@Serializable
data class YomuMangasSearchDto(
val mangas: List<YomuMangasSeriesDto> = emptyList(),
val page: Int,
val pages: Int,
) {
val hasNextPage: Boolean
get() = page < pages
}
@Serializable
data class YomuMangasDetailsDto(val manga: YomuMangasSeriesDto)
@Serializable
data class YomuMangasSeriesDto(
val id: Int,
val slug: String,
val title: String,
val cover: String? = null,
val status: String,
val authors: List<String>? = emptyList(),
val artists: List<String>? = emptyList(),
@Serializable(with = YomuMangasGenreDtoSerializer::class)
val genres: List<YomuMangasGenreDto>? = emptyList(),
val description: String? = null,
) {
val genre: String?
get() = genres
?.filter { it.name.equals("unknown").not() }
?.joinToString { it.name }
fun toSManga(): SManga = SManga.create().apply {
title = this@YomuMangasSeriesDto.title
author = authors.orEmpty().joinToString { it.trim() }
artist = artists.orEmpty().joinToString { it.trim() }
genre = this@YomuMangasSeriesDto.genre
description = this@YomuMangasSeriesDto.description?.trim()
status = when (this@YomuMangasSeriesDto.status) {
"ONGOING" -> SManga.ONGOING
"COMPLETE" -> SManga.COMPLETED
"HIATUS" -> SManga.ON_HIATUS
"CANCELLED" -> SManga.CANCELLED
"PLANNED" -> SManga.PUBLISHING_FINISHED
else -> SManga.UNKNOWN
}
thumbnail_url = cover?.let { "${YomuMangas.CDN_URL}/images/${it.substringAfter("//")}" }
url = "/mangas/$id/$slug"
}
}
@Serializable
data class YomuMangasGenreDto(val name: String)
private object YomuMangasGenreDtoSerializer : JsonTransformingSerializer<List<YomuMangasGenreDto>>(
ListSerializer(YomuMangasGenreDto.serializer()),
) {
override fun transformDeserialize(element: JsonElement): JsonElement {
return JsonArray(
element.jsonArray.map { jsonElement ->
jsonElement.takeIf { it.isObject } ?: buildJsonObject {
genresList.firstOrNull { it.id.equals(jsonElement.jsonPrimitive.intOrNull) }?.let {
put("name", JsonPrimitive(it.name))
} ?: put("name", JsonPrimitive("unknown"))
}
},
)
}
private val JsonElement.isObject get() = this is JsonObject
}
@Serializable
data class YomuMangasChaptersDto(val chapters: List<YomuMangasChapterDto> = emptyList())
@Serializable
data class YomuMangasChapterDto(
val id: Int,
val chapter: Float,
@SerialName("uploaded_at") val uploadedAt: String,
val images: List<YomuMangasImageDto>? = emptyList(),
) {
fun toSChapter(series: YomuMangasSeriesDto): SChapter = SChapter.create().apply {
name = "Capítulo ${chapter.toString().removeSuffix(".0")}"
date_upload = DATE_FORMATTER.tryParse(uploadedAt)
url = "/mangas/${series.id}/${series.slug}/$chapter"
}
companion object {
private val DATE_FORMATTER by lazy {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
}
}
}
@Serializable
data class YomuMangasImageDto(val uri: String)

View File

@ -0,0 +1,116 @@
package eu.kanade.tachiyomi.extension.pt.yomumangas
import eu.kanade.tachiyomi.source.model.Filter
import okhttp3.HttpUrl
interface UrlQueryFilter {
fun addQueryParameter(url: HttpUrl.Builder)
}
class NsfwContentFilter : Filter.CheckBox("Conteúdo NSFW"), UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder) {
if (state) {
url.addQueryParameter("nsfw", "true")
}
}
}
class AdultContentFilter : Filter.CheckBox("Conteúdo adulto"), UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder) {
if (state) {
url.addQueryParameter("hentai", "true")
}
}
}
open class EnhancedSelect<T>(name: String, values: Array<T>) : Filter.Select<T>(name, values) {
val selected: T
get() = values[state]
}
data class Status(val name: String, val value: String) {
override fun toString() = name
}
class StatusFilter(statusList: List<Status>) :
EnhancedSelect<Status>("Status", statusList.toTypedArray()),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder) {
if (state > 0) {
url.addQueryParameter("status", selected.value)
}
}
}
data class Type(val name: String, val value: String) {
override fun toString() = name
}
class TypeFilter(typesList: List<Type>) :
EnhancedSelect<Type>("Tipo", typesList.toTypedArray()),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder) {
if (state > 0) {
url.addQueryParameter("type", selected.value)
}
}
}
class Genre(name: String, val id: String) : Filter.CheckBox(name) {
override fun toString() = name
}
class GenreFilter(genres: List<Genre>) :
Filter.Group<Genre>("Gêneros", genres),
UrlQueryFilter {
override fun addQueryParameter(url: HttpUrl.Builder) {
state.filter(Genre::state)
.forEach { url.addQueryParameter("genres[]", it.id) }
}
}
val genresList: List<Genre> = listOf(
Genre("Ação", "1"),
Genre("Aventura", "8"),
Genre("Comédia", "2"),
Genre("Drama", "3"),
Genre("Ecchi", "15"),
Genre("Esportes", "14"),
Genre("Fantasia", "6"),
Genre("Hentai", "19"),
Genre("Horror", "4"),
Genre("Mahou shoujo", "18"),
Genre("Mecha", "17"),
Genre("Mistério", "7"),
Genre("Música", "16"),
Genre("Psicológico", "9"),
Genre("Romance", "13"),
Genre("Sci-fi", "11"),
Genre("Slice of life", "10"),
Genre("Sobrenatural", "5"),
Genre("Suspense", "12"),
)
val statusList: List<Status> = listOf(
Status("Todos", ""),
Status("Finalizado", "COMPLETE"),
Status("Em lançando", "ONGOING"),
Status("Hiato", "HIATUS"),
Status("Pausado", "ONHOLD"),
Status("Planejado", "PLANNED"),
Status("Arquivado", "ARCHIVED"),
Status("Cancelado", "CANCELLED"),
)
val typesList: List<Type> = listOf(
Type("Todos", ""),
Type("Mangá", "MANGA"),
Type("Manhwa", "MANHWA"),
Type("Manhua", "MANHUA"),
Type("One-shot", "ONESHOT"),
Type("Doujinshi", "DOUJINSHI"),
Type("Outros", "OTHER"),
)