Zaimanhua: add genre filter & check token expiration (#10357)

* Zaimanhua: make comments list immutable

* Zaimanhua: add genre filter

Also refactors the ranking filter to allow disabling it.

* Zaimanhua: check JWT token expiration

* Zaimanhua: use parseAs functions from utils

* misc
This commit is contained in:
zhongfly 2025-09-03 00:03:10 +08:00 committed by Draff
parent 0fae25ac43
commit 105e329c47
Signed by: Draff
GPG Key ID: E8A89F3211677653
6 changed files with 197 additions and 50 deletions

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'Zaimanhua' extName = 'Zaimanhua'
extClass = '.Zaimanhua' extClass = '.Zaimanhua'
extVersionCode = 12 extVersionCode = 13
isNsfw = false isNsfw = false
} }

View File

@ -8,6 +8,7 @@ import android.text.Layout
import android.text.StaticLayout import android.text.StaticLayout
import android.text.TextPaint import android.text.TextPaint
import eu.kanade.tachiyomi.extension.zh.zaimanhua.Zaimanhua.Companion.COMMENTS_FLAG import eu.kanade.tachiyomi.extension.zh.zaimanhua.Zaimanhua.Companion.COMMENTS_FLAG
import keiyoushi.utils.parseAs
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Response import okhttp3.Response
@ -60,7 +61,7 @@ object CommentsInterceptor : Interceptor {
true, true,
) )
val comments = parseChapterComments(response).toMutableList() val comments = parseChapterComments(response)
val paintBody = TextPaint().apply { val paintBody = TextPaint().apply {
color = Color.BLACK color = Color.BLACK
textSize = BODY_FONT_SIZE textSize = BODY_FONT_SIZE

View File

@ -1,21 +1,6 @@
package eu.kanade.tachiyomi.extension.zh.zaimanhua package eu.kanade.tachiyomi.extension.zh.zaimanhua
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Response
import okhttp3.ResponseBody
import uy.kohesive.injekt.injectLazy
val json: Json by injectLazy()
inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString(body.string())
}
inline fun <reified T> ResponseBody.parseAs(): T {
return json.decodeFromString(this.string())
}
fun parseStatus(status: String): Int = when (status) { fun parseStatus(status: String): Int = when (status) {
"连载中" -> SManga.ONGOING "连载中" -> SManga.ONGOING

View File

@ -19,17 +19,6 @@ class RankingGroup : Filter.Group<Filter<*>>(
SortFilter(), SortFilter(),
), ),
) { ) {
private class TimeFilter : QueryFilter(
"榜单",
"by_time",
arrayOf(
Pair("日排行", "0"),
Pair("周排行", "1"),
Pair("月排行", "2"),
Pair("总排行", "3"),
),
)
private class SortFilter : QueryFilter( private class SortFilter : QueryFilter(
"排序", "排序",
"rank_type", "rank_type",
@ -40,3 +29,130 @@ class RankingGroup : Filter.Group<Filter<*>>(
), ),
) )
} }
class TimeFilter : QueryFilter(
"榜单",
"by_time",
arrayOf(
Pair("不查看榜单", ""),
Pair("日排行", "0"),
Pair("周排行", "1"),
Pair("月排行", "2"),
Pair("总排行", "3"),
),
)
class GenreGroup : Filter.Group<Filter<*>>(
"筛选出满足以下所有条件的漫画",
listOf<Filter<*>>(
SortTypeFilter(),
StatusFilter(),
CateFilter(),
ZoneFilter(),
ThemeFilter(),
),
) {
private class SortTypeFilter : QueryFilter(
"排序",
"sortType",
arrayOf(
Pair("更新排序", "1"),
Pair("人气排序", "2"),
),
)
private class StatusFilter : QueryFilter(
"进度",
"status",
arrayOf(
Pair("全部", "0"),
Pair("连载中", "2309"),
Pair("已完结", "2310"),
Pair("短篇", "29205"),
),
)
private class CateFilter : QueryFilter(
"读者群",
"cate",
arrayOf(
Pair("全部", "0"),
Pair("少年漫画", "3262"),
Pair("少女漫画", "3263"),
Pair("青年漫画", "3264"),
Pair("女青漫画", "13626"),
),
)
private class ZoneFilter : QueryFilter(
"地区",
"zone",
arrayOf(
Pair("全部", "0"),
Pair("日本", "2304"),
Pair("韩国", "2305"),
Pair("欧美", "2306"),
Pair("港台", "2307"),
Pair("内地", "2308"),
Pair("其他", "8435"),
),
)
private class ThemeFilter : QueryFilter(
"题材",
"theme",
arrayOf(
Pair("全部", "0"),
Pair("冒险", "4"),
Pair("欢乐向", "5"),
Pair("格斗", "6"),
Pair("科幻", "7"),
Pair("爱情", "8"),
Pair("侦探", "9"),
Pair("竞技", "10"),
Pair("魔法", "11"),
Pair("神鬼", "12"),
Pair("校园", "13"),
Pair("惊悚", "14"),
Pair("其他", "16"),
Pair("四格", "17"),
Pair("亲情", "3242"),
Pair("ゆり", "3243"),
Pair("秀吉", "3244"),
Pair("悬疑", "3245"),
Pair("纯爱", "3246"),
Pair("热血", "3248"),
Pair("泛爱", "3249"),
Pair("历史", "3250"),
Pair("战争", "3251"),
Pair("萌系", "3252"),
Pair("宅系", "3253"),
Pair("治愈", "3254"),
Pair("励志", "3255"),
Pair("武侠", "3324"),
Pair("机战", "3325"),
Pair("音乐舞蹈", "3326"),
Pair("美食", "3327"),
Pair("职场", "3328"),
Pair("西方魔幻", "3365"),
Pair("高清单行", "4459"),
Pair("TS", "4518"),
Pair("东方", "5077"),
Pair("魔幻", "5806"),
Pair("奇幻", "5848"),
Pair("节操", "6219"),
Pair("轻小说", "6316"),
Pair("颜艺", "6437"),
Pair("搞笑", "7568"),
Pair("仙侠", "7900"),
Pair("舰娘", "13627"),
Pair("动画", "17192"),
Pair("AA", "18522"),
Pair("福瑞", "23323"),
Pair("生存", "23388"),
Pair("日常", "30788"),
Pair("画集", "31137"),
),
)
}

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.extension.zh.zaimanhua package eu.kanade.tachiyomi.extension.zh.zaimanhua
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Base64
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
@ -9,13 +10,16 @@ import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.interceptor.rateLimit import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Filter
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.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.firstInstanceOrNull
import keiyoushi.utils.getPreferences import keiyoushi.utils.getPreferences
import keiyoushi.utils.parseAs
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -66,7 +70,7 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
} }
val response = chain.proceed(request) val response = chain.proceed(request)
if (!request.headers["authorization"].isNullOrBlank() && response.peekBody(Long.MAX_VALUE).parseAs<ResponseDto<DataWrapperDto<CanReadDto>>>().data.data?.canRead != false) { if (!request.headers["authorization"].isNullOrBlank() && response.peekBody(Long.MAX_VALUE).string().parseAs<ResponseDto<DataWrapperDto<CanReadDto>>>().data.data?.canRead != false) {
return response return response
} }
var token: String = preferences.getString(TOKEN_PREF, "")!! var token: String = preferences.getString(TOKEN_PREF, "")!!
@ -75,9 +79,9 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
val password = preferences.getString(PASSWORD_PREF, "")!! val password = preferences.getString(PASSWORD_PREF, "")!!
token = getToken(username, password) token = getToken(username, password)
if (token.isBlank()) { if (token.isBlank()) {
preferences.edit().putString(TOKEN_PREF, "").apply() preferences.edit().putString(TOKEN_PREF, "")
preferences.edit().putString(USERNAME_PREF, "").apply() .putString(USERNAME_PREF, "")
preferences.edit().putString(PASSWORD_PREF, "").apply() .putString(PASSWORD_PREF, "").apply()
return response return response
} else { } else {
preferences.edit().putString(TOKEN_PREF, token).apply() preferences.edit().putString(TOKEN_PREF, token).apply()
@ -101,6 +105,11 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
private fun isValid(token: String): Boolean { private fun isValid(token: String): Boolean {
if (token.isBlank()) return false if (token.isBlank()) return false
val parts = token.split(".")
if (parts.size != 3) throw Exception("token格式错误不符合JWT规范")
val payload = Base64.decode(parts[1], Base64.DEFAULT).toString(Charsets.UTF_8).parseAs<JwtPayload>()
if (payload.expirationTime * 1000 < System.currentTimeMillis()) return false
val response = client.newCall( val response = client.newCall(
GET( GET(
"$accountApiUrl/userInfo/get", "$accountApiUrl/userInfo/get",
@ -233,24 +242,32 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
apiHeaders, apiHeaders,
) )
private fun genreApiUrl(): HttpUrl.Builder =
"$apiUrl/comic/filter/list".toHttpUrl().newBuilder()
.addQueryParameter("size", DEFAULT_PAGE_SIZE.toString())
override fun popularMangaParse(response: Response): MangasPage = latestUpdatesParse(response) override fun popularMangaParse(response: Response): MangasPage = latestUpdatesParse(response)
// Search // Search
private fun searchApiUrl(): HttpUrl.Builder = private fun searchApiUrl(): HttpUrl.Builder =
"$apiUrl/search/index".toHttpUrl().newBuilder().addQueryParameter("source", "0") "$apiUrl/search/index".toHttpUrl().newBuilder().addQueryParameter("source", "0")
.addQueryParameter("size", "20") .addQueryParameter("size", DEFAULT_PAGE_SIZE.toString())
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val ranking = filters.filterIsInstance<RankingGroup>().firstOrNull() val ranking = filters.firstInstanceOrNull<RankingGroup>()
val url = if (query.isEmpty() && ranking != null) { val genres = filters.firstInstanceOrNull<GenreGroup>()
rankApiUrl().apply { val url = when {
ranking.state.filterIsInstance<QueryFilter>().forEach { query.isEmpty() && ranking != null && (ranking.state[0] as TimeFilter).state != 0 -> rankApiUrl().apply {
it.addQuery(this) ranking.state.filterIsInstance<QueryFilter>().forEach { it.addQuery(this) }
}
addQueryParameter("page", page.toString()) addQueryParameter("page", page.toString())
}.build() }.build()
} else {
searchApiUrl().apply { query.isEmpty() && genres != null -> genreApiUrl().apply {
genres.state.filterIsInstance<QueryFilter>().forEach { it.addQuery(this) }
addQueryParameter("page", page.toString())
}.build()
else -> searchApiUrl().apply {
addQueryParameter("keyword", query) addQueryParameter("keyword", query)
addQueryParameter("page", page.toString()) addQueryParameter("page", page.toString())
}.build() }.build()
@ -258,11 +275,14 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
return GET(url, apiHeaders) return GET(url, apiHeaders)
} }
override fun searchMangaParse(response: Response): MangasPage = override fun searchMangaParse(response: Response): MangasPage {
if (response.request.url.toString().startsWith("$apiUrl/comic/rank/list")) { val url = response.request.url
return if (url.toString().startsWith("$apiUrl/comic/rank/list")) {
latestUpdatesParse(response) latestUpdatesParse(response)
} else { } else {
response.parseAs<ResponseDto<PageDto>>().data.toMangasPage() // "$apiUrl/comic/filter/list" or "$apiUrl/search/index"
response.parseAs<ResponseDto<PageDto>>().data.toMangasPage(url.queryParameter("page")!!.toInt())
}
} }
// Latest // Latest
@ -280,6 +300,9 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
override fun getFilterList() = FilterList( override fun getFilterList() = FilterList(
RankingGroup(), RankingGroup(),
Filter.Separator(),
Filter.Header("分类(搜索/查看排行榜时无效)"),
GenreGroup(),
) )
private fun chapterCommentsUrl(comicId: String, chapterId: String) = "$apiUrl/viewpoint/list?comicId=$comicId&chapterId=$chapterId" private fun chapterCommentsUrl(comicId: String, chapterId: String) = "$apiUrl/viewpoint/list?comicId=$comicId&chapterId=$chapterId"
@ -292,6 +315,7 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
const val COMMENTS_PREF = "COMMENTS" const val COMMENTS_PREF = "COMMENTS"
const val COMMENTS_FLAG = "COMMENTS" const val COMMENTS_FLAG = "COMMENTS"
const val IMAGE_RETRY_FLAG = "IMAGE_RETRY" const val IMAGE_RETRY_FLAG = "IMAGE_RETRY"
const val DEFAULT_PAGE_SIZE = 20
} }
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.extension.zh.zaimanhua package eu.kanade.tachiyomi.extension.zh.zaimanhua
import eu.kanade.tachiyomi.extension.zh.zaimanhua.Zaimanhua.Companion.DEFAULT_PAGE_SIZE
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
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
@ -100,23 +101,37 @@ class ChapterImagesDto(
@Serializable @Serializable
class PageDto( class PageDto(
// Only genre(/comic/filter/list) use `comicList`, others use `list`
@JsonNames("comicList")
private val list: List<PageItemDto>?, private val list: List<PageItemDto>?,
private val page: Int, // Genre(/comic/filter/list) doesn't have `page` and `size`
private val size: Int, private val page: Int?,
private val size: Int?,
// Only genre(/comic/filter/list) use `totalNum`, others use `total`
@JsonNames("totalNum")
private val total: Int, private val total: Int,
) { ) {
fun toMangasPage(): MangasPage { fun toMangasPage(page: Int): MangasPage {
val currentPage = this.page ?: page
val pageSize = this.size ?: DEFAULT_PAGE_SIZE
if (list.isNullOrEmpty()) throw Exception("漫画结果为空,请检查输入") if (list.isNullOrEmpty()) throw Exception("漫画结果为空,请检查输入")
val hasNextPage = page * size < total val hasNextPage = currentPage * pageSize < total
return MangasPage(list.map { it.toSManga() }, hasNextPage) return MangasPage(list.map { it.toSManga() }, hasNextPage)
} }
} }
@Serializable @Serializable
class PageItemDto( class PageItemDto(
// must have at least one of id and comicId
// Genre(/comic/filter/list) only have `id`
// Ranking(/comic/rank/list) only have `comic_id`
// latest(/comic/update/list) have both `id` (always 0) and `comic_id`
// Search(/search/index) have both `id` and `comic_id` (always 0)
private val id: Int?, private val id: Int?,
@SerialName("comic_id") @SerialName("comic_id")
private val comicId: Int, private val comicId: Int?,
// Only genre(/comic/filter/list) use `name`, others use `title`
@JsonNames("name")
private val title: String, private val title: String,
private val authors: String?, private val authors: String?,
private val status: String?, private val status: String?,
@ -124,7 +139,7 @@ class PageItemDto(
private val types: String?, private val types: String?,
) { ) {
fun toSManga() = SManga.create().apply { fun toSManga() = SManga.create().apply {
url = (this@PageItemDto.id?.takeIf { it != 0 } ?: this@PageItemDto.comicId).toString() url = (this@PageItemDto.comicId?.takeIf { it != 0 } ?: this@PageItemDto.id)!!.toString()
title = this@PageItemDto.title title = this@PageItemDto.title
author = authors?.formatList() author = authors?.formatList()
genre = types?.formatList() genre = types?.formatList()
@ -192,3 +207,9 @@ class CommentDataDto(
} }
} }
} }
@Serializable
class JwtPayload(
@SerialName("exp")
val expirationTime: Long,
)