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:
parent
76eb331136
commit
11981cd25e
|
@ -32,7 +32,7 @@ jobs:
|
|||
},
|
||||
{
|
||||
"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,
|
||||
"message": "{match} will not be added back as it is too difficult to maintain. Read #3475 for more information"
|
||||
},
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="eu.kanade.tachiyomi.extension" />
|
|
@ -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.
|
|
@ -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 |
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 = ""
|
||||
)
|
|
@ -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"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue