New source: pt/Taiyō (#799)

* feat: Create Taiyō base

* feat: Implement popular manga page

* feat: Implement latest updates page

* feat: Implement search manga page

* feat: (finally) Implement manga details page

i hate it

* feat: Implement chapter list

* feat: Parse page list

* feat: Rate-limit requests to image CDN

* fix: Fix crash when using URL intent handler

* chore: Add source icon

* Optimize icons with pingo

---------

Co-authored-by: beerpiss <lacvtg.a1.2023@gmail.com>
This commit is contained in:
Claudemirovsky 2024-01-30 08:09:49 -03:00 committed by GitHub
parent 3bb8eb8b4c
commit c350d6f718
10 changed files with 424 additions and 0 deletions

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".pt.taiyo.TaiyoUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="www.taiyo.moe"
android:pathPattern="/media/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,8 @@
ext {
extName = 'Taiyō'
extClass = '.Taiyo'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,274 @@
package eu.kanade.tachiyomi.extension.pt.taiyo
import eu.kanade.tachiyomi.extension.pt.taiyo.dto.AdditionalInfoDto
import eu.kanade.tachiyomi.extension.pt.taiyo.dto.ChapterListDto
import eu.kanade.tachiyomi.extension.pt.taiyo.dto.MediaChapterDto
import eu.kanade.tachiyomi.extension.pt.taiyo.dto.ResponseDto
import eu.kanade.tachiyomi.extension.pt.taiyo.dto.SearchResultDto
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
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.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
class Taiyo : ParsedHttpSource() {
override val name = "Taiyō"
override val baseUrl = "https://www.taiyo.moe"
override val lang = "pt-BR"
override val supportsLatest = true
override val client = network.client.newBuilder()
.rateLimitHost(baseUrl.toHttpUrl(), 2)
.rateLimitHost(IMG_CDN.toHttpUrl(), 2)
.build()
private val json: Json by injectLazy()
// ============================== Popular ===============================
override fun popularMangaRequest(page: Int) = GET(baseUrl, headers)
override fun popularMangaSelector() = "main > div.flex > div.overflow-hidden div.flex > a"
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
setUrlWithoutDomain(element.attr("href"))
thumbnail_url = element.selectFirst("div.overflow-hidden > img")?.getImageUrl()
title = element.selectFirst("p")!!.text()
}
override fun popularMangaNextPageSelector() = null
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) = GET(baseUrl, headers)
override fun latestUpdatesSelector() = "main div.grow div.flex:has(div.grow)"
override fun latestUpdatesFromElement(element: Element) = SManga.create().apply {
with(element.selectFirst("a.line-clamp-1")!!) {
setUrlWithoutDomain(attr("href"))
title = text()
}
thumbnail_url = element.selectFirst("img")?.getImageUrl()?.replace("&w=128", "&w=256")
}
override fun latestUpdatesNextPageSelector() = null
// =============================== Search ===============================
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
val id = query.removePrefix(PREFIX_SEARCH)
client.newCall(GET("$baseUrl/media/$id"))
.asObservableSuccess()
.map(::searchMangaByIdParse)
} else {
super.fetchSearchManga(page, query, filters)
}
}
private fun searchMangaByIdParse(response: Response): MangasPage {
val details = mangaDetailsParse(response.asJsoup())
return MangasPage(listOf(details), false)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val jsonObj = buildJsonObject {
putJsonObject("0") {
putJsonObject("json") {
put("title", query)
}
}
}
val requestBody = json.encodeToString(jsonObj).toRequestBody(MEDIA_TYPE)
return POST("$baseUrl/api/trpc/medias.search?batch=1", headers, requestBody)
}
override fun searchMangaParse(response: Response): MangasPage {
val obj = response.parseAs<List<ResponseDto<List<SearchResultDto>>>>().first()
val mangas = obj.data.map { item ->
SManga.create().apply {
url = "/media/${item.id}"
title = item.title
thumbnail_url = item.coverId?.let {
"$baseUrl/_next/image?url=$IMG_CDN/${item.id}/covers/$it.jpg&w=256&q=75"
}
}
}
return MangasPage(mangas, false)
}
override fun searchMangaSelector(): String {
throw UnsupportedOperationException()
}
override fun searchMangaFromElement(element: Element): SManga {
throw UnsupportedOperationException()
}
override fun searchMangaNextPageSelector(): String? {
throw UnsupportedOperationException()
}
// =========================== Manga Details ============================
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
setUrlWithoutDomain(document.location())
thumbnail_url = document.selectFirst("section:has(h2) img")?.getImageUrl()
title = document.selectFirst("p.media-title")!!.text()
val additionalDataObj = document.parseJsonFromDocument<AdditionalInfoDto> {
substringBefore(",\\\"trackers\\\"") + "}"
}
genre = additionalDataObj?.genres?.joinToString { it.portugueseName }
status = when (additionalDataObj?.status.orEmpty()) {
"FINISHED" -> SManga.COMPLETED
"RELEASING" -> SManga.ONGOING
else -> SManga.UNKNOWN
}
description = buildString {
val synopsis = document.selectFirst("section > div.flex + div p")?.text()
?: additionalDataObj?.synopsis
synopsis?.also { append("$it\n\n") }
additionalDataObj?.titles?.takeIf { it.isNotEmpty() }?.run {
append("Títulos alternativos:")
forEach {
val languageName = Locale(it.language.substringBefore("_")).displayLanguage
append("\n\t$languageName: ${it.title}")
}
}
}
}
// ============================== Chapters ==============================
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val id = manga.url.substringAfter("/media/").trimEnd('/')
var page = 1
val apiUrl = "$baseUrl/api/trpc/mediaChapters.getByMediaId?batch=1".toHttpUrl()
val chapters = buildList {
do {
val input = buildJsonObject {
putJsonObject("0") {
putJsonObject("json") {
put("mediaId", id)
put("page", page)
put("perPage", 50)
}
}
}
page++
val pageUrl = apiUrl.newBuilder()
.addQueryParameter("input", json.encodeToString(input))
.build()
val res = client.newCall(GET(pageUrl, headers)).execute()
val parsed = res.parseAs<List<ResponseDto<ChapterListDto>>>().first().data
addAll(
parsed.chapters.map {
SChapter.create().apply {
chapter_number = it.number
name = it.title ?: "Capítulo ${it.number}".replace(".0", "")
url = "/chapter/${it.id}/1"
date_upload = it.createdAt.orEmpty().toDate()
}
},
)
} while (page <= parsed.totalPages)
}
return Observable.just(chapters.sortedByDescending { it.chapter_number })
}
override fun chapterListSelector(): String {
throw UnsupportedOperationException()
}
override fun chapterFromElement(element: Element): SChapter {
throw UnsupportedOperationException()
}
// =============================== Pages ================================
override fun pageListParse(document: Document): List<Page> {
val chapterObj = document.parseJsonFromDocument<MediaChapterDto>("mediaChapter") {
substringBefore(",\\\"chapters\\\"") + "}}"
}!!
val base = "$IMG_CDN/${chapterObj.media.id}/chapters/${chapterObj.id}"
return chapterObj.pages.mapIndexed { index, item ->
Page(index, imageUrl = "$base/${item.id}.jpg")
}
}
override fun imageUrlParse(document: Document): String {
throw UnsupportedOperationException()
}
// ============================= Utilities ==============================
private fun Element.getImageUrl() = absUrl("srcset").substringBefore(" ")
private inline fun <reified T> Response.parseAs(): T = use {
json.decodeFromStream(it.body.byteStream())
}
private fun String.toDate(): Long {
return runCatching { DATE_FORMATTER.parse(this)?.time }
.getOrNull() ?: 0L
}
private inline fun <reified T> Document.parseJsonFromDocument(
itemName: String = "media",
crossinline transformer: String.() -> String,
): T? {
return runCatching {
val script = selectFirst("script:containsData($itemName\\\\\":):containsData(\\\"6:\\[)")!!.data()
val obj = script.substringAfter(",{\\\"$itemName\\\":")
.run(transformer)
.replace("\\", "")
json.decodeFromString<T>(obj)
}.onFailure { it.printStackTrace() }.getOrNull()
}
companion object {
const val PREFIX_SEARCH = "id:"
private const val IMG_CDN = "https://cdn.taiyo.moe/medias"
private val MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private val DATE_FORMATTER by lazy {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.ENGLISH)
}
}
}

View File

@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.extension.pt.taiyo
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://www.taiyo.moe/media/<item> intents
* and redirects them to the main Tachiyomi process.
*/
class TaiyoUrlActivity : Activity() {
private val tag = javaClass.simpleName
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val item = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${Taiyo.PREFIX_SEARCH}$item")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e(tag, e.toString())
}
} else {
Log.e(tag, "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}

View File

@ -0,0 +1,79 @@
package eu.kanade.tachiyomi.extension.pt.taiyo.dto
import kotlinx.serialization.Serializable
@Serializable
data class ResponseDto<T>(val result: ResultDto<T>) {
val data: T = result.data.json
}
@Serializable
data class ResultDto<T>(val data: DataDto<T>)
@Serializable
data class DataDto<T>(val json: T)
@Serializable
data class SearchResultDto(
val id: String,
val title: String,
val coverId: String? = null,
)
@Serializable
data class AdditionalInfoDto(
val synopsis: String? = null,
val status: String? = null,
val genres: List<Genre>? = null,
val titles: List<TitleDto>? = null,
)
enum class Genre(val portugueseName: String) {
ACTION("Ação"),
ADVENTURE("Aventura"),
COMEDY("Comédia"),
DRAMA("Drama"),
ECCHI("Ecchi"),
FANTASY("Fantasia"),
HENTAI("Hentai"),
HORROR("Horror"),
MAHOU_SHOUJO("Mahou Shoujo"),
MECHA("Mecha"),
MUSIC("Música"),
MYSTERY("Mistério"),
PSYCHOLOGICAL("Psicológico"),
ROMANCE("Romance"),
SCI_FI("Sci-fi"),
SLICE_OF_LIFE("Slice of Life"),
SPORTS("Esportes"),
SUPERNATURAL("Sobrenatural"),
THRILLER("Thriller"),
}
@Serializable
data class TitleDto(val title: String, val language: String)
@Serializable
data class ChapterListDto(val chapters: List<ChapterDto>, val totalPages: Int)
@Serializable
data class ChapterDto(
val id: String,
val number: Float,
val scans: List<ScanDto>,
val title: String?,
val createdAt: String? = null,
)
@Serializable
data class ScanDto(val name: String)
@Serializable
data class MediaChapterDto(
val id: String,
val media: ItemId,
val pages: List<ItemId>,
)
@Serializable
data class ItemId(val id: String)