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 {
extName = 'Zaimanhua'
extClass = '.Zaimanhua'
extVersionCode = 12
extVersionCode = 13
isNsfw = false
}

View File

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

View File

@ -1,21 +1,6 @@
package eu.kanade.tachiyomi.extension.zh.zaimanhua
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) {
"连载中" -> SManga.ONGOING

View File

@ -19,17 +19,6 @@ class RankingGroup : Filter.Group<Filter<*>>(
SortFilter(),
),
) {
private class TimeFilter : QueryFilter(
"榜单",
"by_time",
arrayOf(
Pair("日排行", "0"),
Pair("周排行", "1"),
Pair("月排行", "2"),
Pair("总排行", "3"),
),
)
private class SortFilter : QueryFilter(
"排序",
"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
import android.content.SharedPreferences
import android.util.Base64
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
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.interceptor.rateLimit
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.MangasPage
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.firstInstanceOrNull
import keiyoushi.utils.getPreferences
import keiyoushi.utils.parseAs
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@ -66,7 +70,7 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
}
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
}
var token: String = preferences.getString(TOKEN_PREF, "")!!
@ -75,9 +79,9 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
val password = preferences.getString(PASSWORD_PREF, "")!!
token = getToken(username, password)
if (token.isBlank()) {
preferences.edit().putString(TOKEN_PREF, "").apply()
preferences.edit().putString(USERNAME_PREF, "").apply()
preferences.edit().putString(PASSWORD_PREF, "").apply()
preferences.edit().putString(TOKEN_PREF, "")
.putString(USERNAME_PREF, "")
.putString(PASSWORD_PREF, "").apply()
return response
} else {
preferences.edit().putString(TOKEN_PREF, token).apply()
@ -101,6 +105,11 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
private fun isValid(token: String): Boolean {
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(
GET(
"$accountApiUrl/userInfo/get",
@ -233,24 +242,32 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
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)
// Search
private fun searchApiUrl(): HttpUrl.Builder =
"$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 {
val ranking = filters.filterIsInstance<RankingGroup>().firstOrNull()
val url = if (query.isEmpty() && ranking != null) {
rankApiUrl().apply {
ranking.state.filterIsInstance<QueryFilter>().forEach {
it.addQuery(this)
}
val ranking = filters.firstInstanceOrNull<RankingGroup>()
val genres = filters.firstInstanceOrNull<GenreGroup>()
val url = when {
query.isEmpty() && ranking != null && (ranking.state[0] as TimeFilter).state != 0 -> rankApiUrl().apply {
ranking.state.filterIsInstance<QueryFilter>().forEach { it.addQuery(this) }
addQueryParameter("page", page.toString())
}.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("page", page.toString())
}.build()
@ -258,12 +275,15 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
return GET(url, apiHeaders)
}
override fun searchMangaParse(response: Response): MangasPage =
if (response.request.url.toString().startsWith("$apiUrl/comic/rank/list")) {
override fun searchMangaParse(response: Response): MangasPage {
val url = response.request.url
return if (url.toString().startsWith("$apiUrl/comic/rank/list")) {
latestUpdatesParse(response)
} 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
// "$apiUrl/comic/update/list/1/$page" is same content
@ -280,6 +300,9 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
override fun getFilterList() = FilterList(
RankingGroup(),
Filter.Separator(),
Filter.Header("分类(搜索/查看排行榜时无效)"),
GenreGroup(),
)
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_FLAG = "COMMENTS"
const val IMAGE_RETRY_FLAG = "IMAGE_RETRY"
const val DEFAULT_PAGE_SIZE = 20
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {

View File

@ -1,5 +1,6 @@
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.SChapter
import eu.kanade.tachiyomi.source.model.SManga
@ -100,23 +101,37 @@ class ChapterImagesDto(
@Serializable
class PageDto(
// Only genre(/comic/filter/list) use `comicList`, others use `list`
@JsonNames("comicList")
private val list: List<PageItemDto>?,
private val page: Int,
private val size: Int,
// Genre(/comic/filter/list) doesn't have `page` and `size`
private val page: Int?,
private val size: Int?,
// Only genre(/comic/filter/list) use `totalNum`, others use `total`
@JsonNames("totalNum")
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("漫画结果为空,请检查输入")
val hasNextPage = page * size < total
val hasNextPage = currentPage * pageSize < total
return MangasPage(list.map { it.toSManga() }, hasNextPage)
}
}
@Serializable
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?,
@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 authors: String?,
private val status: String?,
@ -124,7 +139,7 @@ class PageItemDto(
private val types: String?,
) {
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
author = authors?.formatList()
genre = types?.formatList()
@ -192,3 +207,9 @@ class CommentDataDto(
}
}
}
@Serializable
class JwtPayload(
@SerialName("exp")
val expirationTime: Long,
)