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:
Jake 2025-04-14 22:42:29 +08:00 committed by Draff
parent 400079e2ae
commit 7586d7ff61
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
4 changed files with 461 additions and 290 deletions

View File

@ -2,7 +2,7 @@ plugins {
id("lib-multisrc") id("lib-multisrc")
} }
baseVersionCode = 31 baseVersionCode = 32
dependencies { dependencies {
api(project(":lib:randomua")) api(project(":lib:randomua"))

View File

@ -1,37 +1,38 @@
package eu.kanade.tachiyomi.multisrc.mangahub 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.UserAgentType
import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent 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.network.interceptor.rateLimit
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
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.ParsedHttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup import keiyoushi.utils.getPreferencesLazy
import kotlinx.serialization.decodeFromString import keiyoushi.utils.parseAs
import kotlinx.serialization.json.Json import keiyoushi.utils.tryParse
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient 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 org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.io.IOException import java.io.IOException
import java.text.ParseException 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
@ -41,21 +42,30 @@ abstract class MangaHub(
final override val baseUrl: String, final override val baseUrl: String,
override val lang: String, override val lang: String,
private val mangaSource: String, private val mangaSource: String,
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MM-dd-yyyy", Locale.US), private val dateFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH),
) : ParsedHttpSource() { ) : HttpSource(), ConfigurableSource {
override val supportsLatest = true override val supportsLatest = true
private var baseApiUrl = "https://api.mghcdn.com" private val baseApiUrl = "https://api.mghcdn.com"
private var baseCdnUrl = "https://imgx.mghcdn.com" private val baseCdnUrl = "https://imgx.mghcdn.com"
private val baseThumbCdnUrl = "https://thumb.mghcdn.com"
private val regex = Regex("mhub_access=([^;]+)") 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() override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.setRandomUserAgent( .setRandomUserAgent(
userAgentType = UserAgentType.DESKTOP, userAgentType = UserAgentType.DESKTOP,
filterInclude = listOf("chrome"), filterInclude = listOf("chrome"),
) )
.addInterceptor(::apiAuthInterceptor) .addInterceptor(::apiAuthInterceptor)
.addInterceptor(::graphQLApiInterceptor)
.rateLimit(1) .rateLimit(1)
.build() .build()
@ -69,7 +79,26 @@ abstract class MangaHub(
.add("Sec-Fetch-Site", "same-origin") .add("Sec-Fetch-Site", "same-origin")
.add("Upgrade-Insecure-Requests", "1") .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 { private fun apiAuthInterceptor(chain: Interceptor.Chain): Response {
val originalRequest = chain.request() val originalRequest = chain.request()
@ -90,40 +119,92 @@ abstract class MangaHub(
return chain.proceed(request) return chain.proceed(request)
} }
private fun refreshApiKey(chapter: SChapter) { private fun graphQLApiInterceptor(chain: Interceptor.Chain): Response {
val slug = "$baseUrl${chapter.url}" val request = chain.request()
.toHttpUrlOrNull()
?.pathSegments
?.get(1)
val url = if (slug != null) { // We won't intercept non-graphql requests (like image retrieval)
"$baseUrl/manga/$slug".toHttpUrl() if (!request.hasGraphQLTag()) {
} else { return chain.proceed(request)
baseUrl.toHttpUrl()
} }
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 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)) client.cookieJar.saveFromResponse(url, listOf(cookie, recentlyCookie))
// 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 ""
try { 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) } val returnedKey = response.headers["set-cookie"]?.let { regex.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
} catch (_: IOException) { } catch (_: IOException) {
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(
@ -133,35 +214,36 @@ abstract class MangaHub(
val signature: String, val signature: String,
) )
private fun Element.toSignature(): String { private fun ApiMangaSearchItem.toSignature(): String {
val author = this.select("small").text() val author = this.author
val chNum = this.select(".col-sm-6 a:contains(#)").text() val chNum = this.latestChapter
val genres = this.select(".genre-label").joinToString { it.text() } val genres = this.genres
return author + chNum + genres return author + chNum + genres
} }
// popular private fun mangaRequest(page: Int, order: String): Request {
override fun popularMangaRequest(page: Int): Request { return postRequestGraphQL(searchQuery(mangaSource, "", "all", order, page))
return GET("$baseUrl/popular/page/$page", headers)
} }
// popular
override fun popularMangaRequest(page: Int): Request = mangaRequest(page, "POPULAR")
// often enough there will be nearly identical entries with slightly different // often enough there will be nearly identical entries with slightly different
// titles, URLs, and image names. in order to cut these "duplicates" down, // titles, URLs, and image names. in order to cut these "duplicates" down,
// assign a "signature" based on author name, chapter number, and genres // 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 // if all of those are the same, then it it's the same manga
override fun popularMangaParse(response: Response): MangasPage { override fun popularMangaParse(response: Response): MangasPage {
val doc = response.asJsoup() val mangaList = response.parseAs<ApiSearchResponse>()
val mangas = doc.select(popularMangaSelector()) val mangas = mangaList.data.search.rows.map {
.map { SMangaDTO(
SMangaDTO( "$baseUrl/manga/${it.slug}",
it.select("h4 a").attr("abs:href"), it.title,
it.select("h4 a").text(), "$baseThumbCdnUrl/${it.image}",
it.select("img").attr("abs:src"), it.toSignature(),
it.toSignature(), )
) }
}
.distinctBy { it.signature } .distinctBy { it.signature }
.map { .map {
SManga.create().apply { SManga.create().apply {
@ -170,198 +252,126 @@ abstract class MangaHub(
thumbnail_url = it.thumbnailUrl 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 // latest
override fun latestUpdatesRequest(page: Int): Request { override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/updates/page/$page", headers) return mangaRequest(page, "LATEST")
} }
override fun latestUpdatesParse(response: Response): MangasPage { override fun latestUpdatesParse(response: Response): MangasPage {
return popularMangaParse(response) return popularMangaParse(response)
} }
override fun latestUpdatesSelector() = popularMangaSelector()
override fun latestUpdatesFromElement(element: Element): SManga {
throw UnsupportedOperationException()
}
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
// search // search
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/search/page/$page".toHttpUrl().newBuilder() var order = "POPULAR"
url.addQueryParameter("q", query) var genres = "all"
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> (if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
when (filter) { when (filter) {
is OrderBy -> { is OrderBy -> {
val order = filter.values[filter.state] order = filter.values[filter.state].key
url.addQueryParameter("order", order.key)
} }
is GenreList -> { is GenreList -> {
val genre = filter.values[filter.state] genres = filter.included.joinToString(",").takeIf { it.isNotBlank() } ?: "all"
url.addQueryParameter("genre", genre.key)
} }
else -> {} else -> {}
} }
} }
return GET(url.build(), headers)
}
override fun searchMangaSelector() = popularMangaSelector() return postRequestGraphQL(searchQuery(mangaSource, query, genres, order, page))
override fun searchMangaFromElement(element: Element): SManga {
throw UnsupportedOperationException()
} }
override fun searchMangaParse(response: Response): MangasPage { override fun searchMangaParse(response: Response): MangasPage {
return popularMangaParse(response) return popularMangaParse(response)
} }
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
// manga details // manga details
override fun mangaDetailsParse(document: Document): SManga { override fun mangaDetailsRequest(manga: SManga): Request {
val manga = SManga.create() return postRequestGraphQL(mangaDetailsQuery(mangaSource, manga.url.removePrefix("/manga/")))
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")
document.select("div:has(h1) span:contains(Status) + span").first()?.text()?.also { statusText -> override fun mangaDetailsParse(response: Response): SManga {
when { val rawManga = response.parseAs<ApiMangaDetailsResponse>()
statusText.contains("ongoing", true) -> manga.status = SManga.ONGOING
statusText.contains("completed", true) -> manga.status = SManga.COMPLETED return SManga.create().apply {
else -> manga.status = SManga.UNKNOWN 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 description = buildString {
document.select("h1 small").firstOrNull()?.ownText()?.let { alternativeName -> rawManga.data.manga.description?.let(::append)
if (alternativeName.isNotBlank()) {
manga.description = manga.description.orEmpty().let { // Add alternative title
if (it.isBlank()) { val altTitle = rawManga.data.manga.alternativeTitle
"Alternative Name: $alternativeName" if (!altTitle.isNullOrBlank()) {
} else { if (isNotBlank()) append("\n\n")
"$it\n\nAlternative Name: $alternativeName" 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> { override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup() val chapterList = response.parseAs<ApiMangaDetailsResponse>()
val head = document.head() val useGenericTitle = preferences.getUseGenericTitlePref()
return document.select(chapterListSelector()).map { chapterFromElement(it, head) }
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 generateChapterName(title: String, number: String): String {
return if (title.contains(number)) {
private fun chapterFromElement(element: Element, head: Element): SChapter { title
val chapter = SChapter.create() } else if (title.isNotBlank()) {
val potentialLinks = element.select("a[href*='$baseUrl/chapter/']") "Chapter $number - $title"
var visibleLink = "" } else {
potentialLinks.forEach { a -> generateGenericChapterName(number)
val className = a.className()
val styles = head.select("style").html()
if (!styles.contains(".$className { display:none; }")) {
visibleLink = a.attr("href")
return@forEach
}
} }
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 { private fun generateGenericChapterName(number: String): String {
throw UnsupportedOperationException() return "Chapter $number"
} }
private fun parseChapterDate(date: String): Long { override fun getChapterUrl(chapter: SChapter): String = "$baseUrl/chapter${chapter.url}"
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
}
// pages // Pages
override fun pageListRequest(chapter: SChapter): Request { override fun pageListRequest(chapter: SChapter): Request {
val body = buildJsonObject { val chapterUrl = chapter.url.split("/")
put("query", PAGES_QUERY)
put(
"variables",
buildJsonObject {
val chapterUrl = chapter.url.split("/")
put("mangaSource", mangaSource) return postRequestGraphQL(pagesQuery(mangaSource, chapterUrl[1], chapterUrl[2].substringAfter("-").toFloat()))
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)
} }
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> = override fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
@ -369,22 +379,12 @@ abstract class MangaHub(
.doOnError { refreshApiKey(chapter) } .doOnError { refreshApiKey(chapter) }
.retry(1) .retry(1)
override fun pageListParse(document: Document): List<Page> = throw UnsupportedOperationException()
override fun pageListParse(response: Response): List<Page> { 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) { return pages.images.mapIndexed { i, page ->
if (chapterObject.errors != null) { Page(i, "", "$baseCdnUrl/${pages.page}$page")
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")
} }
} }
@ -401,10 +401,14 @@ abstract class MangaHub(
return GET(page.url, newHeaders) return GET(page.url, newHeaders)
} }
override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException() override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
// filters // 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 { override fun toString(): String {
return name return name
} }
@ -417,11 +421,14 @@ abstract class MangaHub(
} }
private class OrderBy(orders: Array<Order>) : Filter.Select<Order>("Order", orders, 0) 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( override fun getFilterList() = FilterList(
OrderBy(orderBy),
GenreList(genres), GenreList(genres),
OrderBy(orderBy),
) )
private val orderBy = arrayOf( private val orderBy = arrayOf(
@ -432,70 +439,119 @@ abstract class MangaHub(
Order("Completed", "COMPLETED"), Order("Completed", "COMPLETED"),
) )
private val genres = arrayOf( private val genres = listOf(
Genre("All Genres", "all"),
Genre("[no chapters]", "no-chapters"),
Genre("4-Koma", "4-koma"),
Genre("Action", "action"), Genre("Action", "action"),
Genre("Adventure", "adventure"), Genre("Adventure", "adventure"),
Genre("Award Winning", "award-winning"),
Genre("Comedy", "comedy"), Genre("Comedy", "comedy"),
Genre("Cooking", "cooking"), Genre("Adult", "adult"),
Genre("Crime", "crime"),
Genre("Demons", "demons"),
Genre("Doujinshi", "doujinshi"),
Genre("Drama", "drama"), 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("Historical", "historical"),
Genre("Horror", "horror"), Genre("Martial Arts", "martial-arts"),
Genre("Isekai", "isekai"), Genre("Romance", "romance"),
Genre("Josei", "josei"), Genre("Ecchi", "ecchi"),
Genre("Kids", "kids"), Genre("Supernatural", "supernatural"),
Genre("Magic", "magic"), Genre("Webtoons", "webtoons"),
Genre("Magical Girls", "magical-girls"),
Genre("Manhua", "manhua"),
Genre("Manhwa", "manhwa"), Genre("Manhwa", "manhwa"),
Genre("Martial arts", "martial-arts"), Genre("Fantasy", "fantasy"),
Genre("Harem", "harem"),
Genre("Shounen", "shounen"),
Genre("Manhua", "manhua"),
Genre("Mature", "mature"), 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("Mecha", "mecha"),
Genre("Medical", "medical"), Genre("Medical", "medical"),
Genre("Military", "military"), Genre("Magic", "magic"),
Genre("4-Koma", "4-koma"),
Genre("Music", "music"), 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("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("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"
}
} }

View File

@ -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,
)

View File

@ -1,42 +1,63 @@
package eu.kanade.tachiyomi.multisrc.mangahub package eu.kanade.tachiyomi.multisrc.mangahub
import kotlinx.serialization.Serializable class GraphQLTag
private fun buildQuery(queryAction: () -> String) = queryAction().replace("%", "$") val searchQuery = { mangaSource: String, query: String, genre: String, order: String, page: Int ->
val PAGES_QUERY = buildQuery {
""" """
query(%mangaSource: MangaSource, %slug: String!, %number: Float!) { {
chapter(x: %mangaSource, slug: %slug, number: %number) { search(x: $mangaSource, q: "$query", genre: "$genre", mod: $order, offset: ${(page - 1) * 30}) {
pages rows {
title,
author,
slug,
image,
genres,
latestChapter
} }
} }
}
""".trimIndent() """.trimIndent()
} }
@Serializable val mangaDetailsQuery = { mangaSource: String, slug: String ->
data class ApiErrorMessages( """
val message: String, {
) manga(x: $mangaSource, slug: "$slug") {
title,
slug,
status,
image,
author,
artist,
genres,
description,
alternativeTitle
}
}
""".trimIndent()
}
@Serializable val mangaChapterListQuery = { mangaSource: String, slug: String ->
data class ApiChapterPagesResponse( """
val data: ApiChapterData?, {
val errors: List<ApiErrorMessages>?, manga(x: $mangaSource, slug: "$slug") {
) slug,
chapters {
number,
title,
date
}
}
}
""".trimIndent()
}
@Serializable val pagesQuery = { mangaSource: String, slug: String, number: Float ->
data class ApiChapterData( """
val chapter: ApiChapter?, {
) chapter(x: $mangaSource, slug: "$slug", number: $number) {
pages
@Serializable }
data class ApiChapter( }
val pages: String, """.trimIndent()
) }
@Serializable
data class ApiChapterPages(
val p: String,
val i: List<String>,
)