Update Komiic (#10376)

- Refresh token automatically
- Refactor requests
- Fetch genres from website
- Tweak chapter list
This commit is contained in:
stevenyomi 2025-09-04 08:34:36 +00:00 committed by Draff
parent 3e65d19929
commit df9da07535
Signed by: Draff
GPG Key ID: E8A89F3211677653
8 changed files with 345 additions and 333 deletions

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'Komiic' extName = 'Komiic'
extClass = '.Komiic' extClass = '.Komiic'
extVersionCode = 3 extVersionCode = 4
isNsfw = true isNsfw = true
} }

View File

@ -3,73 +3,86 @@ package eu.kanade.tachiyomi.extension.zh.komiic
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 kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
@Serializable @Serializable
class Data<T>(val data: Result<T>) class ResponseDto(private val data: DataDto?, private val errors: List<ErrorDto>?) {
fun getData() = data ?: throw Exception(errors!!.joinToString("\n") { it.message })
}
@Serializable @Serializable
class MultiData<T, V>(val data: MultiResult<T, V>) class ErrorDto(val message: String)
@Serializable @Serializable
class Result<T>(val result: T) class DataDto(
private val comics: List<MangaDto>?,
@Serializable val allCategory: List<ItemDto>?,
class MultiResult<T, V>(val result1: T, val result2: V) private val searchComicsAndAuthors: DataDto?,
val comicById: MangaDto?,
@Serializable val chaptersByComicId: List<ChapterDto>?,
data class Item(val id: String, val name: String) val reachedImageLimit: Boolean?,
val imagesByChapterId: List<PageDto>?,
@Serializable
data class Comic(
val id: String,
val title: String,
val description: String,
val status: String,
val imageUrl: String,
var authors: List<Item>,
val categories: List<Item>,
) { ) {
fun getListing(): List<MangaDto> = comics ?: searchComicsAndAuthors!!.comics!!
}
@Serializable
class JwtPayload(val exp: Long)
@Serializable
class ItemDto(val id: String, val name: String)
@Serializable
class MangaDto(
private val id: String,
private val title: String,
private val description: String,
private val status: String,
private val imageUrl: String,
private val authors: List<ItemDto>,
private val categories: List<ItemDto>,
) {
val url get() = "/comic/$id"
fun toSManga() = SManga.create().apply { fun toSManga() = SManga.create().apply {
url = "/comic/$id" url = this@MangaDto.url
title = this@Comic.title title = this@MangaDto.title
thumbnail_url = this@Comic.imageUrl thumbnail_url = this@MangaDto.imageUrl
author = this@Comic.authors.joinToString { it.name } author = this@MangaDto.authors.joinToString { it.name }
genre = this@Comic.categories.joinToString { it.name } genre = this@MangaDto.categories.joinToString { it.name }
description = this@Comic.description description = this@MangaDto.description
status = when (this@Comic.status) { status = when (this@MangaDto.status) {
"ONGOING" -> SManga.ONGOING "ONGOING" -> SManga.ONGOING
"END" -> SManga.COMPLETED "END" -> SManga.COMPLETED
else -> SManga.UNKNOWN else -> SManga.UNKNOWN
} }
initialized = this@Comic.description.isNotEmpty() initialized = this@MangaDto.description.isNotEmpty()
} }
} }
@Serializable @Serializable
data class Chapter( class ChapterDto(
val id: String, private val id: String,
val serial: String, val serial: String,
val type: String, val type: String,
val size: Int, private val size: Int,
val dateCreated: String, private val dateCreated: String,
) { ) {
fun toSChapter(comicUrl: String, parseDate: (String) -> Long) = SChapter.create().apply { fun toSChapter(mangaUrl: String, dateFormat: SimpleDateFormat) = SChapter.create().apply {
url = "$comicUrl/chapter/${this@Chapter.id}" val (suffix, typeName) = when (val type = this@ChapterDto.type) {
name = when (this@Chapter.type) { "chapter" -> Pair("", "連載")
"chapter" -> "${this@Chapter.serial}" "book" -> Pair("", "單行本")
"book" -> "${this@Chapter.serial}" else -> throw Exception("未知章節類型:$type")
else -> this@Chapter.serial
} }
scanlator = "${this@Chapter.size}P" url = "$mangaUrl/chapter/${this@ChapterDto.id}"
date_upload = parseDate(this@Chapter.dateCreated) name = "${this@ChapterDto.serial}$suffix${this@ChapterDto.size}P"
chapter_number = if (this@Chapter.type == "book") 0F else this@Chapter.serial.toFloatOrNull() ?: -1f scanlator = typeName
date_upload = dateFormat.parse(this@ChapterDto.dateCreated)!!.time
chapter_number = this@ChapterDto.serial.toFloatOrNull() ?: -1f
} }
} }
@Serializable @Serializable
data class Image( class PageDto(
val id: String,
val kid: String, val kid: String,
val height: Int,
val width: Int,
) )

View File

@ -3,43 +3,50 @@ package eu.kanade.tachiyomi.extension.zh.komiic
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
var categories: List<ItemDto> = emptyList()
fun buildFilterList(): FilterList { fun buildFilterList(): FilterList {
val categories = mapOf( val categoryFilter = if (categories.isNotEmpty()) {
"1" to "愛情", "3" to "神鬼", "4" to "校園", "5" to "搞笑", "6" to "生活", CategoryFilter()
"7" to "懸疑", "8" to "冒險", "10" to "職場", "11" to "魔幻", "2" to "後宮", } else {
"12" to "魔法", "13" to "格鬥", "14" to "宅男", "15" to "勵志", "16" to "耽美", Filter.Header("點擊“重設”載入類型")
"17" to "科幻", "18" to "百合", "19" to "治癒", "20" to "萌系", "21" to "熱血", }
"22" to "競技", "23" to "推理", "24" to "雜誌", "25" to "偵探", "26" to "偽娘",
"27" to "美食", "9" to "恐怖", "28" to "四格", "31" to "社會", "32" to "歷史",
"33" to "戰爭", "34" to "舞蹈", "35" to "武俠", "36" to "機戰", "37" to "音樂",
"40" to "體育", "42" to "黑道", "46" to "腐女", "47" to "異世界", "48" to "驚悚",
"51" to "成人", "54" to "戰鬥", "55" to "復仇", "56" to "轉生", "57" to "黑暗奇幻",
"58" to "戲劇", "59" to "生存", "60" to "策略", "61" to "政治", "62" to "黑暗",
"64" to "動作", "70" to "性轉換", "73" to "色情", "181" to "校园", "78" to "日常",
"81" to "青春", "83" to "料理", "85" to "醫療", "86" to "致鬱", "87" to "心理",
"88" to "穿越", "92" to "友情", "93" to "犯罪", "97" to "劇情",
"110" to "運動", "113" to "少女", "114" to "賭博", "119" to "情色", "123" to "女性向",
"128" to "性轉", "129" to "溫馨", "164" to "同人",
)
return FilterList( return FilterList(
Filter.Header("過濾條件(搜索關鍵字時無效)"), Filter.Header("篩選條件(搜索關鍵字時無效)"),
CategoryFilter(categories), categoryFilter,
StatusFilter(),
SortFilter(), SortFilter(),
StatusFilter(),
RatingFilter(),
) )
} }
interface KomiicFilter {
fun apply(variables: ListingVariables)
}
class Category(val id: String, name: String) : Filter.CheckBox(name) class Category(val id: String, name: String) : Filter.CheckBox(name)
class CategoryFilter(categories: Map<String, String>) : class CategoryFilter :
Filter.Group<Category>("類型(篩選同時包含全部所選標簽的漫畫)", categories.map { Category(it.key, it.value) }) { Filter.Group<Category>("類型(篩選同時包含全部所選標簽的漫畫)", categories.map { Category(it.id, it.name) }), KomiicFilter {
val selected get() = state.filter(Category::state).map(Category::id) override fun apply(variables: ListingVariables) {
variables.categoryId = state.mapNotNull { if (it.state) it.id else null }
}
} }
class StatusFilter : Filter.Select<String>("狀態", arrayOf("全部", "連載", "完結")) { class StatusFilter : Filter.Select<String>("狀態", arrayOf("全部", "連載", "完結")), KomiicFilter {
val value get() = arrayOf("", "ONGOING", "END")[state] override fun apply(variables: ListingVariables) {
variables.pagination.status = arrayOf("", "ONGOING", "END")[state]
}
} }
class SortFilter : Filter.Select<String>("排序", arrayOf("更新", "觀看數", "喜愛數")) { class SortFilter : Filter.Select<String>("排序", arrayOf("更新", "本月觀看數(不能篩選類型)", "觀看數", "喜愛數")), KomiicFilter {
val value get() = arrayOf("DATE_UPDATED", "VIEWS", "FAVORITE_COUNT")[state] override fun apply(variables: ListingVariables) {
variables.pagination.orderBy = arrayOf(OrderBy.DATE_UPDATED, OrderBy.MONTH_VIEWS, OrderBy.VIEWS, OrderBy.FAVORITE_COUNT)[state]
}
}
class RatingFilter : Filter.Select<String>("色氣程度", arrayOf("全部", "", "1", "2", "3", "≥4", "5")), KomiicFilter {
override fun apply(variables: ListingVariables) {
variables.pagination.sexyLevel = arrayOf(null, 0, 1, 2, 3, 4, 5)[state]
}
} }

View File

@ -1,35 +1,48 @@
package eu.kanade.tachiyomi.extension.zh.komiic package eu.kanade.tachiyomi.extension.zh.komiic
import android.util.Base64
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
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.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.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import keiyoushi.utils.firstInstance
import keiyoushi.utils.getPreferencesLazy import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonString import okhttp3.Interceptor
import keiyoushi.utils.tryParse
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody
import okhttp3.Response import okhttp3.Response
import rx.Observable import java.io.IOException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
class Komiic : HttpSource(), ConfigurableSource { class Komiic : HttpSource(), ConfigurableSource {
override var name = "Komiic" override val name = "Komiic"
override val baseUrl = "https://komiic.com" override val baseUrl = "https://komiic.com"
override val lang = "zh" override val lang = "zh"
override val supportsLatest = true override val supportsLatest = true
override val client = network.cloudflareClient override val client = network.cloudflareClient.newBuilder()
.addInterceptor { chain ->
refreshToken(chain)
chain.proceed(chain.request())
}
.build()
private fun refreshToken(chain: Interceptor.Chain) {
val url = chain.request().url
if (url.pathSegments[0] != "api") return
val cookie = client.cookieJar.loadForRequest(url).find { it.name == "komiic-access-token" } ?: return
val parts = cookie.value.split(".")
if (parts.size != 3) throw IOException("Token 格式無效")
val payload = Base64.decode(parts[1], Base64.DEFAULT).decodeToString()
if (System.currentTimeMillis() + 3600_000 < payload.parseAs<JwtPayload>().exp * 1000) return
val response = chain.proceed(POST("$baseUrl/auth/refresh", headers)).apply { close() }
if (!response.isSuccessful) throw IOException("刷新 Token 失敗HTTP ${response.code}")
}
private val apiUrl = "$baseUrl/api/query" private val apiUrl = "$baseUrl/api/query"
private val preferences by getPreferencesLazy() private val preferences by getPreferencesLazy()
@ -40,168 +53,98 @@ class Komiic : HttpSource(), ConfigurableSource {
// Customize =================================================================================== // Customize ===================================================================================
companion object {
const val PAGE_SIZE = 20
const val PREFIX_ID_SEARCH = "id:"
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
}
private val SManga.id get() = url.substringAfterLast("/") private val SManga.id get() = url.substringAfterLast("/")
private val SChapter.id get() = url.substringAfterLast("/") private val SChapter.id get() = url.substringAfterLast("/")
private inline fun <reified T> Payload<T>.toRequestBody() = this.toJsonString().toRequestBody(JSON_MEDIA_TYPE)
/** private fun RequestBody.request() = POST(apiUrl, headers, this)
* 根據 ID 搜索漫畫 private fun Response.parse() = parseAs<ResponseDto>().getData()
* Search the comic based on the ID.
*/
private fun comicByIDRequest(id: String): Request {
val variables = Variables().field("comicId", id).build()
val payload = Payload(Query.COMIC_BY_ID, variables)
return POST(apiUrl, headers, payload.toRequestBody())
}
/**
* 根據 ID 解析搜索來的漫畫
* Parse the comic based on the ID.
*/
private fun parseComicByID(response: Response): MangasPage {
val res = response.parseAs<Data<Comic>>()
val entries = listOf(res.data.result.toSManga())
return MangasPage(entries, false)
}
/**
* 檢查 API 是否達到上限
* Check if the API has reached its limit.
* But how to throw an exception message to notify user in reading page?
*/
// private fun checkAPILimit(): Observable<Boolean> {
// val payload = Payload("reachedImageLimit", Variables().build(), QUERY_API_LIMIT)
// val response = client.newCall(POST(queryAPIUrl, headers, payload.toRequestBody()))
// val limit = response.asObservableSuccess().map { it.parseAs<Data<Boolean>>().data.result }
// return limit
// }
// Popular Manga =============================================================================== // Popular Manga ===============================================================================
override fun popularMangaRequest(page: Int): Request { override fun popularMangaRequest(page: Int): Request {
val pagination = Pagination((page - 1) * PAGE_SIZE, "MONTH_VIEWS") val pagination = Pagination((page - 1) * PAGE_SIZE, OrderBy.MONTH_VIEWS)
val variables = Variables().field("pagination", pagination).build() return listingQuery(ListingVariables(pagination)).request()
val payload = Payload(Query.HOT_COMICS, variables)
return POST(apiUrl, headers, payload.toRequestBody())
} }
override fun popularMangaParse(response: Response): MangasPage { override fun popularMangaParse(response: Response) = parseListing(response.parse())
val res = response.parseAs<Data<List<Comic>>>()
val comics = res.data.result
return MangasPage(comics.map(Comic::toSManga), comics.size == PAGE_SIZE)
}
// Latest Updates ============================================================================== // Latest Updates ==============================================================================
override fun latestUpdatesRequest(page: Int): Request { override fun latestUpdatesRequest(page: Int): Request {
val pagination = Pagination((page - 1) * PAGE_SIZE, "DATE_UPDATED") val pagination = Pagination((page - 1) * PAGE_SIZE, OrderBy.DATE_UPDATED)
val variables = Variables().field("pagination", pagination).build() return listingQuery(ListingVariables(pagination)).request()
val payload = Payload(Query.RECENT_UPDATE, variables)
return POST(apiUrl, headers, payload.toRequestBody())
} }
override fun latestUpdatesParse(response: Response) = popularMangaParse(response) override fun latestUpdatesParse(response: Response) = parseListing(response.parse())
// Search Manga ================================================================================ // Search Manga ================================================================================
override fun getFilterList() = buildFilterList() override fun getFilterList() = buildFilterList()
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (query.isNotBlank()) { return if (query.startsWith(PREFIX_ID_SEARCH)) {
val variables = Variables().field("keyword", query).build() idsQuery(query.removePrefix(PREFIX_ID_SEARCH)).request()
val payload = Payload(Query.SEARCH, variables)
return POST(apiUrl, headers, payload.toRequestBody())
} else {
val categories = filters.firstInstance<CategoryFilter>()
val status = filters.firstInstance<StatusFilter>()
val sort = filters.firstInstance<SortFilter>()
val variables = Variables().field(
"pagination",
Pagination((page - 1) * PAGE_SIZE, sort.value, status.value, false),
).field("categoryId", categories.selected).build()
val payload = Payload(Query.COMIC_BY_CATEGORIES, variables)
return POST(apiUrl, headers, payload.toRequestBody())
}
}
override fun searchMangaParse(response: Response): MangasPage {
val res = response.parseAs<Data<Result<List<Comic>>>>()
val comics = res.data.result.result
return MangasPage(comics.map(Comic::toSManga), comics.size == PAGE_SIZE)
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
if (query.startsWith(PREFIX_ID_SEARCH)) {
return client.newCall(comicByIDRequest(query.substringAfter(PREFIX_ID_SEARCH)))
.asObservableSuccess().map(::parseComicByID)
} else if (query.isNotBlank()) { } else if (query.isNotBlank()) {
return super.fetchSearchManga(page, query, filters) searchQuery(query).request()
} else {
val variables = ListingVariables(Pagination((page - 1) * PAGE_SIZE))
for (filter in filters) if (filter is KomiicFilter) filter.apply(variables)
listingQuery(variables).request()
} }
return client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess().map(::popularMangaParse)
} }
override fun searchMangaParse(response: Response) = parseListing(response.parse())
// Manga Details =============================================================================== // Manga Details ===============================================================================
override fun getMangaUrl(manga: SManga) = baseUrl + manga.url override fun getMangaUrl(manga: SManga) = baseUrl + manga.url
override fun mangaDetailsRequest(manga: SManga) = comicByIDRequest(manga.id) override fun mangaDetailsRequest(manga: SManga) = mangaQuery(manga.id).request()
override fun mangaDetailsParse(response: Response): SManga { override fun mangaDetailsParse(response: Response) = response.parse().comicById!!.toSManga()
val res = response.parseAs<Data<Comic>>()
return res.data.result.toSManga()
}
// Chapter List ================================================================================ // Chapter List ================================================================================
override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url + "/images/all" override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url + "/images/all"
override fun chapterListRequest(manga: SManga): Request { override fun chapterListRequest(manga: SManga) = mangaQuery(manga.id).request()
val variables = Variables().field("comicId", manga.id).build()
val payload = Payload(Query.CHAPTERS_BY_COMIC_ID, variables)
return POST("$apiUrl#${manga.url}", headers, payload.toRequestBody())
}
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val res = response.parseAs<Data<List<Chapter>>>() val data = response.parse()
val comics = res.data.result.sortedWith( val chapters = data.chaptersByComicId!!.toMutableList()
compareByDescending<Chapter> { it.type } when (preferences.getString(CHAPTER_FILTER_PREF, "all")!!) {
"chapter" -> chapters.retainAll { it.type == "chapter" }
"book" -> chapters.retainAll { it.type == "book" }
else -> {}
}
chapters.sortWith(
compareByDescending<ChapterDto> { it.type }
.thenByDescending { it.serial.toFloatOrNull() }, .thenByDescending { it.serial.toFloatOrNull() },
) )
val display = preferences.getString(CHAPTER_FILTER_PREF, "all") val mangaUrl = data.comicById!!.url
val items = when (display) { val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
"chapter" -> comics.filter { it.type == "chapter" } timeZone = TimeZone.getTimeZone("UTC")
"book" -> comics.filter { it.type == "book" }
else -> comics
} }
val comicUrl = response.request.url.fragment!! return chapters.map { it.toSChapter(mangaUrl, dateFormat) }
return items.map { it.toSChapter(comicUrl, DATE_FORMAT::tryParse) }
} }
// Page List =================================================================================== // Page List ===================================================================================
override fun pageListRequest(chapter: SChapter): Request { override fun pageListRequest(chapter: SChapter): Request {
val variables = Variables().field("chapterId", chapter.id).build() return pageListQuery(chapter.id).request().newBuilder()
val payload = Payload(Query.IMAGES_BY_CHAPTER_ID, variables) .tag(String::class.java, chapter.url)
return POST("$apiUrl#${chapter.url}", headers, payload.toRequestBody()) .build()
} }
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
val res = response.parseAs<MultiData<Boolean, List<Image>>>() val data = response.parse()
val check = preferences.getBoolean(CHECK_API_LIMIT_PREF, true) val check = preferences.getBoolean(CHECK_API_LIMIT_PREF, true)
check(!check || !res.data.result1) { "今日圖片讀取次數已達上限,請登录或明天再來!" } if (check && data.reachedImageLimit!!) {
val chapterUrl = response.request.url.fragment!! throw Exception("今日圖片讀取次數已達上限,請登录或明天再來!")
return res.data.result2.mapIndexed { index, image -> }
Page(index, "$chapterUrl/page/$index", "$baseUrl/api/image/${image.kid}") val chapterUrl = response.request.tag(String::class.java)!!
return data.imagesByChapterId!!.mapIndexed { index, image ->
Page(index, "$chapterUrl/page/${index + 1}", "$baseUrl/api/image/${image.kid}")
} }
} }
@ -209,7 +152,7 @@ class Komiic : HttpSource(), ConfigurableSource {
override fun imageRequest(page: Page): Request { override fun imageRequest(page: Page): Request {
return super.imageRequest(page).newBuilder() return super.imageRequest(page).newBuilder()
.addHeader("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8'") .addHeader("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
.addHeader("referer", page.url) .addHeader("referer", page.url)
.build() .build()
} }

View File

@ -3,38 +3,48 @@ package eu.kanade.tachiyomi.extension.zh.komiic
import kotlinx.serialization.EncodeDefault import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
const val PAGE_SIZE = 30 // using 20 causes weird behavior in the filter endpoint
@Serializable @Serializable
data class Payload<T>( class ListingVariables(
val operationName: String, val pagination: Pagination,
val variables: T,
val query: String,
) { ) {
constructor(query: Query, variables: T) : this(query.operation, variables, query.body) @EncodeDefault
var categoryId: List<String> = emptyList()
fun encode() = Json.encodeToJsonElement(this) as JsonObject
} }
@Suppress("unused")
@Serializable @Serializable
data class Pagination( class Pagination(
val offset: Int, private val offset: Int,
val orderBy: String,
@EncodeDefault @EncodeDefault
val status: String = "", var orderBy: OrderBy = OrderBy.DATE_UPDATED,
) {
@EncodeDefault @EncodeDefault
val asc: Boolean = true, var status: String = ""
@EncodeDefault
val limit: Int = Komiic.PAGE_SIZE,
)
class Variables { @EncodeDefault
val variableMap = mutableMapOf<String, JsonElement>() private val asc: Boolean = false // this should be true in popular but doesn't take effect in any case
inline fun <reified T> field(key: String, value: T): Variables { @EncodeDefault
variableMap[key] = Json.encodeToJsonElement(value) private val limit: Int = PAGE_SIZE
return this
@EncodeDefault
var sexyLevel: Int? = null
} }
fun build() = JsonObject(variableMap) enum class OrderBy {
DATE_UPDATED,
DATE_CREATED,
VIEWS,
MONTH_VIEWS,
ID,
COMIC_DATE_UPDATED,
FAVORITE_ADDED,
FAVORITE_COUNT,
} }

View File

@ -0,0 +1,152 @@
package eu.kanade.tachiyomi.extension.zh.komiic
import eu.kanade.tachiyomi.source.model.MangasPage
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
private fun buildQuery(query: String): String {
return query.trimIndent()
.replace("#{body}", COMIC_BODY.trimIndent())
.replace("%", "$")
}
private const val COMIC_BODY =
"""
{
id
title
description
status
imageUrl
authors {
id
name
}
categories {
id
name
}
}
"""
private fun buildRequestBody(query: String, variables: JsonObject): RequestBody {
val body = buildJsonObject {
put("query", query)
put("variables", variables)
}
val contentType = "application/json".toMediaType()
return Json.encodeToString(body).toByteArray().toRequestBody(contentType)
}
fun parseListing(data: DataDto): MangasPage {
data.allCategory?.let { categories = it }
val listing = data.getListing()
val entries = listing.map { it.toSManga() }
val hasNextPage = listing.size == PAGE_SIZE
return MangasPage(entries, hasNextPage)
}
fun listingQuery(variables: ListingVariables): RequestBody {
if (variables.pagination.orderBy == OrderBy.MONTH_VIEWS) return popularQuery(variables)
val query = buildQuery(
"""
query comicByCategories(%categoryId: [ID!]!, %pagination: Pagination!) {
comics: comicByCategories(categoryId: %categoryId, pagination: %pagination) #{body}
allCategory { id name }
}
""",
)
return buildRequestBody(query, variables.encode())
}
private fun popularQuery(variables: ListingVariables): RequestBody {
if (variables.categoryId.isNotEmpty()) throw Exception("“本月最夯”不能篩選類型")
val query = buildQuery(
"""
query hotComics(%pagination: Pagination!) {
comics: hotComics(pagination: %pagination) #{body}
allCategory { id name }
}
""",
)
return buildRequestBody(query, variables.encode())
}
fun searchQuery(keyword: String): RequestBody {
val query = buildQuery(
"""
query searchComicAndAuthorQuery(%keyword: String!) {
searchComicsAndAuthors(keyword: %keyword) {
comics #{body}
}
allCategory { id name }
}
""",
)
val variables = buildJsonObject {
put("keyword", keyword)
}
return buildRequestBody(query, variables)
}
fun idsQuery(id: String): RequestBody {
val query = buildQuery(
"""
query comicByIds(%comicIds: [ID]!) {
comics: comicByIds(comicIds: %comicIds) #{body}
}
""",
)
val variables = buildJsonObject {
putJsonArray("comicIds") {
add(id)
}
}
return buildRequestBody(query, variables)
}
fun mangaQuery(id: String): RequestBody {
val query = buildQuery(
"""
query chapterByComicId(%comicId: ID!) {
comicById(comicId: %comicId) #{body}
chaptersByComicId(comicId: %comicId) {
id
serial
type
size
dateCreated
}
}
""",
)
val variables = buildJsonObject {
put("comicId", id)
}
return buildRequestBody(query, variables)
}
fun pageListQuery(chapterId: String): RequestBody {
val query = buildQuery(
"""
query imagesByChapterId(%chapterId: ID!) {
reachedImageLimit
imagesByChapterId(chapterId: %chapterId) {
kid
}
}
""",
)
val variables = buildJsonObject {
put("chapterId", chapterId)
}
return buildRequestBody(query, variables)
}

View File

@ -1,115 +0,0 @@
package eu.kanade.tachiyomi.extension.zh.komiic
enum class Query {
HOT_COMICS {
override val operation = "hotComics"
override val body = buildQuery(comicBody) {
"""
query hotComics(%pagination: Pagination!) {
result: hotComics(pagination: %pagination) #{body}
}
"""
}
},
RECENT_UPDATE {
override val operation = "recentUpdate"
override val body = buildQuery(comicBody) {
"""
query recentUpdate(%pagination: Pagination!) {
result: recentUpdate(pagination: %pagination) #{body}
}
"""
}
},
SEARCH {
override val operation = "searchComicAndAuthorQuery"
override val body = buildQuery(comicBody) {
"""
query searchComicAndAuthorQuery(%keyword: String!) {
result: searchComicsAndAuthors(keyword: %keyword) {
result: comics #{body}
}
}
"""
}
},
COMIC_BY_CATEGORIES {
override val operation = "comicByCategories"
override val body = buildQuery(comicBody) {
"""
query comicByCategories(%categoryId: [ID!]!, %pagination: Pagination!) {
result: comicByCategories(categoryId: %categoryId, pagination: %pagination) #{body}
}
"""
}
},
COMIC_BY_ID {
override val operation = "comicById"
override val body = buildQuery(comicBody) {
"""
query comicById(%comicId: ID!) {
result: comicById(comicId: %comicId) #{body}
}
"""
}
},
CHAPTERS_BY_COMIC_ID {
override val operation = "chapterByComicId"
override val body = buildQuery {
"""
query chapterByComicId(%comicId: ID!) {
result: chaptersByComicId(comicId: %comicId) {
id
serial
type
size
dateCreated
}
}
"""
}
},
IMAGES_BY_CHAPTER_ID {
override val operation = "imagesByChapterId"
override val body = buildQuery {
"""
query imagesByChapterId(%chapterId: ID!) {
result1: reachedImageLimit,
result2: imagesByChapterId(chapterId: %chapterId) {
id
kid
height
width
}
}
"""
}
}, ;
abstract val body: String
abstract val operation: String
val comicBody =
"""
{
id
title
description
status
imageUrl
authors {
id
name
}
categories {
id
name
}
}
"""
fun buildQuery(body: String = "", queryAction: () -> String): String {
return queryAction().trimIndent()
.replace("#{body}", body.trimIndent())
.replace("%", "$")
}
}

View File

@ -7,6 +7,8 @@ import android.os.Bundle
import android.util.Log import android.util.Log
import kotlin.system.exitProcess import kotlin.system.exitProcess
const val PREFIX_ID_SEARCH = "id:"
class UrlActivity : Activity() { class UrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -15,7 +17,7 @@ class UrlActivity : Activity() {
val id = pathSegments[1] val id = pathSegments[1]
val mainIntent = Intent().apply { val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH" action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${Komiic.PREFIX_ID_SEARCH}$id") putExtra("query", "$PREFIX_ID_SEARCH$id")
putExtra("filter", packageName) putExtra("filter", packageName)
} }