diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mangahub/MangaHub.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mangahub/MangaHub.kt index 84d5912b9..3b896ddb4 100644 --- a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mangahub/MangaHub.kt +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mangahub/MangaHub.kt @@ -15,7 +15,10 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import okhttp3.Cookie import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor import okhttp3.OkHttpClient @@ -24,7 +27,9 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import org.jsoup.nodes.Document import org.jsoup.nodes.Element +import rx.Observable import uy.kohesive.injekt.injectLazy +import java.net.URLEncoder import java.text.ParseException import java.text.SimpleDateFormat import java.util.Calendar @@ -39,8 +44,12 @@ abstract class MangaHub( override val supportsLatest = true + private var baseApiUrl = "https://api.mghubcdn.com" + private var baseCdnUrl = "https://imgx.mghubcdn.com" + override val client: OkHttpClient = super.client.newBuilder() .addInterceptor(::uaIntercept) + .addInterceptor(::apiAuthInterceptor) .rateLimit(1) .build() @@ -56,9 +65,6 @@ abstract class MangaHub( open val json: Json by injectLazy() - private var baseApiUrl = "https://api.mghubcdn.com" - private var baseCdnUrl = "https://imgx.mghubcdn.com" - private var userAgent: String? = null private var checkedUa = false @@ -88,6 +94,63 @@ abstract class MangaHub( return chain.proceed(chain.request()) } + private fun apiAuthInterceptor(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + val cookie = client.cookieJar + .loadForRequest(baseUrl.toHttpUrl()) + .firstOrNull { it.name == "mhub_access" } + + val request = + if (originalRequest.url.toString() == "$baseApiUrl/graphql" && cookie != null) { + originalRequest.newBuilder() + .header("x-mhub-access", cookie.value) + .build() + } else { + originalRequest + } + + return chain.proceed(request) + } + + private fun refreshApiKey(chapter: SChapter) { + val now = Calendar.getInstance().time.time + + val slug = "$baseUrl${chapter.url}" + .toHttpUrlOrNull() + ?.pathSegments + ?.get(1) + + val url = if (slug != null) { + "$baseUrl/manga/$slug".toHttpUrl() + } else { + baseUrl.toHttpUrl() + } + + // Set required cookie (for cache busting?) + val recently = buildJsonObject { + putJsonObject((now - (0..3600).random()).toString()) { + put("mangaID", (1..42_000).random()) + put("number", (1..20).random()) + } + }.toString() + + client.cookieJar.saveFromResponse( + url, + listOf( + Cookie.Builder() + .domain(url.host) + .name("recently") + .value(URLEncoder.encode(recently, "utf-8")) + .expiresAt(now + 2 * 60 * 60 * 24 * 31) // +2 months + .build(), + ), + ) + + val request = GET("$url?reloadKey=1", headers) + client.newCall(request).execute() + } + // popular override fun popularMangaRequest(page: Int): Request { return GET("$baseUrl/popular/page/$page", headers) @@ -148,16 +211,16 @@ abstract class MangaHub( override fun searchMangaParse(response: Response): MangasPage { val document = response.asJsoup() - /* - * To remove duplicates we group by the thumbnail_url, which is - * common between duplicates. The duplicates have a suffix in the - * url "-by-{name}". Here we select the shortest url, to avoid - * removing manga that has "by" in the title already. - * Example: - * /manga/tales-of-demons-and-gods (kept) - * /manga/tales-of-demons-and-gods-by-mad-snail (removed) - * /manga/leveling-up-by-only-eating (kept) - */ + /* + * To remove duplicates we group by the thumbnail_url, which is + * common between duplicates. The duplicates have a suffix in the + * url "-by-{name}". Here we select the shortest url, to avoid + * removing manga that has "by" in the title already. + * Example: + * /manga/tales-of-demons-and-gods (kept) + * /manga/tales-of-demons-and-gods-by-mad-snail (removed) + * /manga/leveling-up-by-only-eating (kept) + */ val mangas = document.select(searchMangaSelector()).map { element -> searchMangaFromElement(element) }.groupBy { it.thumbnail_url }.mapValues { (_, values) -> @@ -301,23 +364,55 @@ abstract class MangaHub( .toRequestBody() val newHeaders = headersBuilder() + .set("Accept", "application/json") .set("Content-Type", "application/json") .set("Origin", baseUrl) + .set("Sec-Fetch-Dest", "empty") + .set("Sec-Fetch-Mode", "cors") + .set("Sec-Fetch-Site", "cross-site") + .removeAll("Upgrade-Insecure-Requests") .build() return POST("$baseApiUrl/graphql", newHeaders, body) } + override fun fetchPageList(chapter: SChapter): Observable> = + super.fetchPageList(chapter) + .doOnError { refreshApiKey(chapter) } + .retry(1) + override fun pageListParse(document: Document): List = throw UnsupportedOperationException("Not used") override fun pageListParse(response: Response): List { - val pagesString = json.decodeFromString(response.body.string()).data.chapter.pages - val pages = json.decodeFromString(pagesString) + val chapterObject = json.decodeFromString(response.body.string()) + + if (chapterObject.data?.chapter == null) { + if (chapterObject.errors != null) { + val errors = chapterObject.errors.joinToString("\n") { it.message } + throw Exception(errors) + } + throw Exception("Unknown error while processing pages") + } + + val pages = json.decodeFromString(chapterObject.data.chapter.pages) return pages.i.mapIndexed { i, page -> Page(i, "", "$baseCdnUrl/${pages.p}$page") } } + // Image + override fun imageUrlRequest(page: Page): Request { + val newHeaders = headersBuilder() + .set("Accept", "image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") + .set("Sec-Fetch-Dest", "image") + .set("Sec-Fetch-Mode", "no-cors") + .set("Sec-Fetch-Site", "cross-site") + .removeAll("Upgrade-Insecure-Requests") + .build() + + return GET(page.url, newHeaders) + } + override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used") // filters diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubGenerator.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubGenerator.kt index db6e77b53..83c879269 100644 --- a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubGenerator.kt +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubGenerator.kt @@ -9,7 +9,7 @@ class MangaHubGenerator : ThemeSourceGenerator { override val themeClass = "MangaHub" - override val baseVersionCode: Int = 18 + override val baseVersionCode: Int = 19 override val sources = listOf( // SingleLang("1Manga.co", "https://1manga.co", "en", isNsfw = true, className = "OneMangaCo"), diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubQueries.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubQueries.kt index 717741b4b..cf3badc47 100644 --- a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubQueries.kt +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubQueries.kt @@ -14,9 +14,15 @@ val PAGES_QUERY = buildQuery { """.trimIndent() } +@Serializable +data class ApiErrorMessages( + val message: String, +) + @Serializable data class ApiChapterPagesResponse( - val data: ApiChapterData, + val data: ApiChapterData?, + val errors: List?, ) @Serializable