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:
manti 2025-09-27 14:22:51 +02:00 committed by Draff
parent 33f4d5f8c0
commit 7d62c04507
Signed by: Draff
GPG Key ID: E8A89F3211677653
3 changed files with 521 additions and 212 deletions

View File

@ -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"

View File

@ -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()
} }

View File

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