MangaHub (multisrc) - Rewrite Extension to Use the API Instead of Scraping (#8392)
* updated chapter list parsing * More robust changes * Now uses HttpSource, updated logic to use API, and more * Fixed bugs, review changes, search and filter implementation * Address some PR comments * Review changes, improved API refresh logic, added pref for chapter titles --------- Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>
This commit is contained in:
parent
400079e2ae
commit
7586d7ff61
@ -2,7 +2,7 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 31
|
||||
baseVersionCode = 32
|
||||
|
||||
dependencies {
|
||||
api(project(":lib:randomua"))
|
||||
|
@ -1,37 +1,38 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangahub
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceScreen
|
||||
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.POST
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
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.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import keiyoushi.utils.getPreferencesLazy
|
||||
import keiyoushi.utils.parseAs
|
||||
import keiyoushi.utils.tryParse
|
||||
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
|
||||
import okhttp3.Request
|
||||
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.io.IOException
|
||||
import java.text.ParseException
|
||||
import java.net.URLEncoder
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
@ -41,21 +42,30 @@ abstract class MangaHub(
|
||||
final override val baseUrl: String,
|
||||
override val lang: String,
|
||||
private val mangaSource: String,
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MM-dd-yyyy", Locale.US),
|
||||
) : ParsedHttpSource() {
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH),
|
||||
) : HttpSource(), ConfigurableSource {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private var baseApiUrl = "https://api.mghcdn.com"
|
||||
private var baseCdnUrl = "https://imgx.mghcdn.com"
|
||||
private val baseApiUrl = "https://api.mghcdn.com"
|
||||
private val baseCdnUrl = "https://imgx.mghcdn.com"
|
||||
private val baseThumbCdnUrl = "https://thumb.mghcdn.com"
|
||||
private val regex = Regex("mhub_access=([^;]+)")
|
||||
|
||||
private val preferences: SharedPreferences by getPreferencesLazy()
|
||||
|
||||
private fun SharedPreferences.getUseGenericTitlePref(): Boolean = getBoolean(
|
||||
PREF_USE_GENERIC_TITLE,
|
||||
false,
|
||||
)
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.setRandomUserAgent(
|
||||
userAgentType = UserAgentType.DESKTOP,
|
||||
filterInclude = listOf("chrome"),
|
||||
)
|
||||
.addInterceptor(::apiAuthInterceptor)
|
||||
.addInterceptor(::graphQLApiInterceptor)
|
||||
.rateLimit(1)
|
||||
.build()
|
||||
|
||||
@ -69,7 +79,26 @@ abstract class MangaHub(
|
||||
.add("Sec-Fetch-Site", "same-origin")
|
||||
.add("Upgrade-Insecure-Requests", "1")
|
||||
|
||||
open val json: Json by injectLazy()
|
||||
private fun postRequestGraphQL(query: String): Request {
|
||||
val requestHeaders = 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()
|
||||
|
||||
val body = buildJsonObject {
|
||||
put("query", query)
|
||||
}
|
||||
|
||||
return POST("$baseApiUrl/graphql", requestHeaders, body.toString().toRequestBody())
|
||||
.newBuilder()
|
||||
.tag(GraphQLTag())
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun apiAuthInterceptor(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
@ -90,40 +119,92 @@ abstract class MangaHub(
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private fun refreshApiKey(chapter: SChapter) {
|
||||
val slug = "$baseUrl${chapter.url}"
|
||||
.toHttpUrlOrNull()
|
||||
?.pathSegments
|
||||
?.get(1)
|
||||
private fun graphQLApiInterceptor(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
val url = if (slug != null) {
|
||||
"$baseUrl/manga/$slug".toHttpUrl()
|
||||
} else {
|
||||
baseUrl.toHttpUrl()
|
||||
// We won't intercept non-graphql requests (like image retrieval)
|
||||
if (!request.hasGraphQLTag()) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val response = chain.proceed(request)
|
||||
|
||||
// We don't care about the data, only the possible error associated with it
|
||||
// If we encounter an error, we'll intercept it and throw an error for app to catch
|
||||
val apiResponse = response.peekBody(Long.MAX_VALUE).string().parseAs<ApiResponseError>()
|
||||
if (apiResponse.errors != null) {
|
||||
response.close() // Avoid leaks
|
||||
val errors = apiResponse.errors.joinToString("\n") { it.message }
|
||||
throw IOException(errors)
|
||||
}
|
||||
|
||||
// Everything works fine
|
||||
return response
|
||||
}
|
||||
|
||||
private fun Request.hasGraphQLTag(): Boolean {
|
||||
return this.tag() is GraphQLTag
|
||||
}
|
||||
|
||||
private fun refreshApiKey(chapter: SChapter) {
|
||||
val now = Calendar.getInstance().time.time
|
||||
|
||||
val url = "$baseUrl/chapter${chapter.url}".toHttpUrl()
|
||||
val oldKey = client.cookieJar
|
||||
.loadForRequest(baseUrl.toHttpUrl())
|
||||
.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) {
|
||||
// Clear key cookie
|
||||
val cookie = Cookie.parse(url, "mhub_access=; Max-Age=0; Path=/")!!
|
||||
client.cookieJar.saveFromResponse(url, listOf(cookie))
|
||||
client.cookieJar.saveFromResponse(url, listOf(cookie, recentlyCookie))
|
||||
|
||||
// We try requesting again with param if the first one fails
|
||||
val query = if (i == 2) "?reloadKey=1" else ""
|
||||
|
||||
try {
|
||||
val response = client.newCall(GET("$url$query", headers)).execute()
|
||||
val response = client.newCall(
|
||||
GET(
|
||||
"$url$query",
|
||||
headers.newBuilder()
|
||||
.set("Referer", "$baseUrl/manga/${url.pathSegments[1]}")
|
||||
.build(),
|
||||
),
|
||||
).execute()
|
||||
val returnedKey = response.headers["set-cookie"]?.let { regex.find(it)?.groupValues?.get(1) }
|
||||
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
|
||||
} catch (_: IOException) {
|
||||
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(
|
||||
@ -133,35 +214,36 @@ abstract class MangaHub(
|
||||
val signature: String,
|
||||
)
|
||||
|
||||
private fun Element.toSignature(): String {
|
||||
val author = this.select("small").text()
|
||||
val chNum = this.select(".col-sm-6 a:contains(#)").text()
|
||||
val genres = this.select(".genre-label").joinToString { it.text() }
|
||||
private fun ApiMangaSearchItem.toSignature(): String {
|
||||
val author = this.author
|
||||
val chNum = this.latestChapter
|
||||
val genres = this.genres
|
||||
|
||||
return author + chNum + genres
|
||||
}
|
||||
|
||||
// popular
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/popular/page/$page", headers)
|
||||
private fun mangaRequest(page: Int, order: String): Request {
|
||||
return postRequestGraphQL(searchQuery(mangaSource, "", "all", order, page))
|
||||
}
|
||||
|
||||
// popular
|
||||
override fun popularMangaRequest(page: Int): Request = mangaRequest(page, "POPULAR")
|
||||
|
||||
// often enough there will be nearly identical entries with slightly different
|
||||
// titles, URLs, and image names. in order to cut these "duplicates" down,
|
||||
// assign a "signature" based on author name, chapter number, and genres
|
||||
// if all of those are the same, then it it's the same manga
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val doc = response.asJsoup()
|
||||
val mangaList = response.parseAs<ApiSearchResponse>()
|
||||
|
||||
val mangas = doc.select(popularMangaSelector())
|
||||
.map {
|
||||
SMangaDTO(
|
||||
it.select("h4 a").attr("abs:href"),
|
||||
it.select("h4 a").text(),
|
||||
it.select("img").attr("abs:src"),
|
||||
it.toSignature(),
|
||||
)
|
||||
}
|
||||
val mangas = mangaList.data.search.rows.map {
|
||||
SMangaDTO(
|
||||
"$baseUrl/manga/${it.slug}",
|
||||
it.title,
|
||||
"$baseThumbCdnUrl/${it.image}",
|
||||
it.toSignature(),
|
||||
)
|
||||
}
|
||||
.distinctBy { it.signature }
|
||||
.map {
|
||||
SManga.create().apply {
|
||||
@ -170,198 +252,126 @@ abstract class MangaHub(
|
||||
thumbnail_url = it.thumbnailUrl
|
||||
}
|
||||
}
|
||||
return MangasPage(mangas, doc.select(popularMangaNextPageSelector()).isNotEmpty())
|
||||
|
||||
// Entries have a max of 30 per request
|
||||
return MangasPage(mangas, mangaList.data.search.rows.count() == 30)
|
||||
}
|
||||
|
||||
override fun popularMangaSelector() = ".col-sm-6:not(:has(a:contains(Yaoi)))"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun popularMangaNextPageSelector() = "ul.pager li.next > a"
|
||||
|
||||
// latest
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/updates/page/$page", headers)
|
||||
return mangaRequest(page, "LATEST")
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
return popularMangaParse(response)
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector() = popularMangaSelector()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
// search
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/search/page/$page".toHttpUrl().newBuilder()
|
||||
url.addQueryParameter("q", query)
|
||||
var order = "POPULAR"
|
||||
var genres = "all"
|
||||
|
||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||
when (filter) {
|
||||
is OrderBy -> {
|
||||
val order = filter.values[filter.state]
|
||||
url.addQueryParameter("order", order.key)
|
||||
order = filter.values[filter.state].key
|
||||
}
|
||||
is GenreList -> {
|
||||
val genre = filter.values[filter.state]
|
||||
url.addQueryParameter("genre", genre.key)
|
||||
genres = filter.included.joinToString(",").takeIf { it.isNotBlank() } ?: "all"
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = popularMangaSelector()
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga {
|
||||
throw UnsupportedOperationException()
|
||||
return postRequestGraphQL(searchQuery(mangaSource, query, genres, order, page))
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
return popularMangaParse(response)
|
||||
}
|
||||
|
||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
||||
|
||||
// manga details
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val manga = SManga.create()
|
||||
manga.title = document.select(".breadcrumb .active span").text()
|
||||
manga.author = document.select("div:has(h1) span:contains(Author) + span").first()?.text()
|
||||
manga.artist = document.select("div:has(h1) span:contains(Artist) + span").first()?.text()
|
||||
manga.genre = document.select(".row p a").joinToString { it.text() }
|
||||
manga.description = document.select(".tab-content p").first()?.text()
|
||||
manga.thumbnail_url = document.select("img.img-responsive").first()
|
||||
?.attr("src")
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return postRequestGraphQL(mangaDetailsQuery(mangaSource, manga.url.removePrefix("/manga/")))
|
||||
}
|
||||
|
||||
document.select("div:has(h1) span:contains(Status) + span").first()?.text()?.also { statusText ->
|
||||
when {
|
||||
statusText.contains("ongoing", true) -> manga.status = SManga.ONGOING
|
||||
statusText.contains("completed", true) -> manga.status = SManga.COMPLETED
|
||||
else -> manga.status = SManga.UNKNOWN
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val rawManga = response.parseAs<ApiMangaDetailsResponse>()
|
||||
|
||||
return SManga.create().apply {
|
||||
title = rawManga.data.manga.title!!
|
||||
author = rawManga.data.manga.author
|
||||
artist = rawManga.data.manga.artist
|
||||
genre = rawManga.data.manga.genres
|
||||
thumbnail_url = "$baseThumbCdnUrl/${rawManga.data.manga.image}"
|
||||
status = when (rawManga.data.manga.status) {
|
||||
"ongoing" -> SManga.ONGOING
|
||||
"completed" -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// add alternative name to manga description
|
||||
document.select("h1 small").firstOrNull()?.ownText()?.let { alternativeName ->
|
||||
if (alternativeName.isNotBlank()) {
|
||||
manga.description = manga.description.orEmpty().let {
|
||||
if (it.isBlank()) {
|
||||
"Alternative Name: $alternativeName"
|
||||
} else {
|
||||
"$it\n\nAlternative Name: $alternativeName"
|
||||
}
|
||||
description = buildString {
|
||||
rawManga.data.manga.description?.let(::append)
|
||||
|
||||
// Add alternative title
|
||||
val altTitle = rawManga.data.manga.alternativeTitle
|
||||
if (!altTitle.isNullOrBlank()) {
|
||||
if (isNotBlank()) append("\n\n")
|
||||
append("Alternative Name: $altTitle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
// chapters
|
||||
override fun getMangaUrl(manga: SManga): String = "$baseUrl${manga.url}"
|
||||
|
||||
// Chapters
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
return postRequestGraphQL(mangaChapterListQuery(mangaSource, manga.url.removePrefix("/manga/")))
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val head = document.head()
|
||||
return document.select(chapterListSelector()).map { chapterFromElement(it, head) }
|
||||
val chapterList = response.parseAs<ApiMangaDetailsResponse>()
|
||||
val useGenericTitle = preferences.getUseGenericTitlePref()
|
||||
|
||||
return chapterList.data.manga.chapters!!.map {
|
||||
SChapter.create().apply {
|
||||
val numberString = "${if (it.number % 1 == 0f) it.number.toInt() else it.number}"
|
||||
|
||||
name = if (!useGenericTitle) {
|
||||
generateChapterName(it.title.trim().replace("\n", " "), numberString)
|
||||
} else {
|
||||
generateGenericChapterName(numberString)
|
||||
}
|
||||
|
||||
url = "/${chapterList.data.manga.slug}/chapter-${it.number}"
|
||||
chapter_number = it.number
|
||||
date_upload = dateFormat.tryParse(it.date)
|
||||
}
|
||||
}.reversed() // The response is sorted in ASC format so we need to reverse it
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = ".tab-content ul li"
|
||||
|
||||
private fun chapterFromElement(element: Element, head: Element): SChapter {
|
||||
val chapter = SChapter.create()
|
||||
val potentialLinks = element.select("a[href*='$baseUrl/chapter/']")
|
||||
var visibleLink = ""
|
||||
potentialLinks.forEach { a ->
|
||||
val className = a.className()
|
||||
val styles = head.select("style").html()
|
||||
if (!styles.contains(".$className { display:none; }")) {
|
||||
visibleLink = a.attr("href")
|
||||
return@forEach
|
||||
}
|
||||
private fun generateChapterName(title: String, number: String): String {
|
||||
return if (title.contains(number)) {
|
||||
title
|
||||
} else if (title.isNotBlank()) {
|
||||
"Chapter $number - $title"
|
||||
} else {
|
||||
generateGenericChapterName(number)
|
||||
}
|
||||
chapter.setUrlWithoutDomain(visibleLink)
|
||||
chapter.name = chapter.url.trimEnd('/').substringAfterLast('/').replace('-', ' ')
|
||||
chapter.date_upload = element.select("small.UovLc").first()?.text()?.let { parseChapterDate(it) } ?: 0
|
||||
return chapter
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
throw UnsupportedOperationException()
|
||||
private fun generateGenericChapterName(number: String): String {
|
||||
return "Chapter $number"
|
||||
}
|
||||
|
||||
private fun parseChapterDate(date: String): Long {
|
||||
val now = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
var parsedDate = 0L
|
||||
when {
|
||||
"just now" in date || "less than an hour" in date -> {
|
||||
parsedDate = now.timeInMillis
|
||||
}
|
||||
// parses: "1 hour ago" and "2 hours ago"
|
||||
"hour" in date -> {
|
||||
val hours = date.replaceAfter(" ", "").trim().toInt()
|
||||
parsedDate = now.apply { add(Calendar.HOUR, -hours) }.timeInMillis
|
||||
}
|
||||
// parses: "Yesterday" and "2 days ago"
|
||||
"day" in date -> {
|
||||
val days = date.replace("days ago", "").trim().toIntOrNull() ?: 1
|
||||
parsedDate = now.apply { add(Calendar.DAY_OF_YEAR, -days) }.timeInMillis
|
||||
}
|
||||
// parses: "2 weeks ago"
|
||||
"weeks" in date -> {
|
||||
val weeks = date.replace("weeks ago", "").trim().toInt()
|
||||
parsedDate = now.apply { add(Calendar.WEEK_OF_YEAR, -weeks) }.timeInMillis
|
||||
}
|
||||
// parses: "12-20-2019" and defaults everything that wasn't taken into account to 0
|
||||
else -> {
|
||||
try {
|
||||
parsedDate = dateFormat.parse(date)?.time ?: 0L
|
||||
} catch (e: ParseException) { /*nothing to do, parsedDate is initialized with 0L*/ }
|
||||
}
|
||||
}
|
||||
return parsedDate
|
||||
}
|
||||
override fun getChapterUrl(chapter: SChapter): String = "$baseUrl/chapter${chapter.url}"
|
||||
|
||||
// pages
|
||||
// Pages
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val body = buildJsonObject {
|
||||
put("query", PAGES_QUERY)
|
||||
put(
|
||||
"variables",
|
||||
buildJsonObject {
|
||||
val chapterUrl = chapter.url.split("/")
|
||||
val chapterUrl = chapter.url.split("/")
|
||||
|
||||
put("mangaSource", mangaSource)
|
||||
put("slug", chapterUrl[2])
|
||||
put("number", chapterUrl[3].substringAfter("-").toFloat())
|
||||
},
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
.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)
|
||||
return postRequestGraphQL(pagesQuery(mangaSource, chapterUrl[1], chapterUrl[2].substringAfter("-").toFloat()))
|
||||
}
|
||||
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
|
||||
@ -369,22 +379,12 @@ abstract class MangaHub(
|
||||
.doOnError { refreshApiKey(chapter) }
|
||||
.retry(1)
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> = throw UnsupportedOperationException()
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val chapterObject = json.decodeFromString<ApiChapterPagesResponse>(response.body.string())
|
||||
val chapterObject = response.parseAs<ApiChapterPagesResponse>()
|
||||
val pages = chapterObject.data.chapter.pages.parseAs<ApiChapterPages>()
|
||||
|
||||
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<ApiChapterPages>(chapterObject.data.chapter.pages)
|
||||
|
||||
return pages.i.mapIndexed { i, page ->
|
||||
Page(i, "", "$baseCdnUrl/${pages.p}$page")
|
||||
return pages.images.mapIndexed { i, page ->
|
||||
Page(i, "", "$baseCdnUrl/${pages.page}$page")
|
||||
}
|
||||
}
|
||||
|
||||
@ -401,10 +401,14 @@ abstract class MangaHub(
|
||||
return GET(page.url, newHeaders)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException()
|
||||
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||
|
||||
// filters
|
||||
private class Genre(title: String, val key: String) : Filter.TriState(title) {
|
||||
private class Genre(title: String, val key: String) : Filter.CheckBox(title) {
|
||||
fun getGenreKey(): String {
|
||||
return key
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return name
|
||||
}
|
||||
@ -417,11 +421,14 @@ abstract class MangaHub(
|
||||
}
|
||||
|
||||
private class OrderBy(orders: Array<Order>) : Filter.Select<Order>("Order", orders, 0)
|
||||
private class GenreList(genres: Array<Genre>) : Filter.Select<Genre>("Genres", genres, 0)
|
||||
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres) {
|
||||
val included: List<String>
|
||||
get() = state.filter { it.state }.map { it.getGenreKey() }
|
||||
}
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
OrderBy(orderBy),
|
||||
GenreList(genres),
|
||||
OrderBy(orderBy),
|
||||
)
|
||||
|
||||
private val orderBy = arrayOf(
|
||||
@ -432,70 +439,119 @@ abstract class MangaHub(
|
||||
Order("Completed", "COMPLETED"),
|
||||
)
|
||||
|
||||
private val genres = arrayOf(
|
||||
Genre("All Genres", "all"),
|
||||
Genre("[no chapters]", "no-chapters"),
|
||||
Genre("4-Koma", "4-koma"),
|
||||
private val genres = listOf(
|
||||
Genre("Action", "action"),
|
||||
Genre("Adventure", "adventure"),
|
||||
Genre("Award Winning", "award-winning"),
|
||||
Genre("Comedy", "comedy"),
|
||||
Genre("Cooking", "cooking"),
|
||||
Genre("Crime", "crime"),
|
||||
Genre("Demons", "demons"),
|
||||
Genre("Doujinshi", "doujinshi"),
|
||||
Genre("Adult", "adult"),
|
||||
Genre("Drama", "drama"),
|
||||
Genre("Ecchi", "ecchi"),
|
||||
Genre("Fantasy", "fantasy"),
|
||||
Genre("Food", "food"),
|
||||
Genre("Game", "game"),
|
||||
Genre("Gender bender", "gender-bender"),
|
||||
Genre("Harem", "harem"),
|
||||
Genre("Historical", "historical"),
|
||||
Genre("Horror", "horror"),
|
||||
Genre("Isekai", "isekai"),
|
||||
Genre("Josei", "josei"),
|
||||
Genre("Kids", "kids"),
|
||||
Genre("Magic", "magic"),
|
||||
Genre("Magical Girls", "magical-girls"),
|
||||
Genre("Manhua", "manhua"),
|
||||
Genre("Martial Arts", "martial-arts"),
|
||||
Genre("Romance", "romance"),
|
||||
Genre("Ecchi", "ecchi"),
|
||||
Genre("Supernatural", "supernatural"),
|
||||
Genre("Webtoons", "webtoons"),
|
||||
Genre("Manhwa", "manhwa"),
|
||||
Genre("Martial arts", "martial-arts"),
|
||||
Genre("Fantasy", "fantasy"),
|
||||
Genre("Harem", "harem"),
|
||||
Genre("Shounen", "shounen"),
|
||||
Genre("Manhua", "manhua"),
|
||||
Genre("Mature", "mature"),
|
||||
Genre("Seinen", "seinen"),
|
||||
Genre("Sports", "sports"),
|
||||
Genre("School Life", "school-life"),
|
||||
Genre("Smut", "smut"),
|
||||
Genre("Mystery", "mystery"),
|
||||
Genre("Psychological", "psychological"),
|
||||
Genre("Shounen ai", "shounen-ai"),
|
||||
Genre("Slice of life", "slice-of-life"),
|
||||
Genre("Shoujo ai", "shoujo-ai"),
|
||||
Genre("Cooking", "cooking"),
|
||||
Genre("Horror", "horror"),
|
||||
Genre("Tragedy", "tragedy"),
|
||||
Genre("Doujinshi", "doujinshi"),
|
||||
Genre("Sci-Fi", "sci-fi"),
|
||||
Genre("Yuri", "yuri"),
|
||||
Genre("Yaoi", "yaoi"),
|
||||
Genre("Shoujo", "shoujo"),
|
||||
Genre("Gender bender", "gender-bender"),
|
||||
Genre("Josei", "josei"),
|
||||
Genre("Mecha", "mecha"),
|
||||
Genre("Medical", "medical"),
|
||||
Genre("Military", "military"),
|
||||
Genre("Magic", "magic"),
|
||||
Genre("4-Koma", "4-koma"),
|
||||
Genre("Music", "music"),
|
||||
Genre("Mystery", "mystery"),
|
||||
Genre("One shot", "one-shot"),
|
||||
Genre("Oneshot", "oneshot"),
|
||||
Genre("Parody", "parody"),
|
||||
Genre("Police", "police"),
|
||||
Genre("Psychological", "psychological"),
|
||||
Genre("Romance", "romance"),
|
||||
Genre("School life", "school-life"),
|
||||
Genre("Sci fi", "sci-fi"),
|
||||
Genre("Seinen", "seinen"),
|
||||
Genre("Shotacon", "shotacon"),
|
||||
Genre("Shoujo", "shoujo"),
|
||||
Genre("Shoujo ai", "shoujo-ai"),
|
||||
Genre("Shoujoai", "shoujoai"),
|
||||
Genre("Shounen", "shounen"),
|
||||
Genre("Shounen ai", "shounen-ai"),
|
||||
Genre("Shounenai", "shounenai"),
|
||||
Genre("Slice of life", "slice-of-life"),
|
||||
Genre("Smut", "smut"),
|
||||
Genre("Space", "space"),
|
||||
Genre("Sports", "sports"),
|
||||
Genre("Super Power", "super-power"),
|
||||
Genre("Superhero", "superhero"),
|
||||
Genre("Supernatural", "supernatural"),
|
||||
Genre("Thriller", "thriller"),
|
||||
Genre("Tragedy", "tragedy"),
|
||||
Genre("Vampire", "vampire"),
|
||||
Genre("Webtoon", "webtoon"),
|
||||
Genre("Webtoons", "webtoons"),
|
||||
Genre("Isekai", "isekai"),
|
||||
Genre("Game", "game"),
|
||||
Genre("Award Winning", "award-winning"),
|
||||
Genre("Oneshot", "oneshot"),
|
||||
Genre("Demons", "demons"),
|
||||
Genre("Military", "military"),
|
||||
Genre("Police", "police"),
|
||||
Genre("Super Power", "super-power"),
|
||||
Genre("Food", "food"),
|
||||
Genre("Kids", "kids"),
|
||||
Genre("Magical Girls", "magical-girls"),
|
||||
Genre("Wuxia", "wuxia"),
|
||||
Genre("Yuri", "yuri"),
|
||||
Genre("Superhero", "superhero"),
|
||||
Genre("Thriller", "thriller"),
|
||||
Genre("Crime", "crime"),
|
||||
Genre("Philosophical", "philosophical"),
|
||||
Genre("Adaptation", "adaptation"),
|
||||
Genre("Full Color", "full-color"),
|
||||
Genre("Crossdressing", "crossdressing"),
|
||||
Genre("Reincarnation", "reincarnation"),
|
||||
Genre("Manga", "manga"),
|
||||
Genre("Cartoon", "cartoon"),
|
||||
Genre("Survival", "survival"),
|
||||
Genre("Comic", "comic"),
|
||||
Genre("English", "english"),
|
||||
Genre("Harlequin", "harlequin"),
|
||||
Genre("Time Travel", "time-travel"),
|
||||
Genre("Traditional Games", "traditional-games"),
|
||||
Genre("Reverse Harem", "reverse-harem"),
|
||||
Genre("Animals", "animals"),
|
||||
Genre("Aliens", "aliens"),
|
||||
Genre("Loli", "loli"),
|
||||
Genre("Video Games", "video-games"),
|
||||
Genre("Monsters", "monsters"),
|
||||
Genre("Office Workers", "office-workers"),
|
||||
Genre("system", "system"),
|
||||
Genre("Villainess", "villainess"),
|
||||
Genre("Zombies", "zombies"),
|
||||
Genre("Vampires", "vampires"),
|
||||
Genre("Violence", "violence"),
|
||||
Genre("Monster Girls", "monster-girls"),
|
||||
Genre("Anthology", "anthology"),
|
||||
Genre("Ghosts", "ghosts"),
|
||||
Genre("Delinquents", "delinquents"),
|
||||
Genre("Post-Apocalyptic", "post-apocalyptic"),
|
||||
Genre("Xianxia", "xianxia"),
|
||||
Genre("Xuanhuan", "xuanhuan"),
|
||||
Genre("R-18", "r-18"),
|
||||
Genre("Cultivation", "cultivation"),
|
||||
Genre("Rebirth", "rebirth"),
|
||||
Genre("Gore", "gore"),
|
||||
Genre("Russian", "russian"),
|
||||
Genre("Samurai", "samurai"),
|
||||
Genre("Ninja", "ninja"),
|
||||
Genre("Revenge", "revenge"),
|
||||
Genre("Cheat Systems", "cheat-systems"),
|
||||
Genre("Dungeons", "dungeons"),
|
||||
Genre("Overpowered", "overpowered"),
|
||||
)
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = PREF_USE_GENERIC_TITLE
|
||||
title = "Use generic title"
|
||||
summary = "Use generic chapter title (\"Chapter 'x'\") instead of the given one.\nNote: May require manga entry to be refreshed."
|
||||
setDefaultValue(false)
|
||||
}.let(screen::addPreference)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_USE_GENERIC_TITLE = "pref_use_generic_title"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,94 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangahub
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
typealias ApiChapterPagesResponse = ApiResponse<ApiChapterData>
|
||||
typealias ApiSearchResponse = ApiResponse<ApiSearchObject>
|
||||
typealias ApiMangaDetailsResponse = ApiResponse<ApiMangaObject>
|
||||
|
||||
// Base classes
|
||||
@Serializable
|
||||
class ApiResponse<T>(
|
||||
val data: T,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiResponseError(
|
||||
val errors: List<ApiErrorMessages>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiErrorMessages(
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class PublicIPResponse(
|
||||
val ip: String,
|
||||
)
|
||||
|
||||
// Chapter metadata (pages)
|
||||
@Serializable
|
||||
class ApiChapterData(
|
||||
val chapter: ApiChapter,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiChapter(
|
||||
val pages: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiChapterPages(
|
||||
@SerialName("p") val page: String,
|
||||
@SerialName("i") val images: List<String>,
|
||||
)
|
||||
|
||||
// Search, Popular, Latest
|
||||
@Serializable
|
||||
class ApiSearchObject(
|
||||
val search: ApiSearchResults,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiSearchResults(
|
||||
val rows: List<ApiMangaSearchItem>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiMangaSearchItem(
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val image: String,
|
||||
val author: String,
|
||||
val latestChapter: Float,
|
||||
val genres: String,
|
||||
)
|
||||
|
||||
// Manga Details, Chapters
|
||||
@Serializable
|
||||
class ApiMangaObject(
|
||||
val manga: ApiMangaData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiMangaData(
|
||||
val title: String?,
|
||||
val status: String?,
|
||||
val image: String?,
|
||||
val author: String?,
|
||||
val artist: String?,
|
||||
val genres: String?,
|
||||
val description: String?,
|
||||
val alternativeTitle: String?,
|
||||
val slug: String?,
|
||||
val chapters: List<ApiMangaChapterList>?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ApiMangaChapterList(
|
||||
val number: Float,
|
||||
val title: String,
|
||||
val date: String,
|
||||
)
|
@ -1,42 +1,63 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mangahub
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
class GraphQLTag
|
||||
|
||||
private fun buildQuery(queryAction: () -> String) = queryAction().replace("%", "$")
|
||||
|
||||
val PAGES_QUERY = buildQuery {
|
||||
val searchQuery = { mangaSource: String, query: String, genre: String, order: String, page: Int ->
|
||||
"""
|
||||
query(%mangaSource: MangaSource, %slug: String!, %number: Float!) {
|
||||
chapter(x: %mangaSource, slug: %slug, number: %number) {
|
||||
pages
|
||||
{
|
||||
search(x: $mangaSource, q: "$query", genre: "$genre", mod: $order, offset: ${(page - 1) * 30}) {
|
||||
rows {
|
||||
title,
|
||||
author,
|
||||
slug,
|
||||
image,
|
||||
genres,
|
||||
latestChapter
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ApiErrorMessages(
|
||||
val message: String,
|
||||
)
|
||||
val mangaDetailsQuery = { mangaSource: String, slug: String ->
|
||||
"""
|
||||
{
|
||||
manga(x: $mangaSource, slug: "$slug") {
|
||||
title,
|
||||
slug,
|
||||
status,
|
||||
image,
|
||||
author,
|
||||
artist,
|
||||
genres,
|
||||
description,
|
||||
alternativeTitle
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ApiChapterPagesResponse(
|
||||
val data: ApiChapterData?,
|
||||
val errors: List<ApiErrorMessages>?,
|
||||
)
|
||||
val mangaChapterListQuery = { mangaSource: String, slug: String ->
|
||||
"""
|
||||
{
|
||||
manga(x: $mangaSource, slug: "$slug") {
|
||||
slug,
|
||||
chapters {
|
||||
number,
|
||||
title,
|
||||
date
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ApiChapterData(
|
||||
val chapter: ApiChapter?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiChapter(
|
||||
val pages: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiChapterPages(
|
||||
val p: String,
|
||||
val i: List<String>,
|
||||
)
|
||||
val pagesQuery = { mangaSource: String, slug: String, number: Float ->
|
||||
"""
|
||||
{
|
||||
chapter(x: $mangaSource, slug: "$slug", number: $number) {
|
||||
pages
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user