From 6c255f4658dcb2cf0e0b5c08345f14c6b8b6063f Mon Sep 17 00:00:00 2001 From: zhongfly <11155705+zhongfly@users.noreply.github.com> Date: Sat, 30 Aug 2025 19:24:55 +0800 Subject: [PATCH] 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 --- src/zh/zaimanhua/build.gradle | 2 +- .../zh/zaimanhua/CommentsInterceptor.kt | 80 +++++++++++++++++++ .../extension/zh/zaimanhua/Zaimanhua.kt | 73 ++++++++++++----- .../extension/zh/zaimanhua/ZaimanhuaDto.kt | 22 +++++ 4 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/CommentsInterceptor.kt diff --git a/src/zh/zaimanhua/build.gradle b/src/zh/zaimanhua/build.gradle index 530cdfd16..995b37687 100644 --- a/src/zh/zaimanhua/build.gradle +++ b/src/zh/zaimanhua/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'Zaimanhua' extClass = '.Zaimanhua' - extVersionCode = 10 + extVersionCode = 11 isNsfw = false } diff --git a/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/CommentsInterceptor.kt b/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/CommentsInterceptor.kt new file mode 100644 index 000000000..a61ad263a --- /dev/null +++ b/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/CommentsInterceptor.kt @@ -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 { + val result = response.parseAs>() + 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() + } +} diff --git a/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/Zaimanhua.kt b/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/Zaimanhua.kt index 789bf8760..f79d04100 100644 --- a/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/Zaimanhua.kt +++ b/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/Zaimanhua.kt @@ -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() @@ -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().errno == 0) { + if (!request.headers["authorization"].isNullOrBlank() && response.peekBody(Long.MAX_VALUE).parseAs>>().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(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) diff --git a/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/ZaimanhuaDto.kt b/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/ZaimanhuaDto.kt index c57faf038..638182290 100644 --- a/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/ZaimanhuaDto.kt +++ b/src/zh/zaimanhua/src/eu/kanade/tachiyomi/extension/zh/zaimanhua/ZaimanhuaDto.kt @@ -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?, +) { + fun toCommentList(): List { + return if (list.isNullOrEmpty()) { + listOf("没有吐槽") + } else { + list.map { item -> + item.last().jsonPrimitive.content + } + } + } +}