Fix chapters not loading in Toptoon+. (#10717)
This commit is contained in:
parent
c51bafdd5f
commit
6da96c40d2
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue