Komiic: Add search filter (#9531)

* add comic description

* fix manga search results missing descriptions

* clean unused variables

* clean unused class

* Add some config options and refactor some code

* refactor some code

* modify config option summary

* apply comments

* modify Queries.kt

* small modification

* Format code

* Format code

* replace parse method

* optimize check API limit

* modify config summary

* add search filter

* add getChapterUrl()

* refactor Query.kt

* use filters.firstInstance()

* nothing

* Replace require() with check()
This commit is contained in:
Hualiang 2025-07-08 00:27:34 +08:00 committed by Draff
parent 5ec031e3b8
commit 3b64f94ff8
Signed by: Draff
GPG Key ID: E8A89F3211677653
8 changed files with 214 additions and 130 deletions

View File

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

View File

@ -17,7 +17,7 @@ class Result<T>(val result: T)
class MultiResult<T, V>(val result1: T, val result2: V) class MultiResult<T, V>(val result1: T, val result2: V)
@Serializable @Serializable
data class ComicItem(val id: String, val name: String) data class Item(val id: String, val name: String)
@Serializable @Serializable
data class Comic( data class Comic(
@ -26,8 +26,8 @@ data class Comic(
val description: String, val description: String,
val status: String, val status: String,
val imageUrl: String, val imageUrl: String,
var authors: List<ComicItem>, var authors: List<Item>,
val categories: List<ComicItem>, val categories: List<Item>,
) { ) {
fun toSManga() = SManga.create().apply { fun toSManga() = SManga.create().apply {
url = "/comic/$id" url = "/comic/$id"

View File

@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.extension.zh.komiic
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
fun buildFilterList(): FilterList {
val categories = mapOf(
"1" to "愛情", "3" to "神鬼", "4" to "校園", "5" to "搞笑", "6" to "生活",
"7" to "懸疑", "8" to "冒險", "10" to "職場", "11" to "魔幻", "2" to "後宮",
"12" to "魔法", "13" to "格鬥", "14" to "宅男", "15" to "勵志", "16" to "耽美",
"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(
Filter.Header("過濾條件(搜索關鍵字時無效)"),
CategoryFilter(categories),
StatusFilter(),
SortFilter(),
)
}
class Category(val id: String, name: String) : Filter.CheckBox(name)
class CategoryFilter(categories: Map<String, String>) :
Filter.Group<Category>("類型(篩選同時包含全部所選標簽的漫畫)", categories.map { Category(it.key, it.value) }) {
val selected get() = state.filter(Category::state).map(Category::id)
}
class StatusFilter : Filter.Select<String>("狀態", arrayOf("全部", "連載", "完結")) {
val value get() = arrayOf("", "ONGOING", "END")[state]
}
class SortFilter : Filter.Select<String>("排序", arrayOf("更新", "觀看數", "喜愛數")) {
val value get() = arrayOf("DATE_UPDATED", "VIEWS", "FAVORITE_COUNT")[state]
}

View File

@ -10,7 +10,8 @@ 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.getPreferences import keiyoushi.utils.firstInstance
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonString import keiyoushi.utils.toJsonString
import keiyoushi.utils.tryParse import keiyoushi.utils.tryParse
@ -31,7 +32,7 @@ class Komiic : HttpSource(), ConfigurableSource {
override val client = network.cloudflareClient override val client = network.cloudflareClient
private val apiUrl = "$baseUrl/api/query" private val apiUrl = "$baseUrl/api/query"
private val preferences = getPreferences() private val preferences by getPreferencesLazy()
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
preferencesInternal(screen.context).forEach(screen::addPreference) preferencesInternal(screen.context).forEach(screen::addPreference)
@ -57,8 +58,8 @@ class Komiic : HttpSource(), ConfigurableSource {
* Search the comic based on the ID. * Search the comic based on the ID.
*/ */
private fun comicByIDRequest(id: String): Request { private fun comicByIDRequest(id: String): Request {
val variables = Variables().set("comicId", id).build() val variables = Variables().field("comicId", id).build()
val payload = Payload("comicById", variables, QUERY_COMIC_BY_ID) val payload = Payload(Query.COMIC_BY_ID, variables)
return POST(apiUrl, headers, payload.toRequestBody()) return POST(apiUrl, headers, payload.toRequestBody())
} }
@ -87,11 +88,9 @@ class Komiic : HttpSource(), ConfigurableSource {
// Popular Manga =============================================================================== // Popular Manga ===============================================================================
override fun popularMangaRequest(page: Int): Request { override fun popularMangaRequest(page: Int): Request {
val variables = Variables().set( val pagination = Pagination((page - 1) * PAGE_SIZE, "MONTH_VIEWS")
"pagination", val variables = Variables().field("pagination", pagination).build()
Pagination((page - 1) * PAGE_SIZE, "MONTH_VIEWS"), val payload = Payload(Query.HOT_COMICS, variables)
).build()
val payload = Payload("hotComics", variables, QUERY_HOT_COMICS)
return POST(apiUrl, headers, payload.toRequestBody()) return POST(apiUrl, headers, payload.toRequestBody())
} }
@ -104,11 +103,9 @@ class Komiic : HttpSource(), ConfigurableSource {
// Latest Updates ============================================================================== // Latest Updates ==============================================================================
override fun latestUpdatesRequest(page: Int): Request { override fun latestUpdatesRequest(page: Int): Request {
val variables = Variables().set( val pagination = Pagination((page - 1) * PAGE_SIZE, "DATE_UPDATED")
"pagination", val variables = Variables().field("pagination", pagination).build()
Pagination((page - 1) * PAGE_SIZE, "DATE_UPDATED"), val payload = Payload(Query.RECENT_UPDATE, variables)
).build()
val payload = Payload("recentUpdate", variables, QUERY_RECENT_UPDATE)
return POST(apiUrl, headers, payload.toRequestBody()) return POST(apiUrl, headers, payload.toRequestBody())
} }
@ -116,19 +113,23 @@ class Komiic : HttpSource(), ConfigurableSource {
// Search Manga ================================================================================ // Search Manga ================================================================================
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun getFilterList() = buildFilterList()
val variables = Variables().set("keyword", query).build()
val payload = Payload("searchComicAndAuthorQuery", variables, QUERY_SEARCH)
return POST(apiUrl, headers, payload.toRequestBody())
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
return if (query.startsWith(PREFIX_ID_SEARCH)) { if (query.isNotBlank()) {
client.newCall(comicByIDRequest(query.substringAfter(PREFIX_ID_SEARCH))) val variables = Variables().field("keyword", query).build()
.asObservableSuccess() val payload = Payload(Query.SEARCH, variables)
.map(::parseComicByID) return POST(apiUrl, headers, payload.toRequestBody())
} else { } else {
super.fetchSearchManga(page, query, filters) 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())
} }
} }
@ -138,6 +139,17 @@ class Komiic : HttpSource(), ConfigurableSource {
return MangasPage(comics.map(Comic::toSManga), comics.size == PAGE_SIZE) 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()) {
return super.fetchSearchManga(page, query, filters)
}
return client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess().map(::popularMangaParse)
}
// Manga Details =============================================================================== // Manga Details ===============================================================================
override fun getMangaUrl(manga: SManga) = baseUrl + manga.url override fun getMangaUrl(manga: SManga) = baseUrl + manga.url
@ -154,8 +166,8 @@ class Komiic : HttpSource(), ConfigurableSource {
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): Request {
val variables = Variables().set("comicId", manga.id).build() val variables = Variables().field("comicId", manga.id).build()
val payload = Payload("chapterByComicId", variables, QUERY_CHAPTER) val payload = Payload(Query.CHAPTERS_BY_COMIC_ID, variables)
return POST("$apiUrl#${manga.url}", headers, payload.toRequestBody()) return POST("$apiUrl#${manga.url}", headers, payload.toRequestBody())
} }
@ -178,15 +190,15 @@ class Komiic : HttpSource(), ConfigurableSource {
// Page List =================================================================================== // Page List ===================================================================================
override fun pageListRequest(chapter: SChapter): Request { override fun pageListRequest(chapter: SChapter): Request {
val variables = Variables().set("chapterId", chapter.id).build() val variables = Variables().field("chapterId", chapter.id).build()
val payload = Payload("imagesByChapterId", variables, QUERY_PAGE_LIST) val payload = Payload(Query.IMAGES_BY_CHAPTER_ID, variables)
return POST("$apiUrl#${chapter.url}", headers, payload.toRequestBody()) return POST("$apiUrl#${chapter.url}", headers, payload.toRequestBody())
} }
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
val res = response.parseAs<MultiData<Boolean, List<Image>>>() val res = response.parseAs<MultiData<Boolean, List<Image>>>()
val check = preferences.getBoolean(CHECK_API_LIMIT_PREF, true) val check = preferences.getBoolean(CHECK_API_LIMIT_PREF, true)
require(!check || !res.data.result1) { "今日圖片讀取次數已達上限,請登录或明天再來!" } check(!check || !res.data.result1) { "今日圖片讀取次數已達上限,請登录或明天再來!" }
val chapterUrl = response.request.url.fragment!! val chapterUrl = response.request.url.fragment!!
return res.data.result2.mapIndexed { index, image -> return res.data.result2.mapIndexed { index, image ->
Page(index, "$chapterUrl/page/$index", "$baseUrl/api/image/${image.kid}") Page(index, "$chapterUrl/page/$index", "$baseUrl/api/image/${image.kid}")

View File

@ -12,24 +12,26 @@ data class Payload<T>(
val operationName: String, val operationName: String,
val variables: T, val variables: T,
val query: String, val query: String,
) ) {
constructor(query: Query, variables: T) : this(query.operation, variables, query.body)
}
@Serializable @Serializable
data class Pagination( data class Pagination(
val offset: Int, val offset: Int,
val orderBy: String, val orderBy: String,
@EncodeDefault @EncodeDefault
val limit: Int = Komiic.PAGE_SIZE,
@EncodeDefault
val status: String = "", val status: String = "",
@EncodeDefault @EncodeDefault
val asc: Boolean = true, val asc: Boolean = true,
@EncodeDefault
val limit: Int = Komiic.PAGE_SIZE,
) )
class Variables { class Variables {
val variableMap = mutableMapOf<String, JsonElement>() val variableMap = mutableMapOf<String, JsonElement>()
inline fun <reified T> set(key: String, value: T): Variables { inline fun <reified T> field(key: String, value: T): Variables {
variableMap[key] = Json.encodeToJsonElement(value) variableMap[key] = Json.encodeToJsonElement(value)
return this return this
} }

View File

@ -18,8 +18,8 @@ fun preferencesInternal(context: Context) = arrayOf(
}, },
SwitchPreferenceCompat(context).apply { SwitchPreferenceCompat(context).apply {
key = CHECK_API_LIMIT_PREF key = CHECK_API_LIMIT_PREF
title = "自動檢查API受限" title = "自動檢查 API 狀態"
summary = "點擊單個章節請求漫畫圖片時自動檢查一次圖片API是否達到今日請求上限。若已達上限則終止後續操作" summary = "點擊單個章節請求漫畫圖片時自動檢查一次圖片API是否達到今日請求上限。若已達上限則終止後續操作關閉后仍會檢查API只是不再終止操作"
setDefaultValue(true) setDefaultValue(true)
}, },
) )

View File

@ -1,90 +0,0 @@
package eu.kanade.tachiyomi.extension.zh.komiic
private fun buildQuery(body: String = "", queryAction: () -> String): String {
return queryAction().trimIndent()
.replace("#{body}", body.trimIndent())
.replace("%", "$")
}
const val COMIC_BODY =
"""
{
id
title
description
status
imageUrl
authors {
id
name
}
categories {
id
name
}
}
"""
val QUERY_HOT_COMICS = buildQuery(COMIC_BODY) {
"""
query hotComics(%pagination: Pagination!) {
result: hotComics(pagination: %pagination) #{body}
}
"""
}
val QUERY_RECENT_UPDATE = buildQuery(COMIC_BODY) {
"""
query recentUpdate(%pagination: Pagination!) {
result: recentUpdate(pagination: %pagination) #{body}
}
"""
}
val QUERY_SEARCH = buildQuery(COMIC_BODY) {
"""
query searchComicAndAuthorQuery(%keyword: String!) {
result: searchComicsAndAuthors(keyword: %keyword) {
result: comics #{body}
}
}
"""
}
val QUERY_COMIC_BY_ID = buildQuery(COMIC_BODY) {
"""
query comicById(%comicId: ID!) {
result: comicById(comicId: %comicId) #{body}
}
"""
}
val QUERY_CHAPTER = buildQuery {
"""
query chapterByComicId(%comicId: ID!) {
result: chaptersByComicId(comicId: %comicId) {
id
serial
type
size
dateCreated
}
}
"""
}
val QUERY_PAGE_LIST = buildQuery {
"""
query imagesByChapterId(%chapterId: ID!) {
result1: reachedImageLimit,
result2: imagesByChapterId(chapterId: %chapterId) {
id
kid
height
width
}
}
"""
}
// val QUERY_API_LIMIT = buildQuery { "query reachedImageLimit { result: reachedImageLimit }" }

View File

@ -0,0 +1,115 @@
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("%", "$")
}
}