diff --git a/src/zh/toptoon/build.gradle b/src/zh/toptoon/build.gradle new file mode 100644 index 000000000..d9d5850ad --- /dev/null +++ b/src/zh/toptoon/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Toptoon.net' + extClass = '.Toptoon' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/zh/toptoon/res/mipmap-hdpi/ic_launcher.png b/src/zh/toptoon/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..a1b1b6415 Binary files /dev/null and b/src/zh/toptoon/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/toptoon/res/mipmap-mdpi/ic_launcher.png b/src/zh/toptoon/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..267315238 Binary files /dev/null and b/src/zh/toptoon/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/toptoon/res/mipmap-xhdpi/ic_launcher.png b/src/zh/toptoon/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..922e68b80 Binary files /dev/null and b/src/zh/toptoon/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/toptoon/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/toptoon/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..40dcbf062 Binary files /dev/null and b/src/zh/toptoon/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/toptoon/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/toptoon/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..8b34ac0c8 Binary files /dev/null and b/src/zh/toptoon/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/toptoon/src/eu/kanade/tachiyomi/extension/zh/toptoon/Toptoon.kt b/src/zh/toptoon/src/eu/kanade/tachiyomi/extension/zh/toptoon/Toptoon.kt new file mode 100644 index 000000000..876054984 --- /dev/null +++ b/src/zh/toptoon/src/eu/kanade/tachiyomi/extension/zh/toptoon/Toptoon.kt @@ -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().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>().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>().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 { + 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 { + 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) + } +} diff --git a/src/zh/toptoon/src/eu/kanade/tachiyomi/extension/zh/toptoon/ToptoonDto.kt b/src/zh/toptoon/src/eu/kanade/tachiyomi/extension/zh/toptoon/ToptoonDto.kt new file mode 100644 index 000000000..d1c0d4d6a --- /dev/null +++ b/src/zh/toptoon/src/eu/kanade/tachiyomi/extension/zh/toptoon/ToptoonDto.kt @@ -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) + +@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)