MangaHub (multisrc) - Fixes and Improvements (#8586)

* remove rate limit

* Fixes and improvements

* Version bump

* Review changes, more improvements
This commit is contained in:
Jake 2025-04-27 10:07:35 +08:00 committed by Draff
parent 5818f1dc64
commit 1a6774af59
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
4 changed files with 102 additions and 48 deletions

View File

@ -2,8 +2,9 @@ plugins {
id("lib-multisrc") id("lib-multisrc")
} }
baseVersionCode = 32 baseVersionCode = 33
dependencies { dependencies {
api(project(":lib:randomua")) //noinspection UseTomlInstead
implementation("org.brotli:dec:0.1.2")
} }

View File

@ -3,11 +3,8 @@ package eu.kanade.tachiyomi.multisrc.mangahub
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.lib.randomua.UserAgentType
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
@ -30,12 +27,16 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.brotli.dec.BrotliInputStream
import rx.Observable import rx.Observable
import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.net.URLEncoder import java.net.URLEncoder
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.zip.GZIPInputStream
abstract class MangaHub( abstract class MangaHub(
override val name: String, override val name: String,
@ -50,7 +51,8 @@ abstract class MangaHub(
private val baseApiUrl = "https://api.mghcdn.com" private val baseApiUrl = "https://api.mghcdn.com"
private val baseCdnUrl = "https://imgx.mghcdn.com" private val baseCdnUrl = "https://imgx.mghcdn.com"
private val baseThumbCdnUrl = "https://thumb.mghcdn.com" private val baseThumbCdnUrl = "https://thumb.mghcdn.com"
private val regex = Regex("mhub_access=([^;]+)") private val apiRegex = Regex("mhub_access=([^;]+)")
private val spaceRegex = Regex("\\s+")
private val preferences: SharedPreferences by getPreferencesLazy() private val preferences: SharedPreferences by getPreferencesLazy()
@ -60,13 +62,9 @@ abstract class MangaHub(
) )
override val client: OkHttpClient = network.cloudflareClient.newBuilder() override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.setRandomUserAgent(
userAgentType = UserAgentType.DESKTOP,
filterInclude = listOf("chrome"),
)
.addInterceptor(::apiAuthInterceptor) .addInterceptor(::apiAuthInterceptor)
.addInterceptor(::graphQLApiInterceptor) .addInterceptor(::graphQLApiInterceptor)
.rateLimit(1) .addNetworkInterceptor(::compatEncodingInterceptor)
.build() .build()
override fun headersBuilder(): Headers.Builder = super.headersBuilder() override fun headersBuilder(): Headers.Builder = super.headersBuilder()
@ -119,6 +117,44 @@ abstract class MangaHub(
return chain.proceed(request) return chain.proceed(request)
} }
// Normally this gets handled properly but in older forks such as TachiyomiJ2K, we have to manually intercept it
// as they have an outdated implementation of NetworkHelper.
private fun compatEncodingInterceptor(chain: Interceptor.Chain): Response {
var response = chain.proceed(chain.request())
val contentEncoding = response.header("Content-Encoding")
if (contentEncoding == "gzip") {
val parsedBody = response.body.byteStream().let { gzipInputStream ->
GZIPInputStream(gzipInputStream).use { inputStream ->
val outputStream = ByteArrayOutputStream()
inputStream.copyTo(outputStream)
outputStream.toByteArray()
}
}
response = response.createNewWithCompatBody(parsedBody)
} else if (contentEncoding == "br") {
val parsedBody = response.body.byteStream().let { brotliInputStream ->
BrotliInputStream(brotliInputStream).use { inputStream ->
val outputStream = ByteArrayOutputStream()
inputStream.copyTo(outputStream)
outputStream.toByteArray()
}
}
response = response.createNewWithCompatBody(parsedBody)
}
return response
}
private fun Response.createNewWithCompatBody(outputStream: ByteArray): Response {
return this.newBuilder()
.body(outputStream.toResponseBody(this.body.contentType()))
.removeHeader("Content-Encoding")
.build()
}
private fun graphQLApiInterceptor(chain: Interceptor.Chain): Response { private fun graphQLApiInterceptor(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
@ -147,33 +183,15 @@ abstract class MangaHub(
} }
private fun refreshApiKey(chapter: SChapter) { private fun refreshApiKey(chapter: SChapter) {
val now = Calendar.getInstance().time.time
val url = "$baseUrl/chapter${chapter.url}".toHttpUrl() val url = "$baseUrl/chapter${chapter.url}".toHttpUrl()
val oldKey = client.cookieJar val oldKey = client.cookieJar
.loadForRequest(baseUrl.toHttpUrl()) .loadForRequest(baseUrl.toHttpUrl())
.firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }?.value .firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }?.value
// With the recent changes on how refresh API token works, we are now apparently required to have
// a cookie for recently when requesting for a new one. Not having this will result in a hit or miss.
val recently = buildJsonObject {
putJsonObject((now - (0..3600).random()).toString()) {
put("mangaID", (1..42_000).random())
put("number", (1..20).random())
}
}.toString()
val recentlyCookie = Cookie.Builder()
.domain(url.host)
.name("recently")
.value(URLEncoder.encode(recently, "utf-8"))
.expiresAt(now + 2 * 60 * 60 * 24 * 31) // +2 months
.build()
for (i in 1..2) { for (i in 1..2) {
// Clear key cookie // Clear key cookie
val cookie = Cookie.parse(url, "mhub_access=; Max-Age=0; Path=/")!! val cookie = Cookie.parse(url, "mhub_access=; Max-Age=0; Path=/")!!
client.cookieJar.saveFromResponse(url, listOf(cookie, recentlyCookie)) client.cookieJar.saveFromResponse(url, listOf(cookie))
// We try requesting again with param if the first one fails // We try requesting again with param if the first one fails
val query = if (i == 2) "?reloadKey=1" else "" val query = if (i == 2) "?reloadKey=1" else ""
@ -187,7 +205,7 @@ abstract class MangaHub(
.build(), .build(),
), ),
).execute() ).execute()
val returnedKey = response.headers["set-cookie"]?.let { regex.find(it)?.groupValues?.get(1) } val returnedKey = response.headers["set-cookie"]?.let { apiRegex.find(it)?.groupValues?.get(1) }
response.close() // Avoid potential resource leaks response.close() // Avoid potential resource leaks
if (returnedKey != oldKey) break // Break out of loop since we got an allegedly valid API key if (returnedKey != oldKey) break // Break out of loop since we got an allegedly valid API key
@ -195,16 +213,6 @@ abstract class MangaHub(
throw IOException("An error occurred while obtaining a new API key") // Show error throw IOException("An error occurred while obtaining a new API key") // Show error
} }
} }
// Sometimes, the new API key is still invalid. To ensure that the token will be fresh and available to use,
// we have to mimic how the browser site works. To put it simply, we will send a GET request that indicates what
// manga and chapter were browsing. If this succeeded, the API key that we use will be revalidated (assuming that we got an expired one.)
// We first need to obtain our public IP first since it is required as a query.
val ipRequest = client.newCall(GET("https://api.ipify.org?format=json")).execute()
val ip = ipRequest.parseAs<PublicIPResponse>().ip
// We'll log our action to the site to revalidate the API key in case we got an expired one
client.newCall(GET("$baseUrl/action/logHistory2/${url.pathSegments[1]}/${chapter.chapter_number}?browserID=$ip")).execute()
} }
data class SMangaDTO( data class SMangaDTO(
@ -339,7 +347,7 @@ abstract class MangaHub(
val numberString = "${if (it.number % 1 == 0f) it.number.toInt() else it.number}" val numberString = "${if (it.number % 1 == 0f) it.number.toInt() else it.number}"
name = if (!useGenericTitle) { name = if (!useGenericTitle) {
generateChapterName(it.title.trim().replace("\n", " "), numberString) generateChapterName(it.title.trim().replace(spaceRegex, " "), numberString)
} else { } else {
generateGenericChapterName(numberString) generateGenericChapterName(numberString)
} }
@ -374,15 +382,52 @@ abstract class MangaHub(
return postRequestGraphQL(pagesQuery(mangaSource, chapterUrl[1], chapterUrl[2].substringAfter("-").toFloat())) return postRequestGraphQL(pagesQuery(mangaSource, chapterUrl[1], chapterUrl[2].substringAfter("-").toFloat()))
} }
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> = override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
super.fetchPageList(chapter) var doApiRefresh = true
.doOnError { refreshApiKey(chapter) }
return super.fetchPageList(chapter)
.doOnError {
// Ensure that the api refresh call will happen only once
if (doApiRefresh) {
refreshApiKey(chapter)
doApiRefresh = false
}
}
.retry(1) .retry(1)
}
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
val chapterObject = response.parseAs<ApiChapterPagesResponse>() val chapterObject = response.parseAs<ApiChapterPagesResponse>()
val pages = chapterObject.data.chapter.pages.parseAs<ApiChapterPages>() val pages = chapterObject.data.chapter.pages.parseAs<ApiChapterPages>()
// We'll update the cookie here to match the browser's "recently" opened chapter.
// This mimics how the browser works and gives us more chance to receive a valid API key upon refresh
val now = Calendar.getInstance().time.time
val baseHttpUrl = baseUrl.toHttpUrl()
val recently = buildJsonObject {
putJsonObject((now).toString()) {
put("mangaID", chapterObject.data.chapter.mangaID)
put("number", chapterObject.data.chapter.chapterNumber)
}
}.toString()
val recentlyCookie = Cookie.Builder()
.domain(baseHttpUrl.host)
.name("recently")
.value(URLEncoder.encode(recently, "utf-8"))
.expiresAt(now + 2 * 60 * 60 * 24 * 31) // +2 months
.build()
// Add/update the cookie
client.cookieJar.saveFromResponse(baseHttpUrl, listOf(recentlyCookie))
// We'll log our action to the site to further increase the chance of valid API key
val ipRequest = client.newCall(GET("https://api.ipify.org?format=json")).execute()
val ip = ipRequest.parseAs<PublicIPResponse>().ip
client.newCall(GET("$baseUrl/action/logHistory2/${chapterObject.data.chapter.manga.slug}/${chapterObject.data.chapter.chapterNumber}?browserID=$ip")).execute().close()
ipRequest.close()
return pages.images.mapIndexed { i, page -> return pages.images.mapIndexed { i, page ->
Page(i, "", "$baseCdnUrl/${pages.page}$page") Page(i, "", "$baseCdnUrl/${pages.page}$page")
} }
@ -517,7 +562,7 @@ abstract class MangaHub(
Genre("Video Games", "video-games"), Genre("Video Games", "video-games"),
Genre("Monsters", "monsters"), Genre("Monsters", "monsters"),
Genre("Office Workers", "office-workers"), Genre("Office Workers", "office-workers"),
Genre("system", "system"), Genre("System", "system"),
Genre("Villainess", "villainess"), Genre("Villainess", "villainess"),
Genre("Zombies", "zombies"), Genre("Zombies", "zombies"),
Genre("Vampires", "vampires"), Genre("Vampires", "vampires"),
@ -540,7 +585,7 @@ abstract class MangaHub(
Genre("Cheat Systems", "cheat-systems"), Genre("Cheat Systems", "cheat-systems"),
Genre("Dungeons", "dungeons"), Genre("Dungeons", "dungeons"),
Genre("Overpowered", "overpowered"), Genre("Overpowered", "overpowered"),
) ).sortedBy { it.toString() }
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply { SwitchPreferenceCompat(screen.context).apply {

View File

@ -37,6 +37,9 @@ class ApiChapterData(
@Serializable @Serializable
class ApiChapter( class ApiChapter(
val pages: String, val pages: String,
val mangaID: Int,
@SerialName("number") val chapterNumber: Float,
val manga: ApiMangaData,
) )
@Serializable @Serializable

View File

@ -56,7 +56,12 @@ val pagesQuery = { mangaSource: String, slug: String, number: Float ->
""" """
{ {
chapter(x: $mangaSource, slug: "$slug", number: $number) { chapter(x: $mangaSource, slug: "$slug", number: $number) {
pages pages,
mangaID,
number,
manga {
slug
}
} }
} }
""".trimIndent() """.trimIndent()