Fix chapters not loading in Toptoon+. (#10717)

This commit is contained in:
Alessandro Jean 2022-02-05 14:45:07 -03:00 committed by GitHub
parent c51bafdd5f
commit 6da96c40d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 164 additions and 65 deletions

View File

@ -6,7 +6,7 @@ ext {
extName = 'TOPTOON+' extName = 'TOPTOON+'
pkgNameSuffix = 'en.toptoonplus' pkgNameSuffix = 'en.toptoonplus'
extClass = '.TopToonPlus' extClass = '.TopToonPlus'
extVersionCode = 3 extVersionCode = 4
isNsfw = true isNsfw = true
} }

View File

@ -19,12 +19,10 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class TopToonPlus : HttpSource() { class TopToonPlus : HttpSource() {
@ -38,7 +36,8 @@ class TopToonPlus : HttpSource() {
override val supportsLatest = true override val supportsLatest = true
override val client: OkHttpClient = network.client.newBuilder() override val client: OkHttpClient = network.client.newBuilder()
.addInterceptor(TopToonPlusWebViewInterceptor(baseUrl, headersBuilder().build())) .addInterceptor(TopToonPlusTokenInterceptor(baseUrl, headersBuilder().build()))
.addInterceptor(TopToonPlusViewerInterceptor(baseUrl, headersBuilder().build()))
.addInterceptor(RateLimitInterceptor(2, 1, TimeUnit.SECONDS)) .addInterceptor(RateLimitInterceptor(2, 1, TimeUnit.SECONDS))
.build() .build()
@ -49,8 +48,6 @@ class TopToonPlus : HttpSource() {
.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT, Locale.US)!! .getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT, Locale.US)!!
.toUpperCase(Locale.US) .toUpperCase(Locale.US)
private val deviceId: String by lazy { UUID.randomUUID().toString() }
override fun headersBuilder(): Headers.Builder = super.headersBuilder() override fun headersBuilder(): Headers.Builder = super.headersBuilder()
.add("Origin", baseUrl) .add("Origin", baseUrl)
.add("Referer", "$baseUrl/") .add("Referer", "$baseUrl/")
@ -58,6 +55,7 @@ class TopToonPlus : HttpSource() {
override fun popularMangaRequest(page: Int): Request { override fun popularMangaRequest(page: Int): Request {
val newHeaders = headersBuilder() val newHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON) .add("Accept", ACCEPT_JSON)
.add("Language", lang)
.add("UA", "web") .add("UA", "web")
.add("X-Api-Key", API_KEY) .add("X-Api-Key", API_KEY)
.build() .build()
@ -86,6 +84,7 @@ class TopToonPlus : HttpSource() {
override fun latestUpdatesRequest(page: Int): Request { override fun latestUpdatesRequest(page: Int): Request {
val newHeaders = headersBuilder() val newHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON) .add("Accept", ACCEPT_JSON)
.add("Language", lang)
.add("UA", "web") .add("UA", "web")
.add("X-Api-Key", API_KEY) .add("X-Api-Key", API_KEY)
.build() .build()
@ -156,6 +155,7 @@ class TopToonPlus : HttpSource() {
private fun mangaDetailsApiRequest(mangaUrl: String): Request { private fun mangaDetailsApiRequest(mangaUrl: String): Request {
val newHeaders = headersBuilder() val newHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON) .add("Accept", ACCEPT_JSON)
.add("Language", lang)
.add("UA", "web") .add("UA", "web")
.add("X-Api-Key", API_KEY) .add("X-Api-Key", API_KEY)
.build() .build()
@ -216,7 +216,7 @@ class TopToonPlus : HttpSource() {
override fun pageListRequest(chapter: SChapter): Request { override fun pageListRequest(chapter: SChapter): Request {
val newHeaders = headersBuilder() val newHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON) .add("Accept", ACCEPT_JSON)
.add("Language", "en") .add("Language", lang)
.add("UA", "web") .add("UA", "web")
.add("X-Api-Key", API_KEY) .add("X-Api-Key", API_KEY)
.build() .build()
@ -251,6 +251,11 @@ class TopToonPlus : HttpSource() {
val viewerRequest = viewerRequest(usableEpisode.comicId, usableEpisode.episodeId) val viewerRequest = viewerRequest(usableEpisode.comicId, usableEpisode.episodeId)
val viewerResponse = client.newCall(viewerRequest).execute() val viewerResponse = client.newCall(viewerRequest).execute()
if (!viewerResponse.isSuccessful) {
throw Exception(COULD_NOT_GET_CHAPTER_IMAGES)
}
val viewerResult = viewerResponse.parseAs<TopToonDetails>() val viewerResult = viewerResponse.parseAs<TopToonDetails>()
return viewerResult.data!!.episode return viewerResult.data!!.episode
@ -262,6 +267,7 @@ class TopToonPlus : HttpSource() {
private fun viewerRequest(comicId: Int, episodeId: Int): Request { private fun viewerRequest(comicId: Int, episodeId: Int): Request {
val newHeaders = headersBuilder() val newHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON) .add("Accept", ACCEPT_JSON)
.add("Language", lang)
.add("UA", "web") .add("UA", "web")
.add("X-Api-Key", API_KEY) .add("X-Api-Key", API_KEY)
.build() .build()
@ -308,6 +314,7 @@ class TopToonPlus : HttpSource() {
private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8" 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_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 const val CHAPTER_NOT_FREE = "This chapter is not free to read."
private val DATE_FORMATTER by lazy { private val DATE_FORMATTER by lazy {

View File

@ -5,9 +5,13 @@ import android.app.Application
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Headers import okhttp3.Headers
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request import okhttp3.Request
@ -19,24 +23,21 @@ import java.util.UUID
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** abstract class TopToonPlusWebViewInterceptor : Interceptor {
* WebView interceptor to get the access token from the user.
* It was created because the website started to use reCAPTCHA.
*/
class TopToonPlusWebViewInterceptor(
private val baseUrl: String,
private val headers: Headers
) : Interceptor {
private val handler by lazy { Handler(Looper.getMainLooper()) } protected abstract val baseUrl: String
private val windowKey: String by lazy { protected abstract val headers: Headers
protected open val executeJavascript: Boolean = true
protected val windowKey: String by lazy {
UUID.randomUUID().toString().replace("-", "") UUID.randomUUID().toString().replace("-", "")
} }
private var token: String? = null protected val handler by lazy { Handler(Looper.getMainLooper()) }
internal class JsInterface(private val latch: CountDownLatch, var payload: String = "") { protected class JsInterface(private val latch: CountDownLatch, var payload: String = "") {
@JavascriptInterface @JavascriptInterface
fun passPayload(passedPayload: String) { fun passPayload(passedPayload: String) {
payload = passedPayload payload = passedPayload
@ -44,6 +45,72 @@ class TopToonPlusWebViewInterceptor(
} }
} }
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 @Synchronized
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request() var request = chain.request()
@ -88,53 +155,12 @@ class TopToonPlusWebViewInterceptor(
return chain.proceed(request) return chain.proceed(request)
} }
@SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface") override fun createWebViewClient(jsInterface: JsInterface): WebViewClient = object : WebViewClient() {
private 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 }
}
webview.addJavascriptInterface(jsInterface, windowKey)
webview.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String?) { override fun onPageFinished(view: WebView, url: String?) {
view.evaluateJavascript(createScript()) {} view.evaluateJavascript(createScript()) {}
} }
} }
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
}
private fun createScript(): String = """ private fun createScript(): String = """
(function () { (function () {
var database = JSON.parse(localStorage.getItem("persist:topco")); var database = JSON.parse(localStorage.getItem("persist:topco"));
@ -155,9 +181,75 @@ class TopToonPlusWebViewInterceptor(
window["$windowKey"].passPayload(accessToken || ""); window["$windowKey"].passPayload(accessToken || "");
})(); })();
""".trimIndent() """.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 { companion object {
private const val TIMEOUT_SEC: Long = 20 private const val VIEWER_ENDPOINT = "api/v1/page/viewer"
private const val DELAY_MILLIS: Long = 10 * 1000
} }
} }