Add LectorMOnline and MangasX (#9665)

* add lectormonline

* add webview methods

* review

* move to factory

* wut

* move to multisrc

* move iconcs to multisrc
This commit is contained in:
kanoou 2025-07-20 13:23:13 -05:00 committed by Draff
parent 9a2e34df4d
commit 927507b7ac
Signed by: Draff
GPG Key ID: E8A89F3211677653
13 changed files with 370 additions and 0 deletions

View File

@ -0,0 +1,5 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -0,0 +1,193 @@
package eu.kanade.tachiyomi.multisrc.lectormonline
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.Filter
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.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import kotlin.concurrent.thread
open class LectorMOnline(
override val name: String,
override val baseUrl: String,
override val lang: String,
) : HttpSource() {
override val supportsLatest = true
override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/comics?sort=views&page=$page", headers)
}
override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response)
override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/comics?page=$page", headers)
}
override fun latestUpdatesParse(response: Response): MangasPage = searchMangaParse(response)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = baseUrl.toHttpUrl().newBuilder()
.addPathSegment("comics")
.addQueryParameter("q", query)
.addQueryParameter("page", page.toString())
filters.forEach { filter ->
when (filter) {
is SortByFilter -> {
if (filter.selected == "views") {
url.addQueryParameter("sort", "views")
}
if (filter.state!!.ascending) {
url.addQueryParameter("isDesc", "false")
}
}
is GenreFilter -> {
val selectedGenre = filter.toUriPart()
if (selectedGenre.isNotEmpty()) {
return GET("$baseUrl/genres/$selectedGenre?page=$page", headers)
}
}
else -> { }
}
}
return GET(url.build(), headers)
}
override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup()
if (response.request.url.pathSegments[0] == "genres") {
return searchMangaGenreParse(document)
}
val script = document.select("script:containsData(self.__next_f.push)").joinToString { it.data() }
val jsonData = COMICS_LIST_REGEX.find(script)?.groupValues?.get(1)?.unescape()
?: throw Exception("No se pudo encontrar la lista de cómics")
val data = jsonData.parseAs<ComicListDataDto>()
return MangasPage(data.comics.map { it.toSManga() }, data.hasNextPage())
}
private fun searchMangaGenreParse(document: Document): MangasPage {
val mangas = document.select("div.grid.relative > a.group.relative").map { element ->
SManga.create().apply {
setUrlWithoutDomain(element.attr("href").substringAfter("/comics/").substringBefore("?"))
title = element.selectFirst("h3")!!.text()
thumbnail_url = element.selectFirst("img")?.attr("abs:src")
}
}
val hasNextPage = document.selectFirst("div.flex.items-center > a:has(> svg):last-child:not(.pointer-events-none)") != null
return MangasPage(mangas, hasNextPage)
}
override fun getMangaUrl(manga: SManga) = "$baseUrl/comics/${manga.url}"
override fun mangaDetailsRequest(manga: SManga): Request {
return GET("$baseUrl/api/app/comic/${manga.url}", headers)
}
override fun mangaDetailsParse(response: Response): SManga {
return response.parseAs<ComicDto>().toSMangaDetails()
}
override fun getChapterUrl(chapter: SChapter): String {
val mangaSlug = chapter.url.substringBefore("/")
val chapterNumber = chapter.url.substringAfter("/")
return "$baseUrl/comics/$mangaSlug/chapters/$chapterNumber"
}
override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga)
override fun chapterListParse(response: Response): List<SChapter> {
return response.parseAs<ComicDto>().getChapters()
}
override fun pageListRequest(chapter: SChapter): Request {
val mangaSlug = chapter.url.substringBefore("/")
val chapterNumber = chapter.url.substringAfter("/")
return GET("$baseUrl/api/app/comic/$mangaSlug/chapter/$chapterNumber", headers)
}
override fun pageListParse(response: Response): List<Page> {
val data = response.parseAs<ChapterPagesDataDto>()
return data.chapter.urlImagesChapter.mapIndexed { index, image ->
Page(index, imageUrl = image)
}
}
private var genresList: List<Pair<String, String>> = emptyList()
private var fetchFiltersAttempts = 0
private var filtersState = FiltersState.NOT_FETCHED
private fun fetchFilters() {
if (filtersState != FiltersState.NOT_FETCHED || fetchFiltersAttempts >= 3) return
filtersState = FiltersState.FETCHING
fetchFiltersAttempts++
thread {
try {
val response = client.newCall(GET("$baseUrl/api/app/genres", headers)).execute()
val filters = response.parseAs<GenreListDto>()
genresList = filters.genres.map { genre -> genre.name.lowercase().replaceFirstChar { it.uppercase() } to genre.name }
filtersState = FiltersState.FETCHED
} catch (_: Throwable) {
filtersState = FiltersState.NOT_FETCHED
}
}
}
override fun getFilterList(): FilterList {
fetchFilters()
val filters = mutableListOf<Filter<*>>(
Filter.Header("El filtro por género no funciona con los demas filtros"),
Filter.Separator(),
SortByFilter(
"Ordenar por",
listOf(
SortProperty("Más vistos", "views"),
SortProperty("Más recientes", "created_at"),
),
1,
),
)
filters += if (filtersState == FiltersState.FETCHED) {
listOf(
Filter.Separator(),
Filter.Header("Filtrar por género"),
GenreFilter(genresList),
)
} else {
listOf(
Filter.Separator(),
Filter.Header("Presione 'Reiniciar' para intentar cargar los filtros"),
)
}
return FilterList(filters)
}
private enum class FiltersState { NOT_FETCHED, FETCHING, FETCHED }
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
private fun String.unescape(): String {
return UNESCAPE_REGEX.replace(this, "$1")
}
companion object {
private val UNESCAPE_REGEX = """\\(.)""".toRegex()
private val COMICS_LIST_REGEX = """\\"comicsData\\":(\{.*?\}),\\"searchParams""".toRegex()
}
}

View File

@ -0,0 +1,93 @@
package eu.kanade.tachiyomi.multisrc.lectormonline
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import keiyoushi.utils.tryParse
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonPrimitive
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
@Serializable
class ComicListDataDto(
val comics: List<ComicDto>,
private val page: Int,
private val totalPages: Int,
) {
fun hasNextPage() = page < totalPages
}
@Serializable
class ComicDto(
private val slug: String,
private val name: String,
private val state: String?,
private val urlCover: String,
private val description: String?,
private val author: String?,
private val chapters: List<ChapterDto> = emptyList(),
) {
fun toSManga() = SManga.create().apply {
url = slug
title = name.substringBeforeLast("-").trim()
thumbnail_url = urlCover
status = state.parseStatus()
}
fun toSMangaDetails() = SManga.create().apply {
url = slug
title = name.substringBeforeLast("-").trim()
thumbnail_url = urlCover
description = this@ComicDto.description
status = state.parseStatus()
author = this@ComicDto.author
}
fun getChapters(): List<SChapter> {
return chapters.map { it.toSChapter(slug) }
}
private fun String?.parseStatus(): Int {
return when (this?.lowercase()) {
"ongoing" -> SManga.ONGOING
else -> SManga.UNKNOWN
}
}
}
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
@Serializable
class ChapterDto(
private val number: JsonPrimitive,
private val createdAt: String,
) {
fun toSChapter(mangaSlug: String) = SChapter.create().apply {
url = "$mangaSlug/$number"
name = "Capítulo $number"
date_upload = dateFormat.tryParse(createdAt)
}
}
@Serializable
class ChapterPagesDataDto(
val chapter: ChapterPagesDto,
)
@Serializable
class ChapterPagesDto(
val urlImagesChapter: List<String> = emptyList(),
)
@Serializable
class GenreListDto(
val genres: List<GenreDto>,
)
@Serializable
class GenreDto(
val name: String,
)

View File

@ -0,0 +1,29 @@
package eu.kanade.tachiyomi.multisrc.lectormonline
import eu.kanade.tachiyomi.source.model.Filter
class SortByFilter(title: String, private val sortProperties: List<SortProperty>, defaultIndex: Int) : Filter.Sort(
title,
sortProperties.map { it.name }.toTypedArray(),
Selection(defaultIndex, ascending = false),
) {
val selected: String
get() = sortProperties[state!!.index].value
}
class SortProperty(val name: String, val value: String) {
override fun toString(): String = name
}
class GenreFilter(genres: List<Pair<String, String>>) : UriPartFilter(
"Género",
arrayOf(
Pair("Todos", ""),
*genres.toTypedArray(),
),
)
open class UriPartFilter(displayName: String, private val vals: Array<Pair<String, String>>) :
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
fun toUriPart() = vals[state].second
}

View File

@ -0,0 +1,10 @@
ext {
extName = 'Lector MOnline'
extClass = '.LectorMOnline'
themePkg = 'lectormonline'
baseUrl = 'https://www.lectormangas.online'
overrideVersionCode = 0
isNsfw = false
}
apply from: "$rootDir/common.gradle"

View File

@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.extension.es.lectormonline
import eu.kanade.tachiyomi.multisrc.lectormonline.LectorMOnline
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import java.util.concurrent.TimeUnit
class LectorMOnline : LectorMOnline(
name = "Lector MOnline",
baseUrl = "https://www.lectormangas.online",
lang = "es",
) {
override val client = network.cloudflareClient.newBuilder()
.rateLimit(3, 1, TimeUnit.SECONDS)
.build()
}

View File

@ -0,0 +1,10 @@
ext {
extName = 'MangasX'
extClass = '.MangasX'
themePkg = 'lectormonline'
baseUrl = 'https://mangasx.online'
overrideVersionCode = 0
isNsfw = true
}
apply from: "$rootDir/common.gradle"

View File

@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.extension.es.mangasx
import eu.kanade.tachiyomi.multisrc.lectormonline.LectorMOnline
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import java.util.concurrent.TimeUnit
class MangasX : LectorMOnline(
name = "MangasX",
baseUrl = "https://mangasx.online",
lang = "es",
) {
override val client = network.cloudflareClient.newBuilder()
.rateLimit(3, 1, TimeUnit.SECONDS)
.build()
}