Add toptoon.net (#10160)

* Add toptoon.net

* Use HttpSource

* Use parseAs from core

* Use SimpleDateFormat.tryParse

* Use fragment for search query

* Add "lock emoji" comment

* Use plain class for dto

* Fix MangaDto

* Fix ThumbnailDto

* Fix name

* Fix extName

* Update comment
This commit is contained in:
tanaka-shizuku3 2025-08-17 21:21:09 +08:00 committed by Draff
parent 36eb58e893
commit 4587ac2c1d
Signed by: Draff
GPG Key ID: E8A89F3211677653
8 changed files with 187 additions and 0 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,134 @@
package eu.kanade.tachiyomi.extension.zh.toptoon
import eu.kanade.tachiyomi.network.GET
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 keiyoushi.utils.tryParse
import okhttp3.Response
import java.text.SimpleDateFormat
import java.util.Locale
class Toptoon : HttpSource() {
override val name: String = "TOPTOON頂通"
override val lang: String = "zh"
override val supportsLatest = true
override val baseUrl = "https://www.toptoon.net"
// Popular
override fun popularMangaRequest(page: Int) = GET("$baseUrl/ranking", headers)
override fun popularMangaParse(response: Response): MangasPage {
val jsonUrl = response.body.string()
.substringAfter("jsonFileUrl: [\"")
.substringBefore("\"")
.replace("\\/", "/")
val jsonResponse = client.newCall(GET("https:$jsonUrl", headers)).execute()
val mangas = jsonResponse.parseAs<PopularResponseDto>().adult.map {
it.toSManga()
}
return MangasPage(mangas, false)
}
// Latest
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/search", headers)
override fun latestUpdatesParse(response: Response): MangasPage {
val jsonUrl = response.body.string()
.substringAfter("var jsonFileUrl = '")
.substringBefore("'")
val jsonResponse = client.newCall(GET("https:$jsonUrl", headers)).execute()
val mangas = jsonResponse.parseAs<Map<String, MangaDto>>().values
.sortedByDescending { it.lastUpdated.pubDate }
.map {
it.toSManga()
}
return MangasPage(mangas, false)
}
// Search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = GET("$baseUrl/search#$query", headers)
override fun searchMangaParse(response: Response): MangasPage {
val query = response.request.url.fragment!!
val jsonUrl = response.body.string()
.substringAfter("var jsonFileUrl = '")
.substringBefore("'")
val jsonResponse = client.newCall(GET("https:$jsonUrl", headers)).execute()
val mangas = jsonResponse.parseAs<Map<String, MangaDto>>().values
.map {
it.toSManga()
}
.filter { it.title.contains(query, true) || it.author!!.contains(query, true) }
return MangasPage(mangas, false)
}
// Details
override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
val document = response.asJsoup()
title = document.selectFirst("section.infoContent div.title")!!.text()
thumbnail_url = document.selectFirst("div.comicThumb img")!!.absUrl("src")
author = document.selectFirst("section.infoContent div.etc")!!.text()
.substringAfter("作家 : ").substringBefore("|")
description = document.selectFirst("div.comic_story div.desc")!!.text()
genre = document.selectFirst("section.infoContent div.hashTag")?.text()
?.replace("#", ", ")
if (document.selectFirst("div.etc span.comicDayBox") != null) {
status = SManga.ONGOING
} else if (document.selectFirst("div.hashTag a[href=/search/keyword/79]") != null) {
status = SManga.COMPLETED
}
}
// Chapters
override fun chapterListParse(response: Response): List<SChapter> {
if (response.request.url.pathSegments[0].isEmpty()) {
throw Exception("请到WebView确认年满18岁")
}
val document = response.asJsoup()
return document.select("section.episode_area ul.list_area li.episodeBox").map {
SChapter.create().apply {
setUrlWithoutDomain(it.selectFirst("a")!!.absUrl("href"))
name = if (it.selectFirst("button.coin, button.gift, button.waitFree") != null) {
"\uD83D\uDD12" // lock emoji
} else {
""
} + it.selectFirst("div.title")!!.text() + " " +
it.selectFirst("div.subTitle")!!.text()
date_upload = dateFormat.tryParse(it.selectFirst("div.pubDate")?.text())
}
}.asReversed()
}
// Pages
override fun pageListParse(response: Response): List<Page> {
val pathSegments = response.request.url.pathSegments
if (pathSegments[0].isEmpty()) {
throw Exception("请到WebView确认年满18岁")
} else if (pathSegments.size < 2 || pathSegments[1] != "epView") {
throw Exception("请确认是否已登录解锁")
}
val document = response.asJsoup()
val images = document.select("article.epContent section.imgWrap div.cImg img")
return images.mapIndexed { index, img ->
Page(index, imageUrl = img.absUrl("data-src"))
}
}
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
private val dateFormat by lazy {
SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
}
}

View File

@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.extension.zh.toptoon
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
@Serializable
class PopularResponseDto(val adult: List<MangaDto>)
@Serializable
class MangaDto(
private val meta: MetaDto,
private val thumbnail: ThumbnailDto,
private val id: String,
val lastUpdated: LastUpdatedDto,
) {
fun toSManga() = SManga.create().apply {
url = "/comic/epList/$id"
title = meta.title
author = meta.author.authorString
thumbnail_url = thumbnail.url
}
}
@Serializable
class MetaDto(val title: String, val author: AuthorDto)
@Serializable
class AuthorDto(val authorString: String)
@Serializable
class ThumbnailDto(private val standard: JsonElement) {
// "standard" in json can be either string or array
val url get() = when (standard) {
is JsonPrimitive -> "https://tw-contents-image.toptoon.net${standard.content}"
is JsonArray -> "https://tw-contents-image.toptoon.net${standard[0].jsonPrimitive.content}"
else -> throw Exception("Unexpected JSON type")
}
}
@Serializable
class LastUpdatedDto(val pubDate: String)