Add EternalMangas and move MangaEsp to multisrc (#2127)

* I made this on termux

* Fix regex and move dateFormat to DTO

* Phase 1

* Phase 2: Prepare for intl

* Phase 3: Add intl
Builds are faster on my phone T.T

* Apply suggestions from code review

* bump
This commit is contained in:
bapeey 2024-03-29 02:01:46 -05:00 committed by GitHub
parent d41a1237af
commit 4d5d03285b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 311 additions and 247 deletions

View File

@ -0,0 +1,13 @@
search_length_error=The query must have at least 2 characters
comics_list_error=Comics list not found
comic_data_error=Comic data not found
sort_by_filter_title=Sort by
sort_by_filter_name=Name
sort_by_filter_views=Views
sort_by_filter_updated=Updated
sort_by_filter_added=Added
status_filter_title=Status
status_filter_ongoing=Ongoing
status_filter_hiatus=Hiatus
status_filter_dropped=Dropped
status_filter_completed=Completed

View File

@ -0,0 +1,13 @@
search_length_error=La búsqueda debe tener al menos 2 caracteres
comics_list_error=No se pudo encontrar la lista de comics
comic_data_error=No se pudo encontrar los datos del comic
sort_by_filter_title=Ordenar por
sort_by_filter_name=Nombre
sort_by_filter_views=Vistas
sort_by_filter_updated=Actualización
sort_by_filter_added=Agregado
status_filter_title=Estado
status_filter_ongoing=En curso
status_filter_hiatus=En pausa
status_filter_dropped=Abandonado
status_filter_completed=Finalizado

View File

@ -0,0 +1,9 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 1
dependencies {
api(project(":lib:i18n"))
}

View File

@ -0,0 +1,248 @@
package eu.kanade.tachiyomi.multisrc.mangaesp
import eu.kanade.tachiyomi.lib.i18n.Intl
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
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 kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
import kotlin.math.min
abstract class MangaEsp(
override val name: String,
override val baseUrl: String,
override val lang: String,
protected val apiBaseUrl: String = baseUrl.replace("://", "://apis."),
) : HttpSource() {
override val supportsLatest = true
protected val json: Json by injectLazy()
protected val intl = Intl(
language = lang,
baseLanguage = "en",
availableLanguages = setOf("en", "es"),
classLoader = this::class.java.classLoader!!,
)
override val client: OkHttpClient = network.client.newBuilder()
.rateLimitHost(baseUrl.toHttpUrl(), 2)
.build()
override fun headersBuilder(): Headers.Builder = Headers.Builder()
.add("Referer", "$baseUrl/")
override fun popularMangaRequest(page: Int): Request = GET("$apiBaseUrl/api/topSerie", headers)
override fun popularMangaParse(response: Response): MangasPage {
val responseData = json.decodeFromString<TopSeriesDto>(response.body.string())
val topDaily = responseData.response.topDaily.flatten().map { it.data }
val topWeekly = responseData.response.topWeekly.flatten().map { it.data }
val topMonthly = responseData.response.topMonthly.flatten().map { it.data }
val mangas = (topDaily + topWeekly + topMonthly).distinctBy { it.slug }.map { it.toSManga() }
return MangasPage(mangas, false)
}
override fun latestUpdatesRequest(page: Int): Request = GET("$apiBaseUrl/api/lastUpdates", headers)
override fun latestUpdatesParse(response: Response): MangasPage {
val responseData = json.decodeFromString<LastUpdatesDto>(response.body.string())
val mangas = responseData.response.map { it.toSManga() }
return MangasPage(mangas, false)
}
private var comicsList = mutableListOf<SeriesDto>()
override fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<MangasPage> {
return if (comicsList.isEmpty()) {
client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess()
.map { searchMangaParse(it, page, query, filters) }
} else {
Observable.just(parseComicsList(page, query, filters))
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = GET("$baseUrl/comics", headers)
override fun searchMangaParse(response: Response): MangasPage = throw UnsupportedOperationException()
private fun searchMangaParse(response: Response, page: Int, query: String, filters: FilterList): MangasPage {
val document = response.asJsoup()
val script = document.select("script:containsData(self.__next_f.push)").joinToString { it.data() }
val jsonString = MANGA_LIST_REGEX.find(script)?.groupValues?.get(1)
?: throw Exception(intl["comics_list_error"])
val unescapedJson = jsonString.unescape()
comicsList = json.decodeFromString<List<SeriesDto>>(unescapedJson).toMutableList()
return parseComicsList(page, query, filters)
}
private var filteredList = mutableListOf<SeriesDto>()
private fun parseComicsList(page: Int, query: String, filterList: FilterList): MangasPage {
if (page == 1) {
filteredList.clear()
if (query.isNotBlank()) {
if (query.length < 2) throw Exception(intl["search_length_error"])
filteredList.addAll(
comicsList.filter {
it.name.contains(query, ignoreCase = true) || it.alternativeName?.contains(query, ignoreCase = true) == true
},
)
} else {
filteredList.addAll(comicsList)
}
val statusFilter = filterList.firstInstanceOrNull<StatusFilter>()
if (statusFilter != null) {
filteredList = filteredList.filter { it.status == statusFilter.toUriPart() }.toMutableList()
}
val sortByFilter = filterList.firstInstanceOrNull<SortByFilter>()
if (sortByFilter != null) {
if (sortByFilter.state?.ascending == true) {
when (sortByFilter.selected) {
"name" -> filteredList.sortBy { it.name }
"views" -> filteredList.sortBy { it.trending?.views }
"updated_at" -> filteredList.sortBy { it.lastChapterDate }
"created_at" -> filteredList.sortBy { it.createdAt }
}
} else {
when (sortByFilter.selected) {
"name" -> filteredList.sortByDescending { it.name }
"views" -> filteredList.sortByDescending { it.trending?.views }
"updated_at" -> filteredList.sortByDescending { it.lastChapterDate }
"created_at" -> filteredList.sortByDescending { it.createdAt }
}
}
}
}
val hasNextPage = filteredList.size > page * MANGAS_PER_PAGE
return MangasPage(
filteredList.subList((page - 1) * MANGAS_PER_PAGE, min(page * MANGAS_PER_PAGE, filteredList.size))
.map { it.toSManga() },
hasNextPage,
)
}
override fun mangaDetailsParse(response: Response): SManga {
val responseBody = response.body.string()
val mangaDetailsJson = MANGA_DETAILS_REGEX.find(responseBody)?.groupValues?.get(1)
?: throw Exception(intl["comic_data_error"])
val unescapedJson = mangaDetailsJson.unescape()
return json.decodeFromString<SeriesDto>(unescapedJson).toSMangaDetails()
}
override fun chapterListParse(response: Response): List<SChapter> {
val responseBody = response.body.string()
val mangaDetailsJson = MANGA_DETAILS_REGEX.find(responseBody)?.groupValues?.get(1)
?: throw Exception(intl["comic_data_error"])
val unescapedJson = mangaDetailsJson.unescape()
val series = json.decodeFromString<SeriesDto>(unescapedJson)
return series.chapters.map { it.toSChapter(series.slug) }
}
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
return document.select("main.contenedor.read img").mapIndexed { i, img ->
Page(i, imageUrl = img.imgAttr())
}
}
override fun getFilterList() = FilterList(
SortByFilter(intl["sort_by_filter_title"], getSortProperties()),
StatusFilter(intl["status_filter_title"], getStatusList()),
)
protected open fun getSortProperties(): List<SortProperty> = listOf(
SortProperty(intl["sort_by_filter_name"], "name"),
SortProperty(intl["sort_by_filter_views"], "views"),
SortProperty(intl["sort_by_filter_updated"], "updated_at"),
SortProperty(intl["sort_by_filter_added"], "created_at"),
)
data class SortProperty(val name: String, val value: String) {
override fun toString(): String = name
}
class SortByFilter(title: String, private val sortProperties: List<SortProperty>) : Filter.Sort(
title,
sortProperties.map { it.name }.toTypedArray(),
Selection(2, ascending = false),
) {
val selected: String
get() = sortProperties[state!!.index].value
}
private class StatusFilter(title: String, statusList: Array<Pair<String, Int>>) : UriPartFilter(
title,
statusList,
)
protected open fun getStatusList() = arrayOf(
Pair(intl["status_filter_ongoing"], 1),
Pair(intl["status_filter_hiatus"], 2),
Pair(intl["status_filter_dropped"], 3),
Pair(intl["status_filter_completed"], 4),
)
private open class UriPartFilter(displayName: String, private val vals: Array<Pair<String, Int>>) :
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
fun toUriPart() = vals[state].second
}
private inline fun <reified R> List<*>.firstInstanceOrNull(): R? =
filterIsInstance<R>().firstOrNull()
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
private fun Element.imgAttr(): String = when {
hasAttr("data-lazy-src") -> attr("abs:data-lazy-src")
hasAttr("data-src") -> attr("abs:data-src")
hasAttr("data-cfsrc") -> attr("abs:data-cfsrc")
else -> attr("abs:src")
}
private fun String.unescape(): String {
return UNESCAPE_REGEX.replace(this, "$1")
}
companion object {
private val UNESCAPE_REGEX = """\\(.)""".toRegex()
private val MANGA_LIST_REGEX = """self\.__next_f\.push\(.*data\\":(\[.*trending.*])\}""".toRegex()
private val MANGA_DETAILS_REGEX = """self\.__next_f\.push\(.*data\\":(\{.*lastChapters.*\}).*\\"numFollow""".toRegex()
private const val MANGAS_PER_PAGE = 15
}
}

View File

@ -1,10 +1,11 @@
package eu.kanade.tachiyomi.extension.es.mangaesp
package eu.kanade.tachiyomi.multisrc.mangaesp
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Locale
@Serializable
class TopSeriesDto(
@ -101,18 +102,22 @@ class ChapterDto(
private val slug: String,
@SerialName("created_at") private val date: String,
) {
fun toSChapter(seriesSlug: String, dateFormat: SimpleDateFormat): SChapter {
fun toSChapter(seriesSlug: String): SChapter {
return SChapter.create().apply {
name = "Capítulo ${number.toString().removeSuffix(".0")}"
if (!this@ChapterDto.name.isNullOrBlank()) {
name += " - ${this@ChapterDto.name}"
}
date_upload = try {
dateFormat.parse(date)?.time ?: 0L
DATE_FORMATTER.parse(date)?.time ?: 0L
} catch (e: Exception) {
0L
}
url = "/ver/$seriesSlug/$slug"
}
}
companion object {
private val DATE_FORMATTER by lazy { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) }
}
}

View File

@ -0,0 +1,10 @@
ext {
extName = 'EternalMangas'
extClass = '.EternalMangas'
themePkg = 'mangaesp'
baseUrl = 'https://eternalmangas.com'
overrideVersionCode = 0
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -0,0 +1,5 @@
package eu.kanade.tachiyomi.extension.es.eternalmangas
import eu.kanade.tachiyomi.multisrc.mangaesp.MangaEsp
class EternalMangas : MangaEsp("EternalMangas", "https://eternalmangas.com", "es")

View File

@ -1,8 +1,9 @@
ext {
extName = 'MangaEsp'
extClass = '.MangaEsp'
extVersionCode = 2
isNsfw = false
themePkg = 'mangaesp'
baseUrl = 'https://mangaesp.net'
overrideVersionCode = 2
}
apply from: "$rootDir/common.gradle"

View File

@ -1,245 +1,5 @@
package eu.kanade.tachiyomi.extension.es.mangaesp
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
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 kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.min
import eu.kanade.tachiyomi.multisrc.mangaesp.MangaEsp
class MangaEsp : HttpSource() {
override val name = "MangaEsp"
override val baseUrl = "https://mangaesp.net"
private val apiBaseUrl = "https://apis.mangaesp.net"
override val lang = "es"
override val supportsLatest = true
private val json: Json by injectLazy()
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
override val client: OkHttpClient = network.client.newBuilder()
.rateLimitHost(baseUrl.toHttpUrl(), 2)
.build()
override fun headersBuilder(): Headers.Builder = Headers.Builder()
.add("Referer", "$baseUrl/")
override fun popularMangaRequest(page: Int): Request = GET("$apiBaseUrl/api/topSerie", headers)
override fun popularMangaParse(response: Response): MangasPage {
val responseData = json.decodeFromString<TopSeriesDto>(response.body.string())
val topDaily = responseData.response.topDaily.flatten().map { it.data }
val topWeekly = responseData.response.topWeekly.flatten().map { it.data }
val topMonthly = responseData.response.topMonthly.flatten().map { it.data }
val mangas = (topDaily + topWeekly + topMonthly).distinctBy { it.slug }.map { it.toSManga() }
return MangasPage(mangas, false)
}
override fun latestUpdatesRequest(page: Int): Request = GET("$apiBaseUrl/api/lastUpdates", headers)
override fun latestUpdatesParse(response: Response): MangasPage {
val responseData = json.decodeFromString<LastUpdatesDto>(response.body.string())
val mangas = responseData.response.map { it.toSManga() }
return MangasPage(mangas, false)
}
private var comicsList = mutableListOf<SeriesDto>()
override fun fetchSearchManga(
page: Int,
query: String,
filters: FilterList,
): Observable<MangasPage> {
return if (comicsList.isEmpty()) {
client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess()
.map { searchMangaParse(it, page, query, filters) }
} else {
Observable.just(parseComicsList(page, query, filters))
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = GET("$baseUrl/comics", headers)
override fun searchMangaParse(response: Response): MangasPage = throw UnsupportedOperationException()
private fun searchMangaParse(response: Response, page: Int, query: String, filters: FilterList): MangasPage {
val document = response.asJsoup()
val script = document.select("script:containsData(self.__next_f.push)").joinToString { it.data() }
val jsonString = MANGA_LIST_REGEX.find(script)?.groupValues?.get(1)
?: throw Exception("No se pudo encontrar la lista de comics")
val unescapedJson = jsonString.unescape()
comicsList = json.decodeFromString<List<SeriesDto>>(unescapedJson).toMutableList()
return parseComicsList(page, query, filters)
}
private var filteredList = mutableListOf<SeriesDto>()
private fun parseComicsList(page: Int, query: String, filterList: FilterList): MangasPage {
if (page == 1) {
filteredList.clear()
if (query.isNotBlank()) {
if (query.length < 2) throw Exception("La búsqueda debe tener al menos 2 caracteres")
filteredList.addAll(
comicsList.filter {
it.name.contains(query, ignoreCase = true) || it.alternativeName?.contains(query, ignoreCase = true) == true
},
)
} else {
filteredList.addAll(comicsList)
}
val statusFilter = filterList.firstInstanceOrNull<StatusFilter>()
if (statusFilter != null) {
filteredList = filteredList.filter { it.status == statusFilter.toUriPart() }.toMutableList()
}
val sortByFilter = filterList.firstInstanceOrNull<SortByFilter>()
if (sortByFilter != null) {
if (sortByFilter.state?.ascending == true) {
when (sortByFilter.selected) {
"name" -> filteredList.sortBy { it.name }
"views" -> filteredList.sortBy { it.trending?.views }
"updated_at" -> filteredList.sortBy { it.lastChapterDate }
"created_at" -> filteredList.sortBy { it.createdAt }
}
} else {
when (sortByFilter.selected) {
"name" -> filteredList.sortByDescending { it.name }
"views" -> filteredList.sortByDescending { it.trending?.views }
"updated_at" -> filteredList.sortByDescending { it.lastChapterDate }
"created_at" -> filteredList.sortByDescending { it.createdAt }
}
}
}
}
val hasNextPage = filteredList.size > page * MANGAS_PER_PAGE
return MangasPage(
filteredList.subList((page - 1) * MANGAS_PER_PAGE, min(page * MANGAS_PER_PAGE, filteredList.size))
.map { it.toSManga() },
hasNextPage,
)
}
override fun mangaDetailsParse(response: Response): SManga {
val responseBody = response.body.string()
val mangaDetailsJson = MANGA_DETAILS_REGEX.find(responseBody)?.groupValues?.get(1)
?: throw Exception("No se pudo encontrar los detalles del manga")
val unescapedJson = mangaDetailsJson.unescape()
return json.decodeFromString<SeriesDto>(unescapedJson).toSMangaDetails()
}
override fun chapterListParse(response: Response): List<SChapter> {
val responseBody = response.body.string()
val mangaDetailsJson = MANGA_DETAILS_REGEX.find(responseBody)?.groupValues?.get(1)
?: throw Exception("No se pudo encontrar la lista de capítulos")
val unescapedJson = mangaDetailsJson.unescape()
val series = json.decodeFromString<SeriesDto>(unescapedJson)
return series.chapters.map { it.toSChapter(series.slug, dateFormat) }
}
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
return document.select("main.contenedor.read img").mapIndexed { i, img ->
Page(i, imageUrl = img.imgAttr())
}
}
override fun getFilterList() = FilterList(
SortByFilter("Ordenar por", getSortProperties()),
StatusFilter(),
)
private fun getSortProperties(): List<SortProperty> = listOf(
SortProperty("Nombre", "name"),
SortProperty("Visitas", "views"),
SortProperty("Actualización", "updated_at"),
SortProperty("Agregado", "created_at"),
)
data class SortProperty(val name: String, val value: String) {
override fun toString(): String = name
}
class SortByFilter(title: String, private val sortProperties: List<SortProperty>) : Filter.Sort(
title,
sortProperties.map { it.name }.toTypedArray(),
Selection(2, ascending = false),
) {
val selected: String
get() = sortProperties[state!!.index].value
}
private class StatusFilter : UriPartFilter(
"Estado",
arrayOf(
Pair("En emisión", 1),
Pair("En pausa", 2),
Pair("Abandonado", 3),
Pair("Finalizado", 4),
),
)
private open class UriPartFilter(displayName: String, private val vals: Array<Pair<String, Int>>) :
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
fun toUriPart() = vals[state].second
}
private inline fun <reified R> List<*>.firstInstanceOrNull(): R? =
filterIsInstance<R>().firstOrNull()
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
private fun Element.imgAttr(): String = when {
hasAttr("data-lazy-src") -> attr("abs:data-lazy-src")
hasAttr("data-src") -> attr("abs:data-src")
hasAttr("data-cfsrc") -> attr("abs:data-cfsrc")
else -> attr("abs:src")
}
private fun String.unescape(): String {
return UNESCAPE_REGEX.replace(this, "$1")
}
companion object {
private val UNESCAPE_REGEX = """\\(.)""".toRegex()
private val MANGA_LIST_REGEX = """self\.__next_f\.push\(.*data\\":(\[.*trending.*])\}""".toRegex()
private val MANGA_DETAILS_REGEX = """self\.__next_f\.push\(.*data\\":(\{.*lastChapters.*\}).*numFollow""".toRegex()
private const val MANGAS_PER_PAGE = 15
}
}
class MangaEsp : MangaEsp("MangaEsp", "https://mangaesp.net", "es")