Add new source "MangaTigre" (#16444)

* Add MangaTigre

* Keep title

* Add more genres to manga

* Use StringBuilder for create description

* Minor changes
This commit is contained in:
Rolando Lecca 2023-05-19 05:15:31 -05:00 committed by GitHub
parent 57c5e0c896
commit 3091cd6fdc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 622 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.extension" />

View File

@ -0,0 +1,13 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'MangaTigre'
pkgNameSuffix = 'es.mangatigre'
extClass = '.MangaTigre'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -0,0 +1,384 @@
package eu.kanade.tachiyomi.extension.es.mangatigre
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
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.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.Buffer
import org.jsoup.nodes.Document
import uy.kohesive.injekt.injectLazy
import java.util.Calendar
class MangaTigre : HttpSource() {
override val name = "MangaTigre"
override val baseUrl = "https://www.mangatigre.net"
override val lang = "es"
override val supportsLatest = true
private val json: Json by injectLazy()
private val imgCDNUrl = "https://i2.mtcdn.xyz"
private var mtToken = ""
override val client: OkHttpClient = network.client.newBuilder()
.addInterceptor { chain ->
val request = chain.request()
if (request.method == "POST") {
val response = chain.proceed(request)
if (response.code == 419) {
response.close()
getToken()
val newBody = json.parseToJsonElement(request.bodyString).jsonObject.toMutableMap().apply {
this["_token"] = JsonPrimitive(mtToken)
}
val payload = Json.encodeToString(JsonObject(newBody)).toRequestBody(JSON_MEDIA_TYPE)
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Content-Type", payload.contentType().toString())
.build()
val newRequest = request.newBuilder()
.headers(apiHeaders)
.method(request.method, payload)
.build()
return@addInterceptor chain.proceed(newRequest)
}
return@addInterceptor response
}
chain.proceed(request)
}
.rateLimitHost(baseUrl.toHttpUrl(), 1, 2)
.build()
private fun getToken() {
val document = client.newCall(GET(baseUrl, headers)).execute().asJsoup()
mtToken = document.selectFirst("input.input-search[data-csrf]")!!.attr("data-csrf")
}
override fun popularMangaRequest(page: Int): Request {
val payloadObj = PayloadManga(
page = page,
token = mtToken,
)
val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Content-Type", payload.contentType().toString())
.build()
return POST("$baseUrl/mangas?sort=views", apiHeaders, payload)
}
override fun popularMangaParse(response: Response): MangasPage {
val jsonString = response.body.string()
val result = json.decodeFromString<MangasDto>(jsonString)
val mangas = result.mangas.map {
SManga.create().apply {
setUrlWithoutDomain("$baseUrl/manga/${it.slug}")
title = it.title
thumbnail_url = "$imgCDNUrl/mangas/${it.thumbnailFileName}"
}
}
val hasNextPage = result.totalPages > result.page
return MangasPage(mangas, hasNextPage)
}
override fun latestUpdatesRequest(page: Int): Request {
val payloadObj = PayloadManga(
page = page,
token = mtToken,
)
val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Content-Type", payload.contentType().toString())
.build()
return POST("$baseUrl/mangas?sort=date", apiHeaders, payload)
}
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (query.isNotEmpty()) {
if (query.length < 2) throw Exception("La cadena de búsqueda debe tener por lo menos 2 caracteres")
val payloadObj = PayloadSearch(
query = query,
token = mtToken,
)
val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Content-Type", payload.contentType().toString())
.build()
return POST("$baseUrl/mangas/search#$query", apiHeaders, payload)
}
val url = "$baseUrl/mangas".toHttpUrlOrNull()!!.newBuilder()
filters.forEach { filter ->
when (filter) {
is OrderFilter -> {
url.addQueryParameter("sort", filter.toUriPart())
}
is TypeFilter -> {
filter.state.forEach { content ->
if (content.state) url.addQueryParameter("type[]", content.id)
}
}
is StatusFilter -> {
filter.state.forEach { content ->
if (content.state) url.addQueryParameter("status[]", content.id)
}
}
is DemographicFilter -> {
filter.state.forEach { content ->
if (content.state) url.addQueryParameter("demographic[]", content.id)
}
}
is ContentFilter -> {
filter.state.forEach { content ->
if (content.state) url.addQueryParameter("content[]", content.id)
}
}
is FormatFilter -> {
filter.state.forEach { content ->
if (content.state) url.addQueryParameter("format[]", content.id)
}
}
is GenreFilter -> {
filter.state.forEach { content ->
if (content.state) url.addQueryParameter("genre[]", content.id)
}
}
is ThemeFilter -> {
filter.state.forEach { content ->
if (content.state) url.addQueryParameter("theme[]", content.id)
}
}
else -> {}
}
}
val payloadObj = PayloadManga(
page = page,
token = mtToken,
)
val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Content-Type", payload.contentType().toString())
.build()
return POST(url.build().toString(), apiHeaders, payload)
}
override fun searchMangaParse(response: Response): MangasPage {
val query = response.request.url.fragment
val jsonString = response.body.string()
if (!query.isNullOrEmpty()) {
val result = json.decodeFromString<SearchDto>(jsonString)
val mangas = result.result.map {
SManga.create().apply {
setUrlWithoutDomain("$baseUrl/manga/${it.slug}")
title = it.title
thumbnail_url = "$imgCDNUrl/mangas/${it.thumbnailFileName}"
}
}
return MangasPage(mangas, false)
}
val result = json.decodeFromString<MangasDto>(jsonString)
val mangas = result.mangas.map {
SManga.create().apply {
setUrlWithoutDomain("$baseUrl/manga/${it.slug}")
title = it.title
thumbnail_url = "$imgCDNUrl/mangas/${it.thumbnailFileName}"
}
}
val hasNextPage = result.totalPages > result.page
return MangasPage(mangas, hasNextPage)
}
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
return SManga.create().apply {
description = createDescription(document)
genre = createGenres(document)
thumbnail_url = document.selectFirst("div.manga-image > img")!!.attr("abs:data-src")
author = document.selectFirst("li.list-group-item:has(strong:contains(Autor)) > a")?.ownText()?.trim()
artist = document.selectFirst("li.list-group-item:has(strong:contains(Artista)) > a")?.ownText()?.trim()
status = document.selectFirst("li.list-group-item:has(strong:contains(Estado))")?.ownText()?.trim()!!.toStatus()
}
}
private fun createGenres(document: Document): String {
val demographic = document.select("li.list-group-item:has(strong:contains(Demografía)) a").joinToString { it.text() }
val genres = document.select("li.list-group-item:has(strong:contains(Géneros)) a").joinToString { it.text() }
val themes = document.select("li.list-group-item:has(strong:contains(Temas)) a").joinToString { it.text() }
val content = document.select("li.list-group-item:has(strong:contains(Contenido)) a").joinToString { it.text() }
return listOf(demographic, genres, themes, content).joinToString(", ")
}
private fun createDescription(document: Document): String {
val originalName = document.selectFirst("li.list-group-item:has(strong:contains(Original))")?.ownText()?.trim() ?: ""
val alternativeName = document.select("li.list-group-item:has(strong:contains(Alternativo)) span.alter-name").text()
val year = document.selectFirst("li.list-group-item:has(strong:contains(Año))")?.ownText()?.trim() ?: ""
val animeAdaptation = document.selectFirst("li.list-group-item:has(strong:contains(Anime))")?.ownText()?.trim() ?: ""
val country = document.selectFirst("li.list-group-item:has(strong:contains(País))")?.ownText()?.trim() ?: ""
val summary = document.selectFirst("div.synopsis > p")?.ownText()?.trim() ?: ""
return StringBuilder()
.appendLine("Nombre Original: $originalName")
.appendLine("Títulos Alternativos: $alternativeName")
.appendLine("Año: $year")
.appendLine("Adaptación al Anime: $animeAdaptation")
.appendLine("País: $country")
.appendLine()
.appendLine("Sinopsis: $summary")
.toString()
}
override fun chapterListRequest(manga: SManga): Request {
val payloadObj = PayloadChapter(
token = mtToken,
)
val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Content-Type", payload.contentType().toString())
.build()
return POST("$baseUrl${manga.url}", apiHeaders, payload)
}
override fun chapterListParse(response: Response): List<SChapter> {
return response.asJsoup().select("li").map {
SChapter.create().apply {
setUrlWithoutDomain(it.select("a").attr("href"))
name = it.selectFirst("a")!!.ownText().trim()
date_upload = parseRelativeDate(it.selectFirst("span")!!.ownText().trim())
}
}
}
override fun pageListParse(response: Response): List<Page> {
val document = response.asJsoup()
val script = document.selectFirst("script:containsData(window.chapter)")!!.data()
val jsonString = CHAPTERS_REGEX.find(script)!!.groupValues[1]
val result = json.decodeFromString<ChapterDto>(jsonString)
val slug = result.manga.slug
val number = result.number
return result.images.map {
val imageUrl = "$imgCDNUrl/chapters/$slug/$number/${it.value.name}.${it.value.format}"
Page(it.key.toInt(), "", imageUrl)
}.sortedBy { it.index }
}
override fun getFilterList() = FilterList(
Filter.Header("Los filtros serán ignorados si se realiza una búsqueda textual"),
Filter.Separator(),
OrderFilter(),
Filter.Separator(),
TypeFilter(getFilterTypeList()),
Filter.Separator(),
StatusFilter(getFilterStatusList()),
Filter.Separator(),
DemographicFilter(getFilterDemographicList()),
Filter.Separator(),
ContentFilter(getFilterContentList()),
Filter.Separator(),
FormatFilter(getFilterFormatList()),
Filter.Separator(),
GenreFilter(getFilterGenreList()),
Filter.Separator(),
ThemeFilter(getFilterThemeList()),
)
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used.")
private fun parseRelativeDate(date: String): Long {
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
val cal = Calendar.getInstance()
return when {
WordSet("segundo").anyWordIn(date) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
WordSet("minuto").anyWordIn(date) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
WordSet("hora").anyWordIn(date) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
WordSet("día").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
WordSet("semana").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis
WordSet("mes").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
WordSet("año").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
else -> 0
}
}
class WordSet(private vararg val words: String) {
fun anyWordIn(dateString: String): Boolean = words.any { dateString.contains(it, ignoreCase = true) }
}
private val Request.bodyString: String
get() {
val requestCopy = newBuilder().build()
val buffer = Buffer()
return runCatching { buffer.apply { requestCopy.body!!.writeTo(this) }.readUtf8() }
.getOrNull() ?: ""
}
companion object {
private const val ACCEPT_JSON = "application/json, text/plain, */*"
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
private val CHAPTERS_REGEX = """window\.chapter\s*=\s*'(.+?)';""".toRegex()
}
}

View File

@ -0,0 +1,75 @@
package eu.kanade.tachiyomi.extension.es.mangatigre
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PayloadManga(
val page: Int,
@SerialName("_token") val token: String,
)
@Serializable
data class PayloadChapter(
@SerialName("_token") val token: String,
)
@Serializable
data class PayloadSearch(
val query: String,
@SerialName("_token") val token: String,
)
@Serializable
data class MangasDto(
@SerialName("current_page") val page: Int,
@SerialName("last_page") val totalPages: Int,
@SerialName("data") val mangas: List<MangasDataDto>,
)
@Serializable
data class MangasDataDto(
@SerialName("name") val title: String,
val slug: String,
@SerialName("image") val thumbnailFileName: String,
)
@Serializable
data class ChapterDto(
val manga: ChapterMangaInfoDto,
val number: Int,
val images: Map<String, ChapterImagesDto>,
)
@Serializable
data class ChapterMangaInfoDto(
val slug: String,
)
@Serializable
data class ChapterImagesDto(
val name: String,
val format: String,
)
@Serializable
data class SearchDto(
val result: List<SearchDataDto>,
)
@Serializable
data class SearchDataDto(
val id: Int,
@SerialName("name") val title: String,
val slug: String,
@SerialName("image")val thumbnailFileName: String,
)
fun String.toStatus(): Int = when (this) {
"En Marcha" -> SManga.ONGOING
"Terminado" -> SManga.COMPLETED
"Detenido" -> SManga.ON_HIATUS
"Pausado" -> SManga.ON_HIATUS
else -> SManga.UNKNOWN
}

View File

@ -0,0 +1,148 @@
package eu.kanade.tachiyomi.extension.es.mangatigre
import eu.kanade.tachiyomi.source.model.Filter
class Type(name: String, val id: String) : Filter.CheckBox(name)
class TypeFilter(values: List<Type>) : Filter.Group<Type>("Tipos", values)
class Status(name: String, val id: String) : Filter.CheckBox(name)
class StatusFilter(values: List<Status>) : Filter.Group<Status>("Estado", values)
class Demographic(name: String, val id: String) : Filter.CheckBox(name)
class DemographicFilter(values: List<Demographic>) : Filter.Group<Demographic>("Demografía", values)
class Content(name: String, val id: String) : Filter.CheckBox(name)
class ContentFilter(values: List<Content>) : Filter.Group<Content>("Contenido", values)
class Format(name: String, val id: String) : Filter.CheckBox(name)
class FormatFilter(values: List<Format>) : Filter.Group<Format>("Formato", values)
class Genre(name: String, val id: String) : Filter.CheckBox(name)
class GenreFilter(values: List<Genre>) : Filter.Group<Genre>("Géneros", values)
class Theme(name: String, val id: String) : Filter.CheckBox(name)
class ThemeFilter(values: List<Theme>) : Filter.Group<Theme>("Temas", values)
open class UriPartFilter(displayName: String, val vals: Array<Pair<String, String>>) :
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
fun toUriPart() = vals[state].second
}
class OrderFilter() : UriPartFilter(
"Ordenar por",
arrayOf(
Pair("Alfabético", "name"),
Pair("Vistas", "views"),
Pair("Fecha Estreno", "date"),
),
)
fun getFilterTypeList() = listOf(
Type("Manga", "1"),
Type("Manhwa", "2"),
Type("Manhua", "3"),
)
fun getFilterStatusList() = listOf(
Status("En Marcha", "1"),
Status("Terminado", "2"),
Status("Detenido", "3"),
Status("Pausado", "4"),
)
fun getFilterDemographicList() = listOf(
Demographic("Shonen", "1"),
Demographic("Seinen", "2"),
Demographic("Shojo", "3"),
Demographic("Josei", "4"),
)
fun getFilterContentList() = listOf(
Content("Ecchi", "1"),
Content("Gore", "2"),
Content("Smut", "3"),
Content("Violencia Sexual", "4"),
)
fun getFilterFormatList() = listOf(
Format("Adaptación", "14"),
Format("Antalogía", "9"),
Format("Color Completo", "18"),
Format("Coloreado Oficial", "19"),
Format("Coloreado Por Fan", "15"),
Format("Creado Por Usuario", "20"),
Format("Delincuencia", "16"),
Format("Doujinshi", "10"),
Format("Galardonado", "13"),
Format("One Shot", "11"),
Format("Tira Larga", "17"),
Format("Webcomic", "12"),
Format("YonKoma", "8"),
)
fun getFilterGenreList() = listOf(
Genre("Acción", "49"),
Genre("Aventura", "50"),
Genre("Boys Love", "75"),
Genre("Chicas Mágicas", "73"),
Genre("Ciencia-Ficción", "64"),
Genre("Comedia", "51"),
Genre("Crimen", "52"),
Genre("Deporte", "65"),
Genre("Drama", "53"),
Genre("Fantasía", "54"),
Genre("Filosófico", "61"),
Genre("Girls Love", "76"),
Genre("Guerra", "74"),
Genre("Histórico", "55"),
Genre("Horror", "56"),
Genre("Isekai", "57"),
Genre("Mecha", "58"),
Genre("Médica", "59"),
Genre("Misterio", "60"),
Genre("Psicológico", "62"),
Genre("Recuentos De La Vida", "72"),
Genre("Romance", "63"),
Genre("Superhéroe", "66"),
Genre("Thriller", "67"),
Genre("Tragedia", "68"),
Genre("Wuxia", "69"),
Genre("Yaoi", "70"),
Genre("Yuri", "71"),
)
fun getFilterThemeList() = listOf(
Theme("Animales", "52"),
Theme("Apocalíptico", "50"),
Theme("Artes Marciales", "60"),
Theme("Chicas Monstruo", "77"),
Theme("Cocinando", "53"),
Theme("Crossdressing", "79"),
Theme("Delincuencia", "78"),
Theme("Demonios", "54"),
Theme("Extranjeros", "51"),
Theme("Fantasma", "55"),
Theme("Género Bender", "81"),
Theme("Gyaru", "56"),
Theme("Harén", "57"),
Theme("Incesto", "58"),
Theme("Lolicon", "59"),
Theme("Mafia", "64"),
Theme("Magia", "65"),
Theme("Militar", "61"),
Theme("Monstruos", "62"),
Theme("Música", "63"),
Theme("Ninja", "66"),
Theme("Policía", "67"),
Theme("Realidad Virtual", "74"),
Theme("Reencarnación", "68"),
Theme("Samurái", "73"),
Theme("Shotacon", "71"),
Theme("Sobrenatural", "69"),
Theme("Superpoderes", "82"),
Theme("Supervivencia", "72"),
Theme("Vampiros", "75"),
Theme("Vida Escolar", "70"),
Theme("Videojuegos", "80"),
Theme("Zombis", "76"),
)