Remove TOPTOON+ due to cat-and-mouse game (#10851)

* Remove TOPTOON+ due to cat-and-mouse game.

* Add the source to the autocloser.
This commit is contained in:
Alessandro Jean 2022-02-17 11:35:52 -03:00 committed by GitHub
parent 76eb331136
commit 11981cd25e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1 additions and 772 deletions

View File

@ -32,7 +32,7 @@ jobs:
}, },
{ {
"type": "both", "type": "both",
"regex": ".*(mangago|mangafox|hq\\s*dragon|manga\\s*host|supermangas|superhentais|union\\s*mangas|yes\\s*mangas|manhuascan|heroscan|manhwahot|leitor\\.?net|manga\\s*livre|tsuki\\s*mangas|manga\\s*yabu|mangas\\.in|mangas\\.pw|hentaikai).*", "regex": ".*(mangago|mangafox|hq\\s*dragon|manga\\s*host|supermangas|superhentais|union\\s*mangas|yes\\s*mangas|manhuascan|heroscan|manhwahot|leitor\\.?net|manga\\s*livre|tsuki\\s*mangas|manga\\s*yabu|mangas\\.in|mangas\\.pw|hentaikai|toptoon\\+?).*",
"ignoreCase": true, "ignoreCase": true,
"message": "{match} will not be added back as it is too difficult to maintain. Read #3475 for more information" "message": "{match} will not be added back as it is too difficult to maintain. Read #3475 for more information"
}, },

View File

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

View File

@ -1,50 +0,0 @@
# TOPTOON+
Table of Content
- [FAQ](#FAQ)
- [Why are some chapters missing?](#why-are-some-chapters-missing)
- [Why I can not see mature titles?](#why-i-cant-see-mature-titles)
- [Guides](#Guides)
- [Reading already paid chapters](#reading-already-paid-chapters)
Don't find the question you are looking for? Go check out our general FAQs and Guides
over at [Extension FAQ] or [Getting Started].
[Extension FAQ]: https://tachiyomi.org/help/faq/#extensions
[Getting Started]: https://tachiyomi.org/help/guides/getting-started/#installation
## FAQ
### Why are some chapters missing?
TOPTOON+ have series with paid chapters. These will be filtered out from
the chapter list by default if you didn't buy it before or if you're not signed in.
To sign in with your existing account, follow the guide available above.
### Why I can not see mature titles?
You need to sign in with your existing account in WebView and toggle the
Mature switch to on in order to these titles appear in the extension.
More details about how to sign in in the guide available above.
## Guides
### Reading already paid chapters
The **TOPTOON+** source allows the reading of paid chapters in your account.
Follow the following steps to be able to sign in and get access to them:
1. Open the popular or latest section of the source.
2. Open the WebView by clicking the button with a globe icon.
3. Do the login with your existing account *(read the observations section)*.
4. Close the WebView and refresh the chapter list of the titles
you want to read the already paid chapters.
#### Observations
- Sign in with your Google account is not supported due to WebView restrictions
access that Google have. You need to have a simple account in order to be able
to login via WebView.
- The extension **will not** bypass any payment requirement. You still do need
to buy the chapters you want to read or wait until they become available and
added to your account.

View File

@ -1,18 +0,0 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'TOPTOON+'
pkgNameSuffix = 'en.toptoonplus'
extClass = '.TopToonPlus'
extVersionCode = 4
isNsfw = true
}
dependencies {
implementation project(':lib-ratelimit')
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View File

@ -1,324 +0,0 @@
package eu.kanade.tachiyomi.extension.en.toptoonplus
import android.util.Base64
import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
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 kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.concurrent.TimeUnit
class TopToonPlus : HttpSource() {
override val name = "TOPTOON+"
override val baseUrl = "https://toptoonplus.com"
override val lang = "en"
override val supportsLatest = true
override val client: OkHttpClient = network.client.newBuilder()
.addInterceptor(TopToonPlusTokenInterceptor(baseUrl, headersBuilder().build()))
.addInterceptor(TopToonPlusViewerInterceptor(baseUrl, headersBuilder().build()))
.addInterceptor(RateLimitInterceptor(2, 1, TimeUnit.SECONDS))
.build()
private val json: Json by injectLazy()
private val day: String
get() = Calendar.getInstance()
.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT, Locale.US)!!
.toUpperCase(Locale.US)
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
.add("Origin", baseUrl)
.add("Referer", "$baseUrl/")
override fun popularMangaRequest(page: Int): Request {
val newHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Language", lang)
.add("UA", "web")
.add("X-Api-Key", API_KEY)
.build()
return GET("$API_URL/api/v1/page/ranking", newHeaders, CacheControl.FORCE_NETWORK)
}
override fun popularMangaParse(response: Response): MangasPage {
val result = response.parseAs<TopToonRanking>()
if (result.data == null) {
return MangasPage(emptyList(), hasNextPage = false)
}
val comicList = result.data.ranking.map(::popularMangaFromObject)
return MangasPage(comicList, hasNextPage = false)
}
private fun popularMangaFromObject(comic: TopToonComic) = SManga.create().apply {
title = comic.information?.title.orEmpty()
thumbnail_url = comic.firstAvailableThumbnail
url = "/comic/${comic.comicId}"
}
override fun latestUpdatesRequest(page: Int): Request {
val newHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Language", lang)
.add("UA", "web")
.add("X-Api-Key", API_KEY)
.build()
return GET("$API_URL/api/v1/page/daily/$day", newHeaders, CacheControl.FORCE_NETWORK)
}
override fun latestUpdatesParse(response: Response): MangasPage {
val result = response.parseAs<TopToonDaily>()
if (result.data == null) {
return MangasPage(emptyList(), hasNextPage = false)
}
val comicList = result.data.daily.map(::latestMangaFromObject)
return MangasPage(comicList, hasNextPage = false)
}
private fun latestMangaFromObject(comic: TopToonComic) = popularMangaFromObject(comic)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return super.fetchSearchManga(page, query, filters)
.map { result ->
val filteredList = result.mangas.filter { it.title.contains(query, true) }
MangasPage(filteredList, result.hasNextPage)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val newHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Language", lang)
.add("UA", "web")
.add("X-Api-Key", API_KEY)
.build()
return GET("$API_URL/api/v1/search/totalsearch", newHeaders, CacheControl.FORCE_NETWORK)
}
override fun searchMangaParse(response: Response): MangasPage {
if (response.request.url.toString().contains("ranking")) {
return popularMangaParse(response)
}
val result = response.parseAs<List<TopToonComic>>()
if (result.data == null) {
return MangasPage(emptyList(), hasNextPage = false)
}
val comicList = result.data.map(::searchMangaFromObject)
return MangasPage(comicList, hasNextPage = false)
}
private fun searchMangaFromObject(comic: TopToonComic) = popularMangaFromObject(comic)
// Workaround to allow "Open in browser" use the real URL.
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsApiRequest(manga.url))
.asObservableSuccess()
.map { response ->
mangaDetailsParse(response).apply { initialized = true }
}
}
private fun mangaDetailsApiRequest(mangaUrl: String): Request {
val newHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Language", lang)
.add("UA", "web")
.add("X-Api-Key", API_KEY)
.build()
val comicId = mangaUrl.substringAfterLast("/")
return GET("$API_URL/api/v1/page/episode?comicId=$comicId", newHeaders)
}
override fun mangaDetailsRequest(manga: SManga): Request {
val newHeaders = headersBuilder()
.removeAll("Accept")
.build()
return GET(baseUrl + manga.url, newHeaders)
}
override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
val result = response.parseAs<TopToonDetails>()
if (result.data == null) {
throw Exception(COULD_NOT_PARSE_RESPONSE)
}
val comic = result.data.comic!!
title = comic.information?.title.orEmpty()
thumbnail_url = comic.firstAvailableThumbnail
description = comic.information?.description
genre = comic.genres
status = if (result.data.isCompleted) SManga.COMPLETED else SManga.ONGOING
author = comic.author.joinToString { it.trim() }
}
override fun chapterListRequest(manga: SManga): Request = mangaDetailsApiRequest(manga.url)
override fun chapterListParse(response: Response): List<SChapter> {
val result = response.parseAs<TopToonDetails>()
if (result.data == null) {
throw Exception(COULD_NOT_PARSE_RESPONSE)
}
return result.data.availableEpisodes
.map(::chapterFromObject)
.reversed()
}
private fun chapterFromObject(chapter: TopToonEpisode): SChapter = SChapter.create().apply {
name = chapter.information?.title.orEmpty() +
(if (chapter.information?.subTitle.isNullOrEmpty().not()) " - " + chapter.information?.subTitle else "")
chapter_number = chapter.order.toFloat()
scanlator = this@TopToonPlus.name
date_upload = chapter.information?.publishedAt?.date.orEmpty().toDate()
url = "/comic/${chapter.comicId}/${chapter.episodeId}"
}
override fun pageListRequest(chapter: SChapter): Request {
val newHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Language", lang)
.add("UA", "web")
.add("X-Api-Key", API_KEY)
.build()
val comicId = chapter.url
.substringAfter("/comic/")
.substringBefore("/")
val episodeId = chapter.url.substringAfterLast("/")
val apiUrl = "$API_URL/check/isUsableEpisode".toHttpUrl().newBuilder()
.addQueryParameter("comicId", comicId)
.addQueryParameter("episodeId", episodeId)
.addQueryParameter("location", "episode")
.addQueryParameter("action", "episode_click")
.toString()
return GET(apiUrl, newHeaders, CacheControl.FORCE_NETWORK)
}
override fun pageListParse(response: Response): List<Page> {
val result = response.parseAs<TopToonUsableEpisode>()
if (result.data == null) {
throw Exception(COULD_NOT_PARSE_RESPONSE)
}
val usableEpisode = result.data
if (usableEpisode.isFree.not() && usableEpisode.isOwn.not()) {
throw Exception(CHAPTER_NOT_FREE)
}
val viewerRequest = viewerRequest(usableEpisode.comicId, usableEpisode.episodeId)
val viewerResponse = client.newCall(viewerRequest).execute()
if (!viewerResponse.isSuccessful) {
throw Exception(COULD_NOT_GET_CHAPTER_IMAGES)
}
val viewerResult = viewerResponse.parseAs<TopToonDetails>()
return viewerResult.data!!.episode
.find { episode -> episode.episodeId == usableEpisode.episodeId }
.let { episode -> episode?.contentImage?.jpeg.orEmpty() }
.mapIndexed { i, page -> Page(i, baseUrl, page.path) }
}
private fun viewerRequest(comicId: Int, episodeId: Int): Request {
val newHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Language", lang)
.add("UA", "web")
.add("X-Api-Key", API_KEY)
.build()
val apiUrl = "$API_URL/api/v1/page/viewer".toHttpUrl().newBuilder()
.addQueryParameter("comicId", comicId.toString())
.addQueryParameter("episodeId", episodeId.toString())
.toString()
return GET(apiUrl, newHeaders)
}
override fun fetchImageUrl(page: Page): Observable<String> = Observable.just(page.imageUrl!!)
override fun imageUrlParse(response: Response): String = ""
override fun imageRequest(page: Page): Request {
val newHeaders = headersBuilder()
.set("Accept", ACCEPT_IMAGE)
.set("Referer", page.url)
.build()
return GET(page.imageUrl!!, newHeaders)
}
private inline fun <reified T> Response.parseAs(): TopToonResult<T> = use {
json.decodeFromString(it.body?.string().orEmpty())
}
private fun String.toDate(): Long {
return runCatching { DATE_FORMATTER.parse(this)?.time }
.getOrNull() ?: 0L
}
companion object {
const val API_URL = "https://api.toptoonplus.com"
private val API_KEY by lazy {
Base64.decode("U1VQRVJDT09MQVBJS0VZMjAyMSNAIyg=", Base64.DEFAULT)
.toString(charset("UTF-8"))
}
private const val ACCEPT_JSON = "application/json, text/plain, */*"
private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"
private const val COULD_NOT_PARSE_RESPONSE = "Could not parse the API response."
private const val COULD_NOT_GET_CHAPTER_IMAGES = "Could not get the chapter images."
private const val CHAPTER_NOT_FREE = "This chapter is not free to read."
private val DATE_FORMATTER by lazy {
SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
}
}
}

View File

@ -1,122 +0,0 @@
package eu.kanade.tachiyomi.extension.en.toptoonplus
import kotlinx.serialization.Serializable
@Serializable
data class TopToonResult<T>(
val uuid: String? = "",
val data: T? = null
)
@Serializable
data class TopToonRanking(
val ranking: List<TopToonComic> = emptyList()
)
@Serializable
data class TopToonDaily(
val daily: List<TopToonComic> = emptyList()
)
@Serializable
data class TopToonDetails(
val comic: TopToonComic? = null,
val episode: List<TopToonEpisode> = emptyList()
) {
val availableEpisodes: List<TopToonEpisode>
get() = episode.filter { it.information?.payType == 0 || it.isPurchased == 1 }
val isCompleted: Boolean
get() = episode.lastOrNull()?.information
?.let {
it.title.contains("[End]", true) ||
it.subTitle.contains("[End]", true)
} ?: false
}
@Serializable
data class TopToonUsableEpisode(
val comicId: Int = 0,
val episode: TopToonEpisode? = null,
val episodeId: Int = 0,
val episodePrice: TopToonEpisodePrice? = null,
val isFree: Boolean = false,
val isOwn: Boolean = false,
val needLogin: Boolean = false,
val purchaseMethod: List<String> = emptyList()
)
@Serializable
data class TopToonEpisodePrice(
val payType: Int = -1
)
@Serializable
data class TopToonComic(
val author: List<String> = emptyList(),
val comicId: Int = -1,
val hashtags: List<String> = emptyList(),
val information: TopToonComicInfo? = null,
val thumbnailImage: TopToonComicPoster? = null,
val titleVerticalThumbnail: TopToonComicPoster? = null
) {
val firstAvailableThumbnail: String
get() = titleVerticalThumbnail?.jpeg?.firstOrNull()?.path
?: thumbnailImage!!.jpeg.firstOrNull()?.path.orEmpty()
val genres: String
get() = hashtags
.flatMap { it.split("&") }
.map(String::trim)
.sorted()
.joinToString()
}
@Serializable
data class TopToonComicInfo(
val description: String = "",
val mature: Int = 0,
val title: String = ""
)
@Serializable
data class TopToonComicPoster(
val jpeg: List<TopToonImage> = emptyList()
)
@Serializable
data class TopToonImage(
val path: String = ""
)
@Serializable
data class TopToonEpisode(
val comicId: Int = -1,
val contentImage: TopToonComicPoster? = null,
val episodeId: Int = -1,
val information: TopToonEpisodeInfo? = null,
val isPurchased: Int = 0,
val order: Int = -1
)
@Serializable
data class TopToonEpisodeInfo(
val needLogin: Int = 0,
val payType: Int = 0,
val publishedAt: TopToonEpisodeDate? = null,
val subTitle: String = "",
val title: String = ""
)
@Serializable
data class TopToonEpisodeDate(
val date: String = ""
)
@Serializable
data class TopToonAuth(
val auth: Int = 0,
val mature: Int = 0,
val sign: Int = 0,
val token: String = ""
)

View File

@ -1,255 +0,0 @@
package eu.kanade.tachiyomi.extension.en.toptoonplus
import android.annotation.SuppressLint
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import eu.kanade.tachiyomi.network.GET
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.IOException
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
abstract class TopToonPlusWebViewInterceptor : Interceptor {
protected abstract val baseUrl: String
protected abstract val headers: Headers
protected open val executeJavascript: Boolean = true
protected val windowKey: String by lazy {
UUID.randomUUID().toString().replace("-", "")
}
protected val handler by lazy { Handler(Looper.getMainLooper()) }
protected class JsInterface(private val latch: CountDownLatch, var payload: String = "") {
@JavascriptInterface
fun passPayload(passedPayload: String) {
payload = passedPayload
latch.countDown()
}
}
abstract override fun intercept(chain: Interceptor.Chain): Response
@SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface")
protected fun proceedWithWebView(websiteRequest: Request): String? {
val latch = CountDownLatch(1)
var webView: WebView? = null
val requestUrl = websiteRequest.url.toString()
val headers = websiteRequest.headers.toMultimap()
.mapValues { it.value.getOrNull(0) ?: "" }
.toMutableMap()
val userAgent = headers["User-Agent"]
val jsInterface = JsInterface(latch)
handler.post {
val webview = WebView(Injekt.get<Application>())
webView = webview
with(webview.settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
useWideViewPort = false
loadWithOverviewMode = false
userAgentString = userAgent.orEmpty().ifEmpty { userAgentString }
}
if (executeJavascript) {
webview.addJavascriptInterface(jsInterface, windowKey)
}
webview.webViewClient = createWebViewClient(jsInterface)
webview.loadUrl(requestUrl, headers)
}
latch.await(TIMEOUT_SEC, TimeUnit.SECONDS)
handler.postDelayed({ webView?.destroy() }, DELAY_MILLIS)
if (jsInterface.payload.isBlank()) {
return null
}
return jsInterface.payload
}
protected abstract fun createWebViewClient(jsInterface: JsInterface): WebViewClient
companion object {
private const val TIMEOUT_SEC: Long = 20
private const val DELAY_MILLIS: Long = 10 * 1000
}
}
/**
* WebView interceptor to get the access token from the user.
* It was created because the website started to use reCAPTCHA.
*/
class TopToonPlusTokenInterceptor(
override val baseUrl: String,
override val headers: Headers
) : TopToonPlusWebViewInterceptor() {
private var token: String? = null
@Synchronized
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
if (!request.url.toString().startsWith(TopToonPlus.API_URL)) {
return chain.proceed(request)
}
if (token != null) {
request = request.newBuilder()
.header("Token", token!!)
.build()
val response = chain.proceed(request)
// The API throws 463 if the token is invalid.
if (response.code != 463) {
return response
}
token = null
request = request.newBuilder()
.removeHeader("Token")
.build()
response.close()
}
try {
val websiteRequest = GET(baseUrl, headers)
token = proceedWithWebView(websiteRequest)
} catch (e: Exception) {
throw IOException(e.message)
}
if (token != null) {
request = request.newBuilder()
.header("Token", token!!)
.build()
}
return chain.proceed(request)
}
override fun createWebViewClient(jsInterface: JsInterface): WebViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String?) {
view.evaluateJavascript(createScript()) {}
}
}
private fun createScript(): String = """
(function () {
var database = JSON.parse(localStorage.getItem("persist:topco"));
if (!database) {
window["$windowKey"].passPayload("");
return;
}
var userDatabase = JSON.parse(database.user);
if (!userDatabase) {
window["$windowKey"].passPayload("");
return;
}
var accessToken = userDatabase.accessToken;
window["$windowKey"].passPayload(accessToken || "");
})();
""".trimIndent()
}
/**
* WebView interceptor to get the viewer token for the chapter.
* It was created because the website started to use reCAPTCHA.
*/
class TopToonPlusViewerInterceptor(
override val baseUrl: String,
override val headers: Headers
) : TopToonPlusWebViewInterceptor() {
override val executeJavascript: Boolean = false
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
if (!request.url.toString().startsWith(TopToonPlus.API_URL)) {
return chain.proceed(request)
}
if (request.url.pathSegments.joinToString("/") != VIEWER_ENDPOINT) {
return chain.proceed(request)
}
val comicId = request.url.queryParameter("comicId")!!
val episodeId = request.url.queryParameter("episodeId")!!
val chapterRequest = GET("$baseUrl/comic/$comicId/$episodeId", headers)
val urlWithToken: String
try {
urlWithToken = proceedWithWebView(chapterRequest).orEmpty()
.ifEmpty { request.url.toString() }
} catch (e: Exception) {
throw IOException(e.message)
}
request = request.newBuilder()
.url(urlWithToken)
.build()
return chain.proceed(request)
}
override fun createWebViewClient(jsInterface: JsInterface): WebViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest
): WebResourceResponse? {
if (!request.url.toString().contains(VIEWER_ENDPOINT)) {
return null
}
val badResponse = buildJsonObject {
put("action", "unusable comic")
put("message", "not allowed")
put("uuid", UUID.randomUUID().toString())
}
jsInterface.passPayload(request.url.toString())
return WebResourceResponse(
"application/json",
"utf-8",
badResponse.toString().byteInputStream()
)
}
}
companion object {
private const val VIEWER_ENDPOINT = "api/v1/page/viewer"
}
}