Fix Ganma: use new API GraphQL (#10687)
* api change to graphql * fix web entries * add afterword page, reduce requests * some review changes * serializable class templates
This commit is contained in:
parent
33f4d5f8c0
commit
7d62c04507
@ -1,7 +1,7 @@
|
|||||||
ext {
|
ext {
|
||||||
extName = 'GANMA!'
|
extName = 'GANMA!'
|
||||||
extClass = '.Ganma'
|
extClass = '.Ganma'
|
||||||
extVersionCode = 2
|
extVersionCode = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "$rootDir/common.gradle"
|
apply from: "$rootDir/common.gradle"
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.extension.ja.ganma
|
package eu.kanade.tachiyomi.extension.ja.ganma
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.asObservable
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
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
|
||||||
@ -10,12 +10,19 @@ 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.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import kotlinx.serialization.json.Json
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
import kotlinx.serialization.json.decodeFromStream
|
import keiyoushi.utils.jsonInstance
|
||||||
|
import keiyoushi.utils.parseAs
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import okio.Buffer
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
|
|
||||||
class Ganma : HttpSource() {
|
class Ganma : HttpSource() {
|
||||||
override val name = "GANMA!"
|
override val name = "GANMA!"
|
||||||
@ -23,91 +30,314 @@ class Ganma : HttpSource() {
|
|||||||
override val baseUrl = "https://ganma.jp"
|
override val baseUrl = "https://ganma.jp"
|
||||||
override val supportsLatest = true
|
override val supportsLatest = true
|
||||||
|
|
||||||
override fun headersBuilder() = super.headersBuilder().add("X-From", baseUrl)
|
override fun headersBuilder() = super.headersBuilder()
|
||||||
|
.add("X-From", "$baseUrl/web")
|
||||||
|
.add("Content-Type", "application/json;charset=UTF-8")
|
||||||
|
.add("Accept", "application/json, text/plain, */*")
|
||||||
|
|
||||||
override fun popularMangaRequest(page: Int) =
|
private val apiUrl = "$baseUrl/api/graphql"
|
||||||
when (page) {
|
|
||||||
1 -> GET("$baseUrl/api/1.0/ranking", headers)
|
private var operationsMap: Map<String, String>? = null
|
||||||
else -> GET("$baseUrl/api/1.1/ranking?flag=Finish", headers)
|
private var lastSearchCursor: String? = null
|
||||||
|
private var lastFilterCursor: String? = null
|
||||||
|
|
||||||
|
// https://ganma.jp/web/_next/static/chunks/app/layout-98772c0967d4bfb7.js
|
||||||
|
// Hashes to bypass OnlyPersistedQueryIsAllowed
|
||||||
|
private fun fetchAndParseHashes(): Map<String, String> {
|
||||||
|
val mainPage = client.newCall(GET("$baseUrl/web", headers)).execute()
|
||||||
|
val document = mainPage.asJsoup()
|
||||||
|
|
||||||
|
val mainScriptUrl = document.selectFirst("script[src*=/app/layout-]")
|
||||||
|
?.attr("abs:src")
|
||||||
|
?.ifEmpty { null }
|
||||||
|
?: throw Exception("Could not find layout script")
|
||||||
|
|
||||||
|
val scriptContent = client.newCall(GET(mainScriptUrl, headers)).execute().body.string()
|
||||||
|
|
||||||
|
val manifestRegex = """operations:(\[.+?])\};""".toRegex()
|
||||||
|
val manifestMatch = manifestRegex.find(scriptContent)
|
||||||
|
?: throw Exception("Could not find operations manifest in script")
|
||||||
|
|
||||||
|
val manifestJson = manifestMatch.groupValues[1]
|
||||||
|
|
||||||
|
val operationRegex = """id:"([a-f0-9]{64})",body:".*?",name:"(\w+)"""".toRegex()
|
||||||
|
return operationRegex.findAll(manifestJson).associate {
|
||||||
|
val (hash, name) = it.destructured
|
||||||
|
name to hash
|
||||||
|
}.also { operationsMap = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> graphQlRequest(operationName: String, variables: T, useAppHeaders: Boolean = true): Request {
|
||||||
|
val hashes = operationsMap ?: fetchAndParseHashes()
|
||||||
|
val hash = hashes[operationName] ?: throw Exception("Could not find hash for operation: $operationName")
|
||||||
|
|
||||||
|
val finalHeaders = headersBuilder().apply {
|
||||||
|
if (useAppHeaders) {
|
||||||
|
set("User-Agent", "GanmaReader/9.9.1 Android")
|
||||||
|
} else {
|
||||||
|
set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
val extensions = Payload.Extensions(Payload.Extensions.PersistedQuery(version = 1, sha256Hash = hash))
|
||||||
|
val payload = Payload(operationName, variables, extensions)
|
||||||
|
val payloadSerializer = Payload.serializer(serializer<T>())
|
||||||
|
|
||||||
|
val requestBody = jsonInstance.encodeToString(payloadSerializer, payload).toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||||
|
return POST(apiUrl, finalHeaders, requestBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateImageUrlWidth(url: String?, width: Int = 4999): String? {
|
||||||
|
return url?.toHttpUrlOrNull()
|
||||||
|
?.newBuilder()
|
||||||
|
?.setQueryParameter("w", width.toString())
|
||||||
|
?.build()
|
||||||
|
?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cacheWebOnlyAliases: Set<String> by lazy {
|
||||||
|
try {
|
||||||
|
fetchWebOnlyAliases()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchWebOnlyAliases(): Set<String> {
|
||||||
|
val response = client.newCall(GET("$baseUrl/web/magazineCategory/webOnly", headers)).execute()
|
||||||
|
val document = response.asJsoup()
|
||||||
|
return document.select("a[href*=/web/magazine/]")
|
||||||
|
.map { it.attr("href").substringAfterLast("/") }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
|
return graphQlRequest("home", EmptyVariables, useAppHeaders = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun popularMangaParse(response: Response): MangasPage {
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
val list: List<Magazine> = response.parseAs()
|
val data = response.parseAs<GraphQLResponse<HomeDto>>().data
|
||||||
return MangasPage(list.map { it.toSManga() }, false)
|
val mangas = data.ranking.totalRanking.map { it.toSManga() }
|
||||||
|
return MangasPage(mangas, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/api/2.2/top", headers)
|
override fun latestUpdatesRequest(page: Int): Request {
|
||||||
|
return graphQlRequest("home", EmptyVariables, useAppHeaders = true)
|
||||||
|
}
|
||||||
|
|
||||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||||
val list = response.parseAs<Top>().boxes.flatMap { it.panels }
|
val data = response.parseAs<GraphQLResponse<HomeDto>>().data
|
||||||
.filter { it.newestStoryItem != null }
|
val mangas = data.latestTotalRanking10.map { it.toSManga() }
|
||||||
.sortedByDescending { it.newestStoryItem!!.release }
|
return MangasPage(mangas, false)
|
||||||
return MangasPage(list.map { it.toSManga() }, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
val pageNumber = when (filters.size) {
|
if (query.isNotBlank()) {
|
||||||
0 -> 1
|
if (page == 1) {
|
||||||
else -> (filters[0] as TypeFilter).state + 1
|
lastSearchCursor = null
|
||||||
}
|
}
|
||||||
return fetchPopularManga(pageNumber).map { mangasPage ->
|
val variables = SearchVariables(keyword = query, first = 20, after = lastSearchCursor)
|
||||||
MangasPage(mangasPage.mangas.filter { it.title.contains(query) }, false)
|
return graphQlRequest("magazinesByKeywordSearch", variables, useAppHeaders = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page == 1) {
|
||||||
|
lastFilterCursor = null
|
||||||
|
}
|
||||||
|
val category = filters.filterIsInstance<CategoryFilter>().first().selected
|
||||||
|
|
||||||
|
return when (category.type) {
|
||||||
|
"day" -> {
|
||||||
|
val variables = DayOfWeekVariables(dayOfWeek = category.id, first = 20, after = lastFilterCursor)
|
||||||
|
graphQlRequest("serialMagazinesByDayOfWeek", variables)
|
||||||
|
}
|
||||||
|
"finished" -> {
|
||||||
|
val variables = FinishedVariables(first = 20, after = lastFilterCursor)
|
||||||
|
graphQlRequest("finishedMagazines", variables)
|
||||||
|
}
|
||||||
|
else -> graphQlRequest("home", EmptyVariables)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request =
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
throw UnsupportedOperationException()
|
val operationName = Buffer().use {
|
||||||
|
response.request.body?.writeTo(it)
|
||||||
override fun searchMangaParse(response: Response): MangasPage =
|
it.readUtf8().parseAs<JsonObject>()["operationName"]?.jsonPrimitive?.content
|
||||||
throw UnsupportedOperationException()
|
|
||||||
|
|
||||||
// navigate Webview to web page
|
|
||||||
override fun mangaDetailsRequest(manga: SManga) =
|
|
||||||
GET("$baseUrl/${manga.url.alias()}", headers)
|
|
||||||
|
|
||||||
protected open fun realMangaDetailsRequest(manga: SManga) =
|
|
||||||
GET("$baseUrl/api/1.0/magazines/web/${manga.url.alias()}", headers)
|
|
||||||
|
|
||||||
override fun chapterListRequest(manga: SManga) = realMangaDetailsRequest(manga)
|
|
||||||
|
|
||||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
|
|
||||||
client.newCall(realMangaDetailsRequest(manga)).asObservableSuccess()
|
|
||||||
.map { mangaDetailsParse(it) }
|
|
||||||
|
|
||||||
override fun mangaDetailsParse(response: Response): SManga =
|
|
||||||
response.parseAs<Magazine>().toSMangaDetails()
|
|
||||||
|
|
||||||
protected open fun List<SChapter>.sortedDescending() = this.asReversed()
|
|
||||||
|
|
||||||
override fun chapterListParse(response: Response): List<SChapter> =
|
|
||||||
response.parseAs<Magazine>().getSChapterList().sortedDescending()
|
|
||||||
|
|
||||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
|
|
||||||
client.newCall(pageListRequest(chapter)).asObservable()
|
|
||||||
.map { pageListParse(chapter, it) }
|
|
||||||
|
|
||||||
override fun pageListRequest(chapter: SChapter) =
|
|
||||||
GET("$baseUrl/api/1.0/magazines/web/${chapter.url.alias()}", headers)
|
|
||||||
|
|
||||||
protected open fun pageListParse(chapter: SChapter, response: Response): List<Page> {
|
|
||||||
val manga: Magazine = response.parseAs()
|
|
||||||
val chapterId = chapter.url.substringAfter('/')
|
|
||||||
return manga.items.find { it.id == chapterId }!!.toPageList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final override fun pageListParse(response: Response): List<Page> =
|
if (operationName == "magazinesByKeywordSearch") {
|
||||||
throw UnsupportedOperationException()
|
val data = response.parseAs<GraphQLResponse<SearchDto>>().data
|
||||||
|
lastSearchCursor = data.searchComic.pageInfo.endCursor
|
||||||
override fun imageUrlParse(response: Response): String =
|
val mangas = data.searchComic.edges
|
||||||
throw UnsupportedOperationException()
|
.mapNotNull { it.node }
|
||||||
|
.map { it.toSManga() }
|
||||||
protected open class TypeFilter : Filter.Select<String>("Type", arrayOf("Popular", "Completed"))
|
return MangasPage(mangas, data.searchComic.pageInfo.hasNextPage)
|
||||||
|
|
||||||
override fun getFilterList() = FilterList(TypeFilter())
|
|
||||||
|
|
||||||
protected inline fun <reified T> Response.parseAs(): T = use {
|
|
||||||
json.decodeFromStream<Result<T>>(it.body.byteStream()).root
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val json: Json by injectLazy()
|
return when (operationName) {
|
||||||
|
"serialMagazinesByDayOfWeek" -> {
|
||||||
|
val data = response.parseAs<GraphQLResponse<SerialResponseDto>>().data.serialPerDayOfWeek.panels
|
||||||
|
lastFilterCursor = data.pageInfo.endCursor
|
||||||
|
val mangas = data.edges
|
||||||
|
.map { it.node.storyInfo.magazine }
|
||||||
|
.map { it.toSManga() }
|
||||||
|
MangasPage(mangas, data.pageInfo.hasNextPage)
|
||||||
|
}
|
||||||
|
"finishedMagazines" -> {
|
||||||
|
val data = response.parseAs<GraphQLResponse<FinishedResponseDto>>().data.magazinesByCategory.magazines
|
||||||
|
lastFilterCursor = data.pageInfo.endCursor
|
||||||
|
val mangas = data.edges
|
||||||
|
.map { it.node }
|
||||||
|
.map { it.toSManga() }
|
||||||
|
MangasPage(mangas, data.pageInfo.hasNextPage)
|
||||||
|
}
|
||||||
|
"home" -> {
|
||||||
|
val data = response.parseAs<GraphQLResponse<HomeDto>>().data
|
||||||
|
val mangas = data.ranking.totalRanking
|
||||||
|
.map { it.toSManga() }
|
||||||
|
MangasPage(mangas, false)
|
||||||
|
}
|
||||||
|
else -> MangasPage(emptyList(), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MangaItemDto.toSManga(): SManga = SManga.create().apply {
|
||||||
|
url = this@toSManga.alias
|
||||||
|
title = this@toSManga.title
|
||||||
|
thumbnail_url = updateImageUrlWidth(this@toSManga.todaysJacketImageURL ?: this@toSManga.rectangleWithLogoImageURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
|
val variables = MagazineDetailVariables(magazineIdOrAlias = manga.url)
|
||||||
|
return graphQlRequest("magazineDetail", variables)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getMangaUrl(manga: SManga): String = "$baseUrl/web/magazine/${manga.url}"
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
|
val magazine = response.parseAs<GraphQLResponse<MagazineDetailDto>>().data.magazine
|
||||||
|
val manga = SManga.create().apply {
|
||||||
|
url = magazine.alias
|
||||||
|
title = magazine.title
|
||||||
|
author = magazine.authorName
|
||||||
|
description = magazine.description
|
||||||
|
status = if (magazine.isFinished) SManga.COMPLETED else SManga.ONGOING
|
||||||
|
thumbnail_url = updateImageUrlWidth(magazine.todaysJacketImageURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (magazine.todaysJacketImageURL == null) {
|
||||||
|
try {
|
||||||
|
val searchResponse = client.newCall(searchMangaRequest(1, manga.title, FilterList())).execute()
|
||||||
|
if (searchResponse.isSuccessful) {
|
||||||
|
val searchData = searchResponse.parseAs<GraphQLResponse<SearchDto>>().data
|
||||||
|
val searchResultUrl = searchData.searchComic.edges
|
||||||
|
.firstOrNull { it.node?.alias == manga.url }
|
||||||
|
?.node?.todaysJacketImageURL
|
||||||
|
|
||||||
|
if (searchResultUrl != null) {
|
||||||
|
manga.thumbnail_url = updateImageUrlWidth(searchResultUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return manga
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||||
|
return Observable.fromCallable {
|
||||||
|
val chapters = mutableListOf<StoryInfoNode>()
|
||||||
|
var hasNextPage = true
|
||||||
|
var cursor: String? = null
|
||||||
|
|
||||||
|
while (hasNextPage) {
|
||||||
|
val variables = StoryInfoListVariables(magazineIdOrAlias = manga.url, first = 100, after = cursor)
|
||||||
|
val response = client.newCall(graphQlRequest("storyInfoList", variables, useAppHeaders = false)).execute()
|
||||||
|
val data = response.parseAs<GraphQLResponse<ChapterListDto>>().data.magazine.storyInfos
|
||||||
|
|
||||||
|
chapters.addAll(data.edges.map { it.node })
|
||||||
|
hasNextPage = data.pageInfo.hasNextPage
|
||||||
|
cursor = data.pageInfo.endCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters.mapIndexed { index, chapter ->
|
||||||
|
SChapter.create().apply {
|
||||||
|
url = "${manga.url}/${chapter.storyId}"
|
||||||
|
name = (chapter.title + chapter.subtitle?.let { " $it" }.orEmpty()).trim()
|
||||||
|
date_upload = chapter.contentsRelease
|
||||||
|
chapter_number = (chapters.size - index).toFloat()
|
||||||
|
val accessCondition = chapter.contentsAccessCondition
|
||||||
|
val isPremiumType = accessCondition.typename != "FreeStoryContentsAccessCondition"
|
||||||
|
val isPurchasable = chapter.isSellByStory && (accessCondition.info?.coins ?: 0) > 0
|
||||||
|
|
||||||
|
if ((isPremiumType || isPurchasable) && !chapter.isPurchased) {
|
||||||
|
name = "\uD83E\uDE99 $name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.reversed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
|
val (alias, storyId) = chapter.url.split("/")
|
||||||
|
val isWebSeries = alias in cacheWebOnlyAliases
|
||||||
|
val variables = StoryReaderVariables(magazineIdOrAlias = alias, storyId = storyId)
|
||||||
|
val apiRequest = graphQlRequest("magazineStoryForReader", variables, useAppHeaders = !isWebSeries)
|
||||||
|
|
||||||
|
return client.newCall(apiRequest).asObservableSuccess().map { response ->
|
||||||
|
val data = response.parseAs<GraphQLResponse<PageListDto>>().data
|
||||||
|
if (data.magazine.storyContents.error != null) {
|
||||||
|
throw Exception("This chapter is locked. Log in via WebView to read if you have premium or have purchased this chapter.")
|
||||||
|
}
|
||||||
|
val pageImages = data.magazine.storyContents.pageImages
|
||||||
|
?: throw Exception("Could not find page images")
|
||||||
|
|
||||||
|
val pages = (1..pageImages.pageCount).map { i ->
|
||||||
|
val imageUrl = "${pageImages.pageImageBaseURL}$i.jpg?${pageImages.pageImageSign}"
|
||||||
|
Page(i - 1, imageUrl = updateImageUrlWidth(imageUrl))
|
||||||
|
}.toMutableList()
|
||||||
|
|
||||||
|
data.magazine.storyContents.afterword?.imageURL?.let {
|
||||||
|
pages.add(Page(pages.size, imageUrl = updateImageUrlWidth(it)))
|
||||||
|
}
|
||||||
|
pages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter
|
||||||
|
override fun getFilterList(): FilterList {
|
||||||
|
val filters = mutableListOf<Filter<*>>(Filter.Header("NOTE: Search query ignores filters"))
|
||||||
|
if (operationsMap != null) {
|
||||||
|
filters.add(CategoryFilter(getCategoryList()))
|
||||||
|
} else {
|
||||||
|
filters.add(Filter.Header("Press 'Reset' to load filters"))
|
||||||
|
}
|
||||||
|
return FilterList(filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Category(val name: String, val id: String, val type: String) {
|
||||||
|
override fun toString(): String = name
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCategoryList() = listOf(
|
||||||
|
Category("人気", "popular", "popular"),
|
||||||
|
Category("完結", "finished", "finished"),
|
||||||
|
Category("月曜日", "MONDAY", "day"),
|
||||||
|
Category("火曜日", "TUESDAY", "day"),
|
||||||
|
Category("水曜日", "WEDNESDAY", "day"),
|
||||||
|
Category("木曜日", "THURSDAY", "day"),
|
||||||
|
Category("金曜日", "FRIDAY", "day"),
|
||||||
|
Category("土曜日", "SATURDAY", "day"),
|
||||||
|
Category("日曜日", "SUNDAY", "day"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private class CategoryFilter(categories: List<Category>) : Filter.Select<Category>("カテゴリー", categories.toTypedArray()) {
|
||||||
|
val selected: Category
|
||||||
|
get() = values[state]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsupported
|
||||||
|
override fun chapterListRequest(manga: SManga): Request = throw UnsupportedOperationException()
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> = throw UnsupportedOperationException()
|
||||||
|
override fun pageListParse(response: Response): List<Page> = throw UnsupportedOperationException()
|
||||||
|
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||||
}
|
}
|
||||||
|
@ -1,168 +1,247 @@
|
|||||||
package eu.kanade.tachiyomi.extension.ja.ganma
|
package eu.kanade.tachiyomi.extension.ja.ganma
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import kotlinx.serialization.SerialName
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.text.DateFormat.getDateTimeInstance
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Result<T>(val root: T)
|
class GraphQLResponse<T>(val data: T)
|
||||||
|
|
||||||
// Manga
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Magazine(
|
class Payload<T>(
|
||||||
val id: String,
|
val operationName: String,
|
||||||
val alias: String? = null,
|
val variables: T,
|
||||||
val title: String,
|
val extensions: Extensions,
|
||||||
val description: String? = null,
|
|
||||||
val squareImage: File? = null,
|
|
||||||
// val squareWithLogoImage: File? = null,
|
|
||||||
val author: Author? = null,
|
|
||||||
val newestStoryItem: Story? = null,
|
|
||||||
val flags: Flags? = null,
|
|
||||||
val announcement: Announcement? = null,
|
|
||||||
val items: List<Story> = emptyList(),
|
|
||||||
) {
|
) {
|
||||||
fun toSManga() = SManga.create().apply {
|
@Serializable
|
||||||
url = "${alias!!}#$id"
|
class Extensions(
|
||||||
title = this@Magazine.title
|
val persistedQuery: PersistedQuery,
|
||||||
thumbnail_url = squareImage!!.url
|
) {
|
||||||
}
|
@Serializable
|
||||||
|
class PersistedQuery(
|
||||||
fun toSMangaDetails() = toSManga().apply {
|
val version: Int,
|
||||||
author = this@Magazine.author?.penName
|
val sha256Hash: String,
|
||||||
val flagsText = flags?.toText()
|
)
|
||||||
description = generateDescription(flagsText)
|
|
||||||
status = when {
|
|
||||||
flags?.isFinish == true -> SManga.COMPLETED
|
|
||||||
!flagsText.isNullOrEmpty() -> SManga.ONGOING
|
|
||||||
else -> SManga.UNKNOWN
|
|
||||||
}
|
|
||||||
initialized = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generateDescription(flagsText: String?): String {
|
|
||||||
val result = mutableListOf<String>()
|
|
||||||
if (!flagsText.isNullOrEmpty()) result.add("Updates: $flagsText")
|
|
||||||
if (announcement != null) result.add("Announcement: ${announcement.text}")
|
|
||||||
if (description != null) result.add(description)
|
|
||||||
return result.joinToString("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSChapterList(): List<SChapter> {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
return items.map {
|
|
||||||
SChapter.create().apply {
|
|
||||||
url = "${alias!!}#$id/${it.id ?: it.storyId}"
|
|
||||||
name = buildString {
|
|
||||||
if (it.kind != "free") append("🔒 ")
|
|
||||||
append(it.title)
|
|
||||||
if (it.subtitle != null) append(' ').append(it.subtitle)
|
|
||||||
}
|
|
||||||
val time = it.releaseStart ?: -1
|
|
||||||
date_upload = time
|
|
||||||
if (time > now) scanlator = getDateTimeInstance().format(Date(time)) + '~'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun String.alias() = this.substringBefore('#')
|
|
||||||
fun String.mangaId() = this.substringAfter('#')
|
|
||||||
fun String.chapterDir(): Pair<String, String> =
|
|
||||||
with(this.substringAfter('#')) {
|
|
||||||
// this == [mangaId-UUID]/[chapterId-UUID]
|
|
||||||
Pair(substring(0, 36), substring(37, 37 + 36))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chapter
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Story(
|
object EmptyVariables
|
||||||
val id: String? = null,
|
|
||||||
val storyId: String? = null,
|
@Serializable
|
||||||
|
class SearchVariables(
|
||||||
|
val keyword: String,
|
||||||
|
val first: Int,
|
||||||
|
val after: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class DayOfWeekVariables(
|
||||||
|
val dayOfWeek: String,
|
||||||
|
val first: Int,
|
||||||
|
val after: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class FinishedVariables(
|
||||||
|
val first: Int,
|
||||||
|
val after: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MagazineDetailVariables(
|
||||||
|
val magazineIdOrAlias: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class StoryInfoListVariables(
|
||||||
|
val magazineIdOrAlias: String,
|
||||||
|
val first: Int,
|
||||||
|
val after: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class StoryReaderVariables(
|
||||||
|
val magazineIdOrAlias: String,
|
||||||
|
val storyId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MangaItemDto(
|
||||||
|
val alias: String,
|
||||||
|
val title: String,
|
||||||
|
val todaysJacketImageURL: String? = null,
|
||||||
|
val rectangleWithLogoImageURL: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class HomeDto(
|
||||||
|
val ranking: Ranking,
|
||||||
|
val latestTotalRanking10: List<MangaItemDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Ranking(
|
||||||
|
val totalRanking: List<MangaItemDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SearchEdge(
|
||||||
|
val node: MangaItemDto?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class StoryInfoMagazine(
|
||||||
|
val magazine: MangaItemDto,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class FinishedEdge(
|
||||||
|
val node: MangaItemDto,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SearchDto(
|
||||||
|
val searchComic: SearchResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SearchResult(
|
||||||
|
val edges: List<SearchEdge>,
|
||||||
|
val pageInfo: PageInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SerialResponseDto(
|
||||||
|
val serialPerDayOfWeek: SerialPanel,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SerialPanel(
|
||||||
|
val panels: SerialConnection,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SerialConnection(
|
||||||
|
val edges: List<SerialEdge>,
|
||||||
|
val pageInfo: PageInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SerialEdge(
|
||||||
|
val node: SerialNode,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SerialNode(
|
||||||
|
val storyInfo: StoryInfoMagazine,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class FinishedResponseDto(
|
||||||
|
val magazinesByCategory: FinishedCategory,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class FinishedCategory(
|
||||||
|
val magazines: FinishedConnection,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class FinishedConnection(
|
||||||
|
val edges: List<FinishedEdge>,
|
||||||
|
val pageInfo: PageInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MagazineDetailDto(
|
||||||
|
val magazine: MagazineDetail,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MagazineDetail(
|
||||||
|
val title: String,
|
||||||
|
val alias: String,
|
||||||
|
val authorName: String? = null,
|
||||||
|
val description: String,
|
||||||
|
val isFinished: Boolean,
|
||||||
|
val todaysJacketImageURL: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ChapterListDto(
|
||||||
|
val magazine: MagazineWithChapters,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MagazineWithChapters(
|
||||||
|
val storyInfos: StoryInfoConnection,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class StoryInfoConnection(
|
||||||
|
val edges: List<StoryInfoEdge>,
|
||||||
|
val pageInfo: PageInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class StoryInfoEdge(
|
||||||
|
val node: StoryInfoNode,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class StoryInfoNode(
|
||||||
|
val storyId: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val subtitle: String? = null,
|
val subtitle: String? = null,
|
||||||
val release: Long = 0,
|
val contentsRelease: Long,
|
||||||
val releaseStart: Long? = null,
|
val isPurchased: Boolean,
|
||||||
val page: Directory? = null,
|
val contentsAccessCondition: ContentsAccessCondition,
|
||||||
val afterwordImage: File? = null,
|
val isSellByStory: Boolean,
|
||||||
val kind: String? = null,
|
)
|
||||||
) {
|
|
||||||
fun toPageList(): List<Page> {
|
|
||||||
val result = page!!.toPageList()
|
|
||||||
if (afterwordImage != null) {
|
|
||||||
result.add(Page(result.size, imageUrl = afterwordImage.url))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class File(val url: String)
|
class ContentsAccessCondition(
|
||||||
|
@SerialName("__typename")
|
||||||
|
val typename: String,
|
||||||
|
val info: PurchaseInfo? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Author(val penName: String? = null)
|
class PurchaseInfo(
|
||||||
|
val coins: Int,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Top(val boxes: List<Box>)
|
class PageInfo(
|
||||||
|
val hasNextPage: Boolean,
|
||||||
|
val endCursor: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Box(val panels: List<Magazine>)
|
class PageListDto(
|
||||||
|
val magazine: MagazinePages,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Flags(
|
class MagazinePages(
|
||||||
val isMonday: Boolean = false,
|
val storyContents: StoryContents,
|
||||||
val isTuesday: Boolean = false,
|
)
|
||||||
val isWednesday: Boolean = false,
|
|
||||||
val isThursday: Boolean = false,
|
|
||||||
val isFriday: Boolean = false,
|
|
||||||
val isSaturday: Boolean = false,
|
|
||||||
val isSunday: Boolean = false,
|
|
||||||
|
|
||||||
val isWeekly: Boolean = false,
|
|
||||||
val isEveryOtherWeek: Boolean = false,
|
|
||||||
val isThreeConsecutiveWeeks: Boolean = false,
|
|
||||||
val isMonthly: Boolean = false,
|
|
||||||
|
|
||||||
val isFinish: Boolean = false,
|
|
||||||
// val isMGAward: Boolean = false,
|
|
||||||
// val isNew: Boolean = false,
|
|
||||||
) {
|
|
||||||
fun toText(): String {
|
|
||||||
val result = mutableListOf<String>()
|
|
||||||
val days = mutableListOf<String>()
|
|
||||||
arrayOf(isWeekly, isEveryOtherWeek, isThreeConsecutiveWeeks, isMonthly)
|
|
||||||
.forEachIndexed { i, value -> if (value) result.add(weekText[i]) }
|
|
||||||
arrayOf(isMonday, isTuesday, isWednesday, isThursday, isFriday, isSaturday, isSunday)
|
|
||||||
.forEachIndexed { i, value -> if (value) days.add(dayText[i] + "s") }
|
|
||||||
if (days.size == 7) {
|
|
||||||
result.add("every day")
|
|
||||||
} else if (days.size != 0) {
|
|
||||||
days[0] = "on " + days[0]
|
|
||||||
result += days
|
|
||||||
}
|
|
||||||
return result.joinToString(", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val weekText = arrayOf("every week", "every other week", "three weeks in a row", "every month")
|
|
||||||
private val dayText = arrayOf("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Announcement(val text: String)
|
class StoryContents(
|
||||||
|
val pageImages: PageImages? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val afterword: Afterword? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class Directory(
|
class PageImages(
|
||||||
val baseUrl: String,
|
val pageCount: Int,
|
||||||
val token: String,
|
val pageImageBaseURL: String,
|
||||||
val files: List<String>,
|
val pageImageSign: String,
|
||||||
) {
|
)
|
||||||
fun toPageList(): MutableList<Page> =
|
|
||||||
files.mapIndexedTo(ArrayList(files.size + 1)) { i, file ->
|
@Serializable
|
||||||
Page(i, imageUrl = "$baseUrl$file?$token")
|
class Afterword(
|
||||||
}
|
val imageURL: String? = null,
|
||||||
}
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user