Zaimanhua: Fix login check & add chapter comments (#10328)

* Zaimanhua: fix login check

When the token expires, accessing restricted chapters still returns errno=0. Therefore, the approach has been changed to check whether canRead is false.

* Zaimanhua: Use constants for preference keys

* Zaimanhua: Add chapter comments

This commit adds a feature to display comments at the end of each chapter.

- Added a new `CommentDataDto` and a custom `LastStringFromArrayListSerializer` to handle the comment data structure.
- Implemented a `commentsInterceptor` to fetch and render comments as an image.
- Added a preference option to enable/disable chapter comments.
- Updated `fetchPageList` to include the comment page if the preference is enabled.
- Modified `imageRequest` to handle comment page requests.
- Added helper functions `chapterCommentsUrl` and `parseChapterComments`.

* Zaimanhua: tag image requests
This commit is contained in:
zhongfly 2025-08-30 19:24:55 +08:00 committed by Draff
parent e49d76ff14
commit 6c255f4658
Signed by: Draff
GPG Key ID: E8A89F3211677653
4 changed files with 155 additions and 22 deletions

View File

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

View File

@ -0,0 +1,80 @@
package eu.kanade.tachiyomi.extension.zh.zaimanhua
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import eu.kanade.tachiyomi.extension.zh.zaimanhua.Zaimanhua.Companion.COMMENTS_FLAG
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Response
import okhttp3.ResponseBody.Companion.asResponseBody
import okio.Buffer
fun parseChapterComments(response: Response, count: Int): List<String> {
val result = response.parseAs<ResponseDto<CommentDataDto>>()
val comments = result.data.toCommentList()
return if (result.errmsg.isNotBlank()) {
throw Exception(result.errmsg)
} else {
comments.take(count)
}
}
object CommentsInterceptor : Interceptor {
private const val MAX_HEIGHT = 1920
private const val WIDTH = 1080
private const val UNIT = 32
private const val UNIT_F = UNIT.toFloat()
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
if (request.tag(String::class) != COMMENTS_FLAG) {
return response
}
val comments = parseChapterComments(response, MAX_HEIGHT / (UNIT * 2) - 1).toMutableList()
comments.add(0, "章末吐槽:")
val paint = TextPaint().apply {
color = Color.BLACK
textSize = UNIT_F
isAntiAlias = true
}
var height = UNIT
val layouts = comments.map {
@Suppress("DEPRECATION")
StaticLayout(it, paint, WIDTH - 2 * UNIT, Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false)
}.takeWhile {
val lineHeight = it.height + UNIT
if (height + lineHeight <= MAX_HEIGHT) {
height += lineHeight
true
} else {
false
}
}
val bitmap = Bitmap.createBitmap(WIDTH, height, Bitmap.Config.ARGB_8888)
bitmap.eraseColor(Color.WHITE)
val canvas = Canvas(bitmap)
var y = UNIT
for (layout in layouts) {
canvas.save()
canvas.translate(UNIT_F, y.toFloat())
layout.draw(canvas)
canvas.restore()
y += layout.height + UNIT
}
val responseBody = Buffer().run {
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream())
asResponseBody("image/jpeg".toMediaType())
}
return response.newBuilder().body(responseBody).build()
}
}

View File

@ -4,6 +4,7 @@ import android.content.SharedPreferences
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.interceptor.rateLimit
@ -43,7 +44,7 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
private val mobileBaseUrl = "https://m.zaimanhua.com"
private val apiUrl = "https://v4api.zaimanhua.com/app/v1"
private val accountApiUrl = "https://account-api.zaimanhua.com/v1"
private val checkTokenRegex = Regex("""$apiUrl/comic/(detail|chapter)""")
private val checkTokenRegex = Regex("""$apiUrl/comic/chapter""")
private val json by injectLazy<Json>()
@ -53,6 +54,7 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
.rateLimit(5)
.addInterceptor(::authIntercept)
.addInterceptor(::imageRetryInterceptor)
.addInterceptor(CommentsInterceptor)
.build()
private fun authIntercept(chain: Interceptor.Chain): Response {
@ -64,21 +66,21 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
}
val response = chain.proceed(request)
if (!request.headers["authorization"].isNullOrBlank() && response.peekBody(Long.MAX_VALUE).parseAs<SimpleResponseDto>().errno == 0) {
if (!request.headers["authorization"].isNullOrBlank() && response.peekBody(Long.MAX_VALUE).parseAs<ResponseDto<DataWrapperDto<CanReadDto>>>().data.data?.canRead != false) {
return response
}
var token: String = preferences.getString("TOKEN", "")!!
var token: String = preferences.getString(TOKEN_PREF, "")!!
if (!isValid(token)) {
val username = preferences.getString("USERNAME", "")!!
val password = preferences.getString("PASSWORD", "")!!
val username = preferences.getString(USERNAME_PREF, "")!!
val password = preferences.getString(PASSWORD_PREF, "")!!
token = getToken(username, password)
if (token.isBlank()) {
preferences.edit().putString("TOKEN", "").apply()
preferences.edit().putString("USERNAME", "").apply()
preferences.edit().putString("PASSWORD", "").apply()
preferences.edit().putString(TOKEN_PREF, "").apply()
preferences.edit().putString(USERNAME_PREF, "").apply()
preferences.edit().putString(PASSWORD_PREF, "").apply()
return response
} else {
preferences.edit().putString("TOKEN", token).apply()
preferences.edit().putString(TOKEN_PREF, token).apply()
apiHeaders = apiHeaders.newBuilder().setToken(token).build()
}
} else if (!request.headers["authorization"].isNullOrBlank() && request.headers["authorization"] == "Bearer $token") {
@ -95,7 +97,7 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
if (token.isNotBlank()) set("authorization", "Bearer $token")
}
private var apiHeaders = headersBuilder().setToken(preferences.getString("TOKEN", "")!!).build()
private var apiHeaders = headersBuilder().setToken(preferences.getString(TOKEN_PREF, "")!!).build()
private fun isValid(token: String): Boolean {
if (token.isBlank()) return false
@ -176,12 +178,18 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
if (!result.data.data!!.canRead) {
throw Exception("用户权限不足,请提升用户等级")
}
return Observable.just(
result.data.data.images.mapIndexed { index, it ->
return Observable.fromCallable {
val images = result.data.data.images
val pageList = images.mapIndexedTo(ArrayList(images.size + 1)) { index, it ->
val fragment = json.encodeToString(ImageRetryParamsDto(chapter.url, index))
Page(index, imageUrl = "$it#$fragment")
},
)
}
if (preferences.getBoolean(COMMENTS_PREF, false)) {
val (mangaId, chapterId) = chapter.url.split("/", limit = 2)
pageList.add(Page(pageList.size, COMMENTS_FLAG, chapterCommentsUrl(mangaId, chapterId)))
}
pageList
}
}
}
@ -189,7 +197,7 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
val request = chain.request()
val response = chain.proceed(request)
val fragment = request.url.fragment
if (response.isSuccessful || request.url.host != "images.zaimanhua.com" || fragment == null) return response
if (response.isSuccessful || request.tag(String::class) != IMAGE_RETRY_FLAG || fragment == null) return response
response.close()
val params = json.decodeFromString<ImageRetryParamsDto>(fragment)
@ -203,6 +211,14 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
}
}
override fun imageRequest(page: Page): Request {
val flag = if (page.url == COMMENTS_FLAG) COMMENTS_FLAG else IMAGE_RETRY_FLAG
val reqHeaders = if (page.url == COMMENTS_FLAG) apiHeaders else headers
return GET(page.imageUrl!!, reqHeaders).newBuilder()
.tag(String::class, flag)
.build()
}
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
// Popular
@ -266,39 +282,54 @@ class Zaimanhua : HttpSource(), ConfigurableSource {
RankingGroup(),
)
private fun chapterCommentsUrl(comicId: String, chapterId: String) = "$apiUrl/viewpoint/list?comicId=$comicId&chapterId=$chapterId"
companion object {
val USE_CACHE = CacheControl.Builder().maxStale(170, TimeUnit.SECONDS).build()
const val USERNAME_PREF = "USERNAME"
const val PASSWORD_PREF = "PASSWORD"
const val TOKEN_PREF = "TOKEN"
const val COMMENTS_PREF = "COMMENTS"
const val COMMENTS_FLAG = "COMMENTS"
const val IMAGE_RETRY_FLAG = "IMAGE_RETRY"
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
SwitchPreferenceCompat(screen.context).apply {
key = COMMENTS_PREF
title = "章末吐槽页"
summary = "修改后,已加载的章节需要清除章节缓存才能生效。"
setDefaultValue(false)
}.let(screen::addPreference)
EditTextPreference(screen.context).apply {
key = "USERNAME"
key = USERNAME_PREF
title = "用户名"
summary = "该配置被修改后,会清空令牌(Token)以便重新登录;如果登录失败,会清空该配置"
setOnPreferenceChangeListener { _, _ ->
// clean token after username/password changed
preferences.edit().putString("TOKEN", "").apply()
preferences.edit().putString(TOKEN_PREF, "").apply()
true
}
}.let(screen::addPreference)
EditTextPreference(screen.context).apply {
key = "PASSWORD"
key = PASSWORD_PREF
title = "密码"
summary = "该配置被修改后,会清空令牌(Token)以便重新登录;如果登录失败,会清空该配置"
setOnPreferenceChangeListener { _, _ ->
// clean token after username/password changed
preferences.edit().putString("TOKEN", "").apply()
preferences.edit().putString(TOKEN_PREF, "").apply()
true
}
}.let(screen::addPreference)
EditTextPreference(screen.context).apply {
key = "TOKEN"
key = TOKEN_PREF
title = "令牌(Token)"
summary = "当前登录状态:${
if (preferences.getString("TOKEN", "").isNullOrEmpty()) "未登录" else "已登录"
if (preferences.getString(TOKEN_PREF, "").isNullOrEmpty()) "未登录" else "已登录"
}\n填写用户名和密码后不会立刻尝试登录会在下次请求时自动尝试"
setEnabled(false)

View File

@ -5,7 +5,9 @@ import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNames
import kotlinx.serialization.json.jsonPrimitive
@Serializable
class MangaDto(
@ -170,3 +172,23 @@ data class ImageRetryParamsDto(
val url: String,
val index: Int,
)
@Serializable
class CanReadDto(
val canRead: Boolean?,
)
@Serializable
class CommentDataDto(
val list: List<JsonArray>?,
) {
fun toCommentList(): List<String> {
return if (list.isNullOrEmpty()) {
listOf("没有吐槽")
} else {
list.map { item ->
item.last().jsonPrimitive.content
}
}
}
}