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 {
extName = 'Komiic'
extClass = '.Komiic'
extVersionCode = 2
extVersionCode = 3
isNsfw = true
}

View File

@ -17,7 +17,7 @@ class Result<T>(val result: T)
class MultiResult<T, V>(val result1: T, val result2: V)
@Serializable
data class ComicItem(val id: String, val name: String)
data class Item(val id: String, val name: String)
@Serializable
data class Comic(
@ -26,8 +26,8 @@ data class Comic(
val description: String,
val status: String,
val imageUrl: String,
var authors: List<ComicItem>,
val categories: List<ComicItem>,
var authors: List<Item>,
val categories: List<Item>,
) {
fun toSManga() = SManga.create().apply {
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.SManga
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.toJsonString
import keiyoushi.utils.tryParse
@ -31,7 +32,7 @@ class Komiic : HttpSource(), ConfigurableSource {
override val client = network.cloudflareClient
private val apiUrl = "$baseUrl/api/query"
private val preferences = getPreferences()
private val preferences by getPreferencesLazy()
override fun setupPreferenceScreen(screen: PreferenceScreen) {
preferencesInternal(screen.context).forEach(screen::addPreference)
@ -57,8 +58,8 @@ class Komiic : HttpSource(), ConfigurableSource {
* Search the comic based on the ID.
*/
private fun comicByIDRequest(id: String): Request {
val variables = Variables().set("comicId", id).build()
val payload = Payload("comicById", variables, QUERY_COMIC_BY_ID)
val variables = Variables().field("comicId", id).build()
val payload = Payload(Query.COMIC_BY_ID, variables)
return POST(apiUrl, headers, payload.toRequestBody())
}
@ -87,11 +88,9 @@ class Komiic : HttpSource(), ConfigurableSource {
// Popular Manga ===============================================================================
override fun popularMangaRequest(page: Int): Request {
val variables = Variables().set(
"pagination",
Pagination((page - 1) * PAGE_SIZE, "MONTH_VIEWS"),
).build()
val payload = Payload("hotComics", variables, QUERY_HOT_COMICS)
val pagination = Pagination((page - 1) * PAGE_SIZE, "MONTH_VIEWS")
val variables = Variables().field("pagination", pagination).build()
val payload = Payload(Query.HOT_COMICS, variables)
return POST(apiUrl, headers, payload.toRequestBody())
}
@ -104,11 +103,9 @@ class Komiic : HttpSource(), ConfigurableSource {
// Latest Updates ==============================================================================
override fun latestUpdatesRequest(page: Int): Request {
val variables = Variables().set(
"pagination",
Pagination((page - 1) * PAGE_SIZE, "DATE_UPDATED"),
).build()
val payload = Payload("recentUpdate", variables, QUERY_RECENT_UPDATE)
val pagination = Pagination((page - 1) * PAGE_SIZE, "DATE_UPDATED")
val variables = Variables().field("pagination", pagination).build()
val payload = Payload(Query.RECENT_UPDATE, variables)
return POST(apiUrl, headers, payload.toRequestBody())
}
@ -116,19 +113,23 @@ class Komiic : HttpSource(), ConfigurableSource {
// Search Manga ================================================================================
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val variables = Variables().set("keyword", query).build()
val payload = Payload("searchComicAndAuthorQuery", variables, QUERY_SEARCH)
return POST(apiUrl, headers, payload.toRequestBody())
}
override fun getFilterList() = buildFilterList()
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return if (query.startsWith(PREFIX_ID_SEARCH)) {
client.newCall(comicByIDRequest(query.substringAfter(PREFIX_ID_SEARCH)))
.asObservableSuccess()
.map(::parseComicByID)
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (query.isNotBlank()) {
val variables = Variables().field("keyword", query).build()
val payload = Payload(Query.SEARCH, variables)
return POST(apiUrl, headers, payload.toRequestBody())
} 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)
}
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 ===============================================================================
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 chapterListRequest(manga: SManga): Request {
val variables = Variables().set("comicId", manga.id).build()
val payload = Payload("chapterByComicId", variables, QUERY_CHAPTER)
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())
}
@ -178,15 +190,15 @@ class Komiic : HttpSource(), ConfigurableSource {
// Page List ===================================================================================
override fun pageListRequest(chapter: SChapter): Request {
val variables = Variables().set("chapterId", chapter.id).build()
val payload = Payload("imagesByChapterId", variables, QUERY_PAGE_LIST)
val variables = Variables().field("chapterId", chapter.id).build()
val payload = Payload(Query.IMAGES_BY_CHAPTER_ID, variables)
return POST("$apiUrl#${chapter.url}", headers, payload.toRequestBody())
}
override fun pageListParse(response: Response): List<Page> {
val res = response.parseAs<MultiData<Boolean, List<Image>>>()
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!!
return res.data.result2.mapIndexed { index, image ->
Page(index, "$chapterUrl/page/$index", "$baseUrl/api/image/${image.kid}")

View File

@ -12,24 +12,26 @@ data class Payload<T>(
val operationName: String,
val variables: T,
val query: String,
)
) {
constructor(query: Query, variables: T) : this(query.operation, variables, query.body)
}
@Serializable
data class Pagination(
val offset: Int,
val orderBy: String,
@EncodeDefault
val limit: Int = Komiic.PAGE_SIZE,
@EncodeDefault
val status: String = "",
@EncodeDefault
val asc: Boolean = true,
@EncodeDefault
val limit: Int = Komiic.PAGE_SIZE,
)
class Variables {
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)
return this
}

View File

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