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 {
|
||||
extName = 'GANMA!'
|
||||
extClass = '.Ganma'
|
||||
extVersionCode = 2
|
||||
extVersionCode = 3
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
@ -1,7 +1,7 @@
|
||||
package eu.kanade.tachiyomi.extension.ja.ganma
|
||||
|
||||
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.source.model.Filter
|
||||
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.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
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.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class Ganma : HttpSource() {
|
||||
override val name = "GANMA!"
|
||||
@ -23,91 +30,314 @@ class Ganma : HttpSource() {
|
||||
override val baseUrl = "https://ganma.jp"
|
||||
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) =
|
||||
when (page) {
|
||||
1 -> GET("$baseUrl/api/1.0/ranking", headers)
|
||||
else -> GET("$baseUrl/api/1.1/ranking?flag=Finish", headers)
|
||||
private val apiUrl = "$baseUrl/api/graphql"
|
||||
|
||||
private var operationsMap: Map<String, String>? = null
|
||||
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 {
|
||||
val list: List<Magazine> = response.parseAs()
|
||||
return MangasPage(list.map { it.toSManga() }, false)
|
||||
val data = response.parseAs<GraphQLResponse<HomeDto>>().data
|
||||
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 {
|
||||
val list = response.parseAs<Top>().boxes.flatMap { it.panels }
|
||||
.filter { it.newestStoryItem != null }
|
||||
.sortedByDescending { it.newestStoryItem!!.release }
|
||||
return MangasPage(list.map { it.toSManga() }, false)
|
||||
val data = response.parseAs<GraphQLResponse<HomeDto>>().data
|
||||
val mangas = data.latestTotalRanking10.map { it.toSManga() }
|
||||
return MangasPage(mangas, false)
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
val pageNumber = when (filters.size) {
|
||||
0 -> 1
|
||||
else -> (filters[0] as TypeFilter).state + 1
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
if (query.isNotBlank()) {
|
||||
if (page == 1) {
|
||||
lastSearchCursor = null
|
||||
}
|
||||
val variables = SearchVariables(keyword = query, first = 20, after = lastSearchCursor)
|
||||
return graphQlRequest("magazinesByKeywordSearch", variables, useAppHeaders = false)
|
||||
}
|
||||
return fetchPopularManga(pageNumber).map { mangasPage ->
|
||||
MangasPage(mangasPage.mangas.filter { it.title.contains(query) }, 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 =
|
||||
throw UnsupportedOperationException()
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val operationName = Buffer().use {
|
||||
response.request.body?.writeTo(it)
|
||||
it.readUtf8().parseAs<JsonObject>()["operationName"]?.jsonPrimitive?.content
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage =
|
||||
throw UnsupportedOperationException()
|
||||
if (operationName == "magazinesByKeywordSearch") {
|
||||
val data = response.parseAs<GraphQLResponse<SearchDto>>().data
|
||||
lastSearchCursor = data.searchComic.pageInfo.endCursor
|
||||
val mangas = data.searchComic.edges
|
||||
.mapNotNull { it.node }
|
||||
.map { it.toSManga() }
|
||||
return MangasPage(mangas, data.searchComic.pageInfo.hasNextPage)
|
||||
}
|
||||
|
||||
// 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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
final override fun pageListParse(response: Response): List<Page> =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun imageUrlParse(response: Response): String =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
protected open class TypeFilter : Filter.Select<String>("Type", arrayOf("Popular", "Completed"))
|
||||
|
||||
override fun getFilterList() = FilterList(TypeFilter())
|
||||
|
||||
protected inline fun <reified T> Response.parseAs(): T = use {
|
||||
json.decodeFromStream<Result<T>>(it.body.byteStream()).root
|
||||
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)
|
||||
}
|
||||
|
||||
val json: Json by injectLazy()
|
||||
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
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.text.DateFormat.getDateTimeInstance
|
||||
import java.util.Date
|
||||
|
||||
@Serializable
|
||||
class Result<T>(val root: T)
|
||||
class GraphQLResponse<T>(val data: T)
|
||||
|
||||
// Manga
|
||||
@Serializable
|
||||
class Magazine(
|
||||
val id: String,
|
||||
val alias: String? = null,
|
||||
val title: String,
|
||||
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(),
|
||||
class Payload<T>(
|
||||
val operationName: String,
|
||||
val variables: T,
|
||||
val extensions: Extensions,
|
||||
) {
|
||||
fun toSManga() = SManga.create().apply {
|
||||
url = "${alias!!}#$id"
|
||||
title = this@Magazine.title
|
||||
thumbnail_url = squareImage!!.url
|
||||
}
|
||||
|
||||
fun toSMangaDetails() = toSManga().apply {
|
||||
author = this@Magazine.author?.penName
|
||||
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)) + '~'
|
||||
}
|
||||
}
|
||||
@Serializable
|
||||
class Extensions(
|
||||
val persistedQuery: PersistedQuery,
|
||||
) {
|
||||
@Serializable
|
||||
class PersistedQuery(
|
||||
val version: Int,
|
||||
val sha256Hash: String,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
class Story(
|
||||
val id: String? = null,
|
||||
val storyId: String? = null,
|
||||
object EmptyVariables
|
||||
|
||||
@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 subtitle: String? = null,
|
||||
val release: Long = 0,
|
||||
val releaseStart: Long? = null,
|
||||
val page: Directory? = null,
|
||||
val afterwordImage: File? = null,
|
||||
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
|
||||
}
|
||||
}
|
||||
val contentsRelease: Long,
|
||||
val isPurchased: Boolean,
|
||||
val contentsAccessCondition: ContentsAccessCondition,
|
||||
val isSellByStory: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class File(val url: String)
|
||||
class ContentsAccessCondition(
|
||||
@SerialName("__typename")
|
||||
val typename: String,
|
||||
val info: PurchaseInfo? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Author(val penName: String? = null)
|
||||
class PurchaseInfo(
|
||||
val coins: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Top(val boxes: List<Box>)
|
||||
class PageInfo(
|
||||
val hasNextPage: Boolean,
|
||||
val endCursor: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Box(val panels: List<Magazine>)
|
||||
class PageListDto(
|
||||
val magazine: MagazinePages,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Flags(
|
||||
val isMonday: Boolean = false,
|
||||
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")
|
||||
}
|
||||
}
|
||||
class MagazinePages(
|
||||
val storyContents: StoryContents,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Announcement(val text: String)
|
||||
class StoryContents(
|
||||
val pageImages: PageImages? = null,
|
||||
val error: String? = null,
|
||||
val afterword: Afterword? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Directory(
|
||||
val baseUrl: String,
|
||||
val token: String,
|
||||
val files: List<String>,
|
||||
) {
|
||||
fun toPageList(): MutableList<Page> =
|
||||
files.mapIndexedTo(ArrayList(files.size + 1)) { i, file ->
|
||||
Page(i, imageUrl = "$baseUrl$file?$token")
|
||||
}
|
||||
}
|
||||
class PageImages(
|
||||
val pageCount: Int,
|
||||
val pageImageBaseURL: String,
|
||||
val pageImageSign: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Afterword(
|
||||
val imageURL: String? = null,
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user