💬 九州书盟(优书网 · 书评社区)
https://jiuzhoushumeng.com
Simwoodone (12535)10/13 21:01
youshu-推书
1.更新书评页支持随手机日夜间模式变化
2.能做什么• 搜索:站内书籍搜索(上游无分页,仅第 1 页 10 条)。• 详情:加权评分、均分、评分人数、分类、字数、状态、标签、简介,并可一键打开网页版。• 目录:① 书籍资料 ② 评分分布 ③ 书评列表(长评,目录内最多载入前 100 篇;要看全部请用书评面板)④ 相似推荐 ⑤ 收录书单 ⑥ 网页版。• 发现:5 个榜单 + 10 个主分类 + 24 个热标签 + 书评广场 + 书单广场(含书单标签)+ 百度小说热榜 + 编辑推荐。• 书评面板:详情页底部的「💬 书评面板」按钮(即书源自定义按钮)用半屏面板展示评分分布、书评列表与评论;首屏预载 100 篇,翻到末尾会自动续拉下一页,可一直翻到该书的全部书评。面板右上角有主题按钮(显示「● 跟随 ▾」),点一下会展开一个小面板:里面能直接选「跟随系统 / 强制深色 / 强制浅色」三档,还能看到系统报告值、主题变化次数、最近变化时间,并带一个「重新检测」按钮——你在手机设置里切完主题回来点一下就知道有没有跟上,不必关掉面板重开。选过就记住(本机),点面板外部自动收起。说明:半屏面板里的网页跟随的是手机系统的日夜模式,不跟随 App 内部的夜间开关;所以系统的「跟随」表现与 App 夜间模式不一致是正常的,想要和 App 一致就选「强制深色 / 强制浅色」。若你的手机 WebView 太老、不支持跟随,面板会明确提示并引导你改用强制档。• 登录:本源码不含任何账号信息,可放心分享。首次使用请在「书源管理 → 本源的登录」中填写你自己的九州书盟邮箱与密码(只保存在本机,不会写回书源),填一次即可。• 自动签到(不用手动点):填好账号后,每天打开软件时自动签到——你正常搜索 / 看发现页 / 点开任意一本书,都会在后台随机 5~40 秒内悄悄完成签到,每天只签一次,签到时刻不固定。无需进入登录页点任何按钮;已签过则一次内存判断即返回,零网络开销。发现页底部「👤 账号」按钮可查看今日签到状态。说明与注意• 本源不提供小说正文——九州书盟是书评/评分社区,本身不存放连载正文;「书评」才是它的内容。• 长评正文按 Markdown 原样取出后转为纯文本,不做任何删减;图片会以链接形式保留。• 站点评论功能目前几乎无人使用(评论数普遍为 0),书评面板会为有评论的书评自动附带评论。• 账号密码只由你本人在本机填写,保存在 App 本地缓存里;源码与分享出去的 JSON 都不包含任何账号。• 若提示「未配置账号」或签到失败,先点发现页的「👤 账号」查看状态;已填过仍失败通常是密码已改,请重新填写。• 未配置账号时,搜索 / 详情 / 目录 / 书评 / 发现 / 面板全部照常可用,只有登录与签到会被跳过。
{
"bookSourceUrl": "https:\/\/jiuzhoushumeng.com",
"bookSourceName": "💬 九州书盟(优书网 · 书评社区)",
"enabledExplore": true,
"enabled": true,
"bookSourceGroup": "书评",
"author": "逆向自《九州书盟》Android 1.0.13",
"help": true,
"html": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head><meta charset=\"UTF-8\"><title>九州书盟 · 书评社区<\/title><\/head>\n<body><\/body>\n<script>\n var isCookieJar = false;\n\n \/\/ ========== FlutterJSBridge ==========\n class FlutterJSBridge {\n constructor() { this.init(); }\n init() {\n if (window.flutter_inappwebview) {\n this.isReady = true;\n this.CookieJar();\n } else {\n window.addEventListener('flutterInAppWebViewPlatformReady', () => {\n this.isReady = true;\n this.CookieJar();\n });\n }\n }\n async CookieJar() {\n try { await window.flutter_inappwebview.callHandler('CookieJar', isCookieJar); } catch (e) {}\n }\n async getbuildNumber() { try { return await window.flutter_inappwebview.callHandler('buildNumber'); } catch (e) { return 0; } }\n async getversion() { try { return await window.flutter_inappwebview.callHandler('version'); } catch (e) { return \"0.0.0\"; } }\n async getsourceurl() { try { return await window.flutter_inappwebview.callHandler('getsourceurl'); } catch (e) { return \"\"; } }\n async getsearch() { try { return await window.flutter_inappwebview.callHandler('getsearch'); } catch (e) { return -1; } }\n async getWidth() { try { return await window.flutter_inappwebview.callHandler('getWidth'); } catch (e) { return 0; } }\n async getHeight() { try { return await window.flutter_inappwebview.callHandler('getHeight'); } catch (e) { return 0; } }\n async htmlToText(str) { try { return await window.flutter_inappwebview.callHandler('htmlToText', str); } catch (e) { return \"\"; } }\n async showvideoplay(url, title, key) { try { return await window.flutter_inappwebview.callHandler('showvideoplay', url, title, key); } catch (e) { return \"\"; } }\n async refreshContent(bookurl) { try { return await window.flutter_inappwebview.callHandler('refreshContent', bookurl); } catch (e) { return \"\"; } }\n async refreshExplore() { try { return await window.flutter_inappwebview.callHandler('refreshExplore'); } catch (e) { return \"\"; } }\n async showsvg() { try { return await window.flutter_inappwebview.callHandler('showsvg'); } catch (e) { return \"\"; } }\n async getsvg() { try { return await window.flutter_inappwebview.callHandler('getsvg'); } catch (e) { return []; } }\n async addsvg(svg) { try { return await window.flutter_inappwebview.callHandler('addsvg', svg); } catch (e) { return []; } }\n async setHtml(str) { try { return await window.flutter_inappwebview.callHandler('setHtml', str); } catch (e) { return \"\"; } }\n async toTraditional(str) { try { return await window.flutter_inappwebview.callHandler('toTraditional', str); } catch (e) { return \"\"; } }\n async toSimplified(str) { try { return await window.flutter_inappwebview.callHandler('toSimplified', str); } catch (e) { return \"\"; } }\n async voice() { try { return await window.flutter_inappwebview.callHandler('voice'); } catch (e) { return \"\"; } }\n async getDeviceid() { try { return await window.flutter_inappwebview.callHandler('id'); } catch (e) { return \"\"; } }\n async getDevice() { try { return await window.flutter_inappwebview.callHandler('device'); } catch (e) { return \"\"; } }\n async getLoginUser() { try { return await window.flutter_inappwebview.callHandler('getLoginUser'); } catch (e) { return \"\"; } }\n async log(str) { try { return await window.flutter_inappwebview.callHandler('log', str); } catch (e) { return false; } }\n async text(type, str) { try { return await window.flutter_inappwebview.callHandler('text', type, str); } catch (e) { return false; } }\n async showToast(str) { try { return await window.flutter_inappwebview.callHandler('showToast', str); } catch (e) { return false; } }\n async showLongToast(str) { try { return await window.flutter_inappwebview.callHandler('showLongToast', str); } catch (e) { return false; } }\n async getWebViewUA() { try { return await window.flutter_inappwebview.callHandler('getWebViewUA'); } catch (e) { return \"\"; } }\n async openurl(url) { try { return await window.flutter_inappwebview.callHandler('openurl', url, \"\"); } catch (e) { return false; } }\n async openurlwithMimeType(url, mimeType) { try { return await window.flutter_inappwebview.callHandler('openurl', url, mimeType); } catch (e) { return false; } }\n async webview(url, js, html, body, header) { try { return await window.flutter_inappwebview.callHandler('webview', url, js, html, body, header, \"\", \"\"); } catch (e) { return \"\"; } }\n async startBrowser(url, title, header) { try { return await window.flutter_inappwebview.callHandler('startBrowser', url, title, header); } catch (e) { return \"\"; } }\n async startBrowserWithShouldOverrideUrlLoading(url, title, header) { try { return await window.flutter_inappwebview.callHandler('startBrowserWithShouldOverrideUrlLoading', url, title, header); } catch (e) { return \"\"; } }\n async startBrowserDp(url, title) { try { return await window.flutter_inappwebview.callHandler('startBrowserDp', url, title); } catch (e) { return \"\"; } }\n async startBrowserDpWithShouldOverrideUrlLoading(url, title) { try { return await window.flutter_inappwebview.callHandler('startBrowserDpWithShouldOverrideUrlLoading', url, title); } catch (e) { return \"\"; } }\n async startBrowserDpwithHtml(url, html, title) { try { return await window.flutter_inappwebview.callHandler('startBrowserDpwithHtml', url, html, title); } catch (e) { return \"\"; } }\n async back() { try { return await window.flutter_inappwebview.callHandler('back'); } catch (e) { return false; } }\n async utf8ToGbkUrlEncoded(str) { try { return await window.flutter_inappwebview.callHandler('utf8ToGbkUrlEncoded', str); } catch (e) { return \"\"; } }\n async getVerificationCode(str, header) { try { return await window.flutter_inappwebview.callHandler('getVerificationCode', str, header); } catch (e) { return \"\"; } }\n async addbook(bookUrl) { try { return await window.flutter_inappwebview.callHandler('addbook', bookUrl); } catch (e) { return \"\"; } }\n async searchbook(key) { try { return await window.flutter_inappwebview.callHandler('searchbook', key); } catch (e) { return \"\"; } }\n async getdurChapterIndex(bookUrl) { try { return await window.flutter_inappwebview.callHandler('getdurChapterIndex', bookUrl); } catch (e) { return 0; } }\n async getReadBook() { try { return await window.flutter_inappwebview.callHandler('getReadBook'); } catch (e) { return null; } }\n async base64encode(str) { try { return await window.flutter_inappwebview.callHandler('base64encode', str); } catch (e) { return \"\"; } }\n async base64decode(str) { try { return await window.flutter_inappwebview.callHandler('base64decode', str); } catch (e) { return \"\"; } }\n }\n\n \/\/ ========== Http ==========\n class Http {\n constructor() {\n this.open = false;\n this.requestTimestamps = [];\n this.rateLimit = 8;\n this.rateLimitWindow = 1000;\n }\n async checkRateLimit() {\n if (!this.open) return;\n const now = Date.now();\n this.requestTimestamps = this.requestTimestamps.filter(t => now - t < this.rateLimitWindow);\n if (this.requestTimestamps.length >= this.rateLimit) {\n const waitTime = this.rateLimitWindow - (now - this.requestTimestamps[0]);\n await new Promise(resolve => setTimeout(resolve, waitTime));\n return this.checkRateLimit();\n }\n this.requestTimestamps.push(now);\n }\n async Get(url, headers, followRedirects) {\n try {\n await this.checkRateLimit();\n return await window.flutter_inappwebview.callHandler('http', \"get\", url, \"\", JSON.stringify(headers), followRedirects, \"\");\n } catch (e) { return null; }\n }\n async Head(url, headers, followRedirects) {\n try {\n await this.checkRateLimit();\n return await window.flutter_inappwebview.callHandler('http', \"head\", url, \"\", JSON.stringify(headers), followRedirects, \"\");\n } catch (e) { return null; }\n }\n async Post(url, headers, body, contenttype, followRedirects) {\n try {\n await this.checkRateLimit();\n return await window.flutter_inappwebview.callHandler('http', \"post\", url, body, JSON.stringify(headers), followRedirects, contenttype);\n } catch (e) { return null; }\n }\n }\n\n \/\/ ========== Cache \/ Cookie ==========\n class Cache {\n constructor() {}\n async get(key) { try { return await window.flutter_inappwebview.callHandler('cache.get', key); } catch (e) { return null; } }\n async set(key, value) { try { return await window.flutter_inappwebview.callHandler('cache.set', key, value); } catch (e) { return null; } }\n async remove(key) { try { return await window.flutter_inappwebview.callHandler('cache.remove', key); } catch (e) { return null; } }\n async allget(key) { try { return await window.flutter_inappwebview.callHandler('cache.allget', key); } catch (e) { return null; } }\n async allset(key, value) { try { return await window.flutter_inappwebview.callHandler('cache.allset', key, value); } catch (e) { return null; } }\n async allremove(key) { try { return await window.flutter_inappwebview.callHandler('cache.allremove', key); } catch (e) { return null; } }\n async getLoginInfo() { return await this.get(\"LoginInfo\"); }\n async putLoginInfo(info) { return await this.set(\"LoginInfo\", info); }\n async putLyric(bookUrl, index, lyric) { try { return await window.flutter_inappwebview.callHandler('putLyric', bookUrl, index, lyric); } catch (e) { return null; } }\n }\n\n class Cookie {\n constructor() {}\n async get(url) { try { return await window.flutter_inappwebview.callHandler('cookie.get', url); } catch (e) { return null; } }\n async remove(url) { try { return await window.flutter_inappwebview.callHandler('cookie.remove', url); } catch (e) { return null; } }\n async set(url, value) { try { return await window.flutter_inappwebview.callHandler('cookie.set', url, value); } catch (e) { return null; } }\n }\n<\/script>\n\n<!-- ==================== 业务逻辑 ==================== -->\n<script>\n const flutterBridge = new FlutterJSBridge();\n const cache = new Cache();\n const http = new Http();\n const cookie = new Cookie();\n\n \/\/ ==================== 常量 ====================\n const BOOK_TYPE = 0; \/\/ 0=小说\/文字(本源内容为书籍资料 + 书评,属文字源)\n const API = \"https:\/\/jiuzhoushumeng.com\/api\/v1\";\n const WEB = \"https:\/\/jiuzhoushumeng.com\";\n const UA = \"Mozilla\/5.0 (Linux; Android 14)\"; \/\/ 原生桥接,不要自称 Chrome(会被 CF\/风控针对)\n const HDR = { \"User-Agent\": UA, \"Accept\": \"application\/json, text\/plain, *\/*\" };\n\n const MAX_REVIEW_PAGES = 5; \/\/ 目录最多载入 5×20 = 100 篇长评\n const REVIEW_PAGE_SIZE = 20; \/\/ 服务端固定 20\/页(page_size 会被忽略)\n const PANEL_REVIEW_PAGES = 5; \/\/ 书评面板首屏预载 5×20 = 100 篇(之后按需续拉,不设上限)\n const PANEL_COMMENT_MAX = 6; \/\/ 面板里最多为多少篇长评拉取评论\n const PANEL_PAGE_SIZE = 10; \/\/ 面板页内本地翻页,每页条数\n const PANEL_FETCH_SIZE = 20; \/\/ 面板「加载更多」每次向服务端续拉的条数\n const LIST_PAGE_SIZE = 20; \/\/ 分类 \/ 标签浏览每页条数\n const MAX_FIND_PAGE = 400; \/\/ 分类\/标签浏览的页码上限(上游不返回空页)\n\n \/\/ 主分类(已逐个实测 \/books\/categories\/sub?main= 返回非空)\n const MAIN_CATEGORIES = [\"玄幻\", \"仙侠\", \"武侠\", \"都市\", \"历史\", \"游戏\", \"科幻\", \"悬疑\", \"言情\", \"轻小说\"];\n \/\/ 榜单(已逐个实测 \/rankings\/{key})\n const RANKINGS = [\n { key: \"popular\", label: \"🔥 热门榜\", desc: \"人气最高\" },\n { key: \"recent-rated\", label: \"🆕 新近评价\", desc: \"近期新增评分最多\" },\n { key: \"controversial\",label: \"⚔️ 争议榜\", desc: \"评分分歧最大\" },\n { key: \"abandoned\", label: \"🚪 弃书榜\", desc: \"读不下去最多\" },\n { key: \"bad-ending\", label: \"💔 烂尾榜\", desc: \"结尾最令人失望\" }\n ];\n const TOP_TAG_COUNT = 24; \/\/ 发现页最多列出多少个热标签\n\n \/\/ ==================== 登录 \/ 签到 ====================\n \/\/ ⚠️ 本源码**不含任何账号信息**(可安全分享)。\n \/\/ 账号由用户在本机首次使用时通过「书源管理 → 登录」填入,只写入本机缓存(K_CRED),\n \/\/ 不会出现在书源文件里。未配置时:公开数据照常可用,登录\/签到静默跳过并给出提示。\n const K_CRED = \"jzsm_cred\"; \/\/ 用户在本机填写的账号({email, password})\n const K_AT = \"jzsm_access_token\";\n const K_RT = \"jzsm_refresh_token\";\n const K_USER = \"jzsm_user\";\n const K_SIGNIN = \"jzsm_signin_day\";\n const K_THEME = \"jzsm_panel_theme\"; \/\/ 书评面板主题:auto \/ dark \/ light(面板侧写 localStorage,书源侧只读)\n \/\/ ---- 自动签到策略 ----\n \/\/ 用户诉求:每天**打开软件时**自动签到,不用手动点「登录」,且时刻不要固定。\n \/\/ 书源没有「App 启动」钩子,所以把触发点铺在「打开软件后必然会走」的入口上(发现页\/搜索\/点发现项\/打开书籍)。\n const SIGNIN_DELAY_MIN = 5; \/\/ 随机延迟下限(秒)\n const SIGNIN_DELAY_MAX = 40; \/\/ 随机延迟上限(秒)——每天时刻不同,避免被当成定时脚本\n const SIGNIN_REPLAN_MS = 180000; \/\/ 一次排程后 3 分钟内不再重复排程(防抖)\n\n \/\/ ==================== 工具函数 ====================\n function b64u(str) {\n try {\n var bytes = new TextEncoder().encode(String(str == null ? \"\" : str));\n var bin = \"\";\n for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);\n return btoa(bin).replace(\/\\+\/g, \"-\").replace(\/\\\/\/g, \"_\").replace(\/=+$\/, \"\");\n } catch (e) { return \"\"; }\n }\n function unb64u(s) {\n try {\n s = String(s || \"\").replace(\/-\/g, \"+\").replace(\/_\/g, \"\/\");\n while (s.length % 4) s += \"=\";\n var bin = atob(s);\n var bytes = new Uint8Array(bin.length);\n for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);\n return new TextDecoder(\"utf-8\").decode(bytes);\n } catch (e) { return \"\"; }\n }\n function esc(s) { return String(s == null ? \"\" : s).replace(\/[|\\r\\n\\t]\/g, \" \").trim(); }\n function escHtml(s) {\n return String(s == null ? \"\" : s)\n .replace(\/&\/g, \"&\").replace(\/<\/g, \"<\").replace(\/>\/g, \">\")\n .replace(\/\"\/g, \""\");\n }\n function escAttr(s) {\n return String(s == null ? \"\" : s).replace(\/'\/g, \" \").replace(\/\"\/g, \" \").replace(\/\\\\\/g, \" \").replace(\/[\\r\\n<>]\/g, \" \");\n }\n function num(v, d) { var n = Number(v); return isFinite(n) ? n : (d === undefined ? 0 : d); }\n function pick(o) {\n for (var i = 1; i < arguments.length; i++) {\n var v = o ? o[arguments[i]] : null;\n if (v !== undefined && v !== null && v !== \"\") return v;\n }\n return \"\";\n }\n function withTimeout(p, ms, fallback) {\n return new Promise(function (resolve) {\n var done = false;\n var t = setTimeout(function () { if (!done) { done = true; resolve(fallback); } }, ms);\n Promise.resolve(p).then(function (v) {\n if (!done) { done = true; clearTimeout(t); resolve(v); }\n }).catch(function () {\n if (!done) { done = true; clearTimeout(t); resolve(fallback); }\n });\n });\n }\n function sleepMs(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }\n\n function safeJson(text) {\n if (!text) return null;\n if (typeof text === \"object\") return text;\n var s = String(text).trim();\n try { return JSON.parse(s); } catch (e) {}\n try {\n var t = s.replace(\/'\/g, '\"').replace(\/\\bNone\\b\/g, \"null\").replace(\/\\bTrue\\b\/g, \"true\").replace(\/\\bFalse\\b\/g, \"false\");\n return JSON.parse(t);\n } catch (e2) { return null; }\n }\n function respText(resp) {\n if (!resp) return \"\";\n if (resp.data !== undefined && resp.data !== null) return (typeof resp.data === \"string\") ? resp.data : JSON.stringify(resp.data);\n if (resp.body !== undefined && resp.body !== null) return (typeof resp.body === \"string\") ? resp.body : JSON.stringify(resp.body);\n return \"\";\n }\n function respStatus(resp) {\n if (!resp) return 0;\n var s = num(pick(resp, \"statusCode\", \"status\", \"status_code\"), 0);\n return s;\n }\n\n function normCover(u) {\n u = String(u == null ? \"\" : u).trim();\n if (!u) return \"\";\n if (u.indexOf(\"\/\/\") === 0) return \"https:\" + u;\n return u;\n }\n function fmtWords(n) {\n n = num(n, 0);\n if (!n) return \"\";\n if (n >= 10000) return (n \/ 10000).toFixed(n >= 1000000 ? 0 : 1).replace(\/\\.0$\/, \"\") + \" 万字\";\n return n + \" 字\";\n }\n function fmtScore(v) {\n var n = num(v, 0);\n if (!n) return \"\";\n return (Math.round(n * 10) \/ 10).toFixed(1);\n }\n function fmtDate(ts) {\n ts = num(ts, 0);\n if (!ts) return \"\";\n try {\n var d = new Date(ts * 1000);\n var p = function (x) { return (x < 10 ? \"0\" : \"\") + x; };\n return d.getFullYear() + \"-\" + p(d.getMonth() + 1) + \"-\" + p(d.getDate());\n } catch (e) { return \"\"; }\n }\n function todayKey() {\n try {\n var d = new Date();\n var p = function (x) { return (x < 10 ? \"0\" : \"\") + x; };\n return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate());\n } catch (e) { return \"0\"; }\n }\n function truncate(s, n) {\n s = String(s == null ? \"\" : s);\n return s.length > n ? s.slice(0, n) + \"…\" : s;\n }\n\n \/\/ Markdown → 纯文本(保留全部文字,仅去标记;图片转为行内提示)\n function mdToText(md) {\n var s = String(md == null ? \"\" : md).replace(\/\\r\\n?\/g, \"\\n\");\n s = s.replace(\/<br\\s*\\\/?>\/gi, \"\\n\");\n s = s.replace(\/!\\[([^\\]]*)\\]\\(([^)\\s]+)[^)]*\\)\/g, function (m, alt, url) { return \"\\n[图片\" + (alt ? \":\" + alt : \"\") + \"]\\n\" + url + \"\\n\"; });\n s = s.replace(\/\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)\/g, function (m, t, url) { return t + \"(\" + url + \")\"; });\n s = s.replace(\/^\\s{0,3}#{1,6}\\s*(.+?)\\s*#*\\s*$\/gm, function (m, t) { return \"\\n【\" + t + \"】\\n\"; });\n s = s.replace(\/\\*\\*\\*(.+?)\\*\\*\\*\/g, \"$1\").replace(\/\\*\\*(.+?)\\*\\*\/g, \"$1\").replace(\/__(.+?)__\/g, \"$1\");\n s = s.replace(\/(^|[^*])\\*([^*\\n]+)\\*\/g, \"$1$2\").replace(\/(^|[^_])_([^_\\n]+)_\/g, \"$1$2\");\n s = s.replace(\/`{3,}[^\\n]*\\n([\\s\\S]*?)`{3,}\/g, \"\\n$1\\n\").replace(\/`([^`\\n]+)`\/g, \"$1\");\n s = s.replace(\/^\\s{0,3}>\\s?\/gm, \"\");\n s = s.replace(\/^\\s{0,3}([-*+])\\s+\/gm, \"· \");\n s = s.replace(\/^\\s{0,3}(\\d+)\\.\\s+\/gm, \"$1. \");\n s = s.replace(\/^\\s{0,3}([-*_]\\s*){3,}$\/gm, \"————————\");\n s = s.replace(\/ \/g, \" \").replace(\/&\/g, \"&\").replace(\/<\/g, \"<\").replace(\/>\/g, \">\").replace(\/"\/g, '\"').replace(\/'\/g, \"'\");\n s = s.replace(\/\\n{3,}\/g, \"\\n\\n\").replace(\/[ \\t]+\\n\/g, \"\\n\");\n return s.trim();\n }\n\n \/\/ ==================== HTTP ====================\n async function apiJson(path, opt) {\n opt = opt || {};\n var url = \/^https?:\/i.test(path) ? path : (API + path);\n var hdrs = { \"User-Agent\": UA, \"Accept\": \"application\/json, text\/plain, *\/*\" };\n if (opt.auth) {\n var t = await withTimeout(ensureAuth(), opt.authMs || 12000, \"\");\n if (t) hdrs[\"Authorization\"] = \"Bearer \" + t;\n }\n var resp;\n if (opt.method === \"POST\") {\n hdrs[\"Content-Type\"] = \"application\/json\";\n resp = await withTimeout(http.Post(url, hdrs, opt.body || \"\", \"application\/json\", true), opt.ms || 15000, null);\n } else {\n resp = await withTimeout(http.Get(url, hdrs, true), opt.ms || 15000, null);\n }\n if (!resp) return { ok: false, status: 0, json: null, error: \"network\" };\n var st = respStatus(resp);\n var j = safeJson(respText(resp));\n var code = j && num(j.code, -1);\n return { ok: !!(j && code === 0), status: st, code: code, json: j, message: (j && j.message) || \"\" };\n }\n\n async function publicGet(path, ms) { return apiJson(path, { ms: ms || 15000 }); }\n\n \/\/ ---- 鉴权 ----\n async function readAuth() {\n var at = \"\", rt = \"\", user = null;\n try { at = String((await cache.get(K_AT)) || \"\"); } catch (e) {}\n try { rt = String((await cache.get(K_RT)) || \"\"); } catch (e) {}\n try { var u = await cache.get(K_USER); user = safeJson(u) || (typeof u === \"string\" && u ? { nickname: u } : null); } catch (e) {}\n return { at: at, rt: rt, user: user };\n }\n \/\/ 账号只从本机缓存读(用户在「登录」弹窗里填过才有);源码内不含任何账号\n async function readCred() {\n try {\n var o = safeJson(await cache.get(K_CRED));\n if (o) {\n var e = String(o.email || o.Email || o[\"邮箱\"] || \"\").trim();\n var p = String(o.password || o.Password || o[\"密码\"] || \"\").trim();\n if (e && p) return { email: e, password: p };\n }\n } catch (err) {}\n return null;\n }\n async function hasCred() { return !!(await readCred()); }\n async function saveAuth(d) {\n try { if (d.access_token) await cache.set(K_AT, d.access_token); } catch (e) {}\n try { if (d.refresh_token) await cache.set(K_RT, d.refresh_token); } catch (e) {}\n try { if (d.user) await cache.set(K_USER, JSON.stringify(d.user)); } catch (e) {}\n }\n async function doLogin(silent) {\n var cred = await readCred();\n if (!cred) {\n try { await flutterBridge.log(\"[九州书盟] 未配置账号:书源不含账号,请在「登录」中填写\"); } catch (e) {}\n if (!silent) { try { await flutterBridge.showLongToast(\"尚未配置账号\\n请在「书源管理 → 本源的登录」中填写九州书盟的邮箱与密码(只需填一次)\"); } catch (e) {} }\n return false;\n }\n var r = await apiJson(\"\/auth\/login\", { method: \"POST\", body: JSON.stringify({ email: cred.email, password: cred.password }), ms: 15000 });\n if (r.ok && r.json && r.json.data) {\n await saveAuth(r.json.data);\n var nick = (r.json.data.user && r.json.data.user.nickname) || \"\";\n try { await flutterBridge.log(\"[九州书盟] 登录成功:\" + nick + \" (\" + cred.email + \")\"); } catch (e) {}\n if (!silent) { try { await flutterBridge.showToast(\"✅ 登录成功:\" + (nick || cred.email)); } catch (e) {} }\n return true;\n }\n var msg = (r.json && r.json.message) || r.error || (\"HTTP \" + r.status);\n try { await flutterBridge.log(\"[九州书盟] 登录失败:\" + msg); } catch (e) {}\n if (!silent) { try { await flutterBridge.showLongToast(\"❌ 登录失败:\" + msg); } catch (e) {} }\n return false;\n }\n async function doRefresh() {\n var a = await readAuth();\n if (!a.rt) return false;\n var r = await apiJson(\"\/auth\/refresh\", { method: \"POST\", body: JSON.stringify({ refresh_token: a.rt }), ms: 15000 });\n if (r.ok && r.json && r.json.data && r.json.data.access_token) {\n await saveAuth(r.json.data);\n return true;\n }\n return false;\n }\n async function ensureAuth(force) {\n var a = await readAuth();\n if (a.at && !force) return a.at;\n if (a.rt) { if (await doRefresh()) { a = await readAuth(); if (a.at) return a.at; } }\n if (!(await hasCred())) return \"\"; \/\/ 未配账号:不发无谓的 401 请求\n if (await doLogin(true)) { a = await readAuth(); return a.at; }\n return \"\";\n }\n \/\/ 带鉴权的请求;401 时自动续期 \/ 重登一次\n async function authedJson(path, body, ms) {\n var opt = { auth: true, ms: ms || 15000 };\n if (body !== undefined && body !== null) { opt.method = \"POST\"; opt.body = JSON.stringify(body); }\n var a0 = await readAuth();\n if (!a0.at && !a0.rt && !(await hasCred())) {\n return { ok: false, status: 0, code: 0, json: null, needLogin: true, message: \"未配置账号\" };\n }\n var r = await apiJson(path, opt);\n if (r.code === 401 || r.status === 401) {\n var ok = await doRefresh();\n if (!ok) ok = await doLogin(true);\n if (ok) r = await apiJson(path, opt);\n }\n return r;\n }\n\n \/\/ ---- 每日签到 ----\n async function ensureSignin(force) {\n var today = todayKey();\n if (!force) {\n var last = \"\";\n try { last = String((await cache.get(K_SIGNIN)) || \"\"); } catch (e) {}\n if (last === today) { signinDoneMem = true; return { skipped: true, day: today }; }\n }\n var r = await authedJson(\"\/me\/signin\", {}, 12000);\n if (!r.ok) {\n var msg = r.needLogin ? \"未配置账号(请在「登录」中填写)\" : ((r.json && r.json.message) || r.error || (\"HTTP \" + r.status));\n try { await flutterBridge.log(\"[九州书盟] 签到跳过:\" + msg); } catch (e) {}\n return { ok: false, needLogin: !!r.needLogin, message: msg };\n }\n var d = (r.json && r.json.data) || {};\n try { await cache.set(K_SIGNIN, today); } catch (e) {}\n signinDoneMem = true;\n var txt = d.already\n ? (\"✅ 今日已签到(连续 \" + num(d.streak, 1) + \" 天)\")\n : (\"🎉 签到成功 +\" + num(d.awarded, 0) + \" 经验(连续 \" + num(d.streak, 1) + \" 天)\");\n txt += \" · \" + (d.level_title || \"\") + \" Lv.\" + num(d.level, 0) + \" · 经验 \" + num(d.total_exp, 0);\n try { await flutterBridge.log(\"[九州书盟] \" + txt); } catch (e) {}\n if (force) { try { await flutterBridge.showToast(txt); } catch (e) {} }\n return { ok: true, data: d, text: txt };\n }\n\n \/\/ ---- 自动签到(打开软件 → 随机延迟内悄悄完成,每天只签一次) ----\n var signinDoneMem = false; \/\/ 本进程内已确认「今日已签」,让高频入口零开销返回\n var signinPlannedAt = 0; \/\/ 本进程内上次排程时刻,用于防抖\n\n \/**\n * 纯逻辑:本次是否该排程签到、延迟多久。返回 null = 不必排程。\n * 抽出来是为了能在本地 harness 里直接断言(不必真的等 40 秒)。\n *\/\n function autoSigninPlan(now) {\n if (signinDoneMem) return null;\n now = num(now, 0) || Date.now();\n if (signinPlannedAt && (now - signinPlannedAt) < SIGNIN_REPLAN_MS) return null;\n var span = Math.max(1, SIGNIN_DELAY_MAX - SIGNIN_DELAY_MIN + 1);\n var sec = SIGNIN_DELAY_MIN + Math.floor(Math.random() * span);\n return { delaySec: sec, delayMs: sec * 1000, day: todayKey() };\n }\n\n \/**\n * 自动签到入口(发现页 \/ 搜索 \/ 点发现项 \/ 打开书籍详情 都调它)。\n * - 打开软件后必然会走到其中一个入口 → 等效于「启动时签到」\n * - 当日已签:一次内存判断即返回,无网络、无桥接开销\n * - 当日未签:排一个 5~40 秒的随机延迟,让每天签到时刻不固定\n *\/\n async function autoSignin() {\n try {\n if (signinDoneMem) return; \/\/ 快路径:零 IO\n var today = todayKey();\n var last = \"\";\n try { last = String((await cache.get(K_SIGNIN)) || \"\"); } catch (e) {}\n if (last === today) { signinDoneMem = true; return; }\n var plan = autoSigninPlan(Date.now());\n if (!plan) return;\n signinPlannedAt = Date.now();\n try { await flutterBridge.log(\"[九州书盟] 已排程自动签到:\" + plan.delaySec + \" 秒后执行(\" + today + \")\"); } catch (e) {}\n var fire = function () { try { ensureSignin(false).catch(function () {}); } catch (e) {} };\n try {\n var t = setTimeout(fire, plan.delayMs);\n if (t && typeof t.unref === \"function\") t.unref(); \/\/ 仅本地 harness:别把 Node 进程挂住\n } catch (e) { fire(); }\n } catch (e) {}\n }\n\n \/\/ ==================== bookUrl \/ chapterId ====================\n \/\/ jzsm:b:{bookCode}:{b64u(id|title|author|cover|score|category|mainCategory|status|wordCount|scoreCount)}\n \/\/ jzsm:r:{reviewCode}:{b64u(title|nick|bookTitle|bookAuthor|bookCover|bookScore|rating)}\n \/\/ jzsm:l:{listCode}:{b64u(title|owner|itemCount|intro)}\n \/\/ jzsm:h:{rank}:{b64u(title|author|cover|score|type|intro)} (百度热榜中未入库的条目)\n function metaJoin(arr) { return b64u(arr.map(function (x) { return esc(x); }).join(\"|\")); }\n function parseBookUrl(bu) {\n var parts = String(bu || \"\").split(\":\");\n if (parts.length < 3 || parts[0] !== \"jzsm\") return null;\n var f = [];\n if (parts.length > 3) f = unb64u(parts.slice(3).join(\":\")).split(\"|\");\n return { kind: parts[1], code: parts[2], f: f };\n }\n function buildMeta(kind) {\n var m = {};\n if (kind === \"b\") {\n m = { id: this[0], title: this[1], author: this[2], cover: this[3], score: this[4],\n category: this[5], mainCategory: this[6], status: this[7], wordCount: this[8], scoreCount: this[9] };\n } else if (kind === \"r\") {\n m = { title: this[0], nick: this[1], bookTitle: this[2], bookAuthor: this[3], bookCover: this[4], bookScore: this[5], rating: this[6] };\n } else if (kind === \"h\") {\n m = { title: this[0], author: this[1], cover: this[2], score: this[3], type: this[4], intro: this[5] };\n } else {\n m = { title: this[0], owner: this[1], itemCount: this[2], intro: this[3] };\n }\n return m;\n }\n function scoreLine(o) {\n var s = fmtScore(o.weighted_score);\n var n = num(o.score_count, 0);\n if (!s) return n ? (n + \" 人评分\") : \"\";\n return \"评分 \" + s + (n ? \" · \" + n + \" 人评分\" : \"\");\n }\n\n function bookToBook(o) {\n if (!o || !o.code) return null;\n var code = String(o.code);\n var cover = normCover(o.cover_url);\n var bu = \"jzsm:b:\" + code + \":\" + metaJoin([o.id, o.title, o.author, cover, o.weighted_score, o.category, o.main_category, o.status, o.word_count, o.score_count]);\n var kindLabel = [o.main_category, o.category].filter(Boolean).join(\" · \");\n var bits = [];\n if (fmtScore(o.weighted_score)) bits.push(\"评分 \" + fmtScore(o.weighted_score));\n if (num(o.score_count, 0)) bits.push(num(o.score_count, 0) + \" 人评\");\n if (o.status) bits.push(o.status);\n if (fmtWords(o.word_count)) bits.push(fmtWords(o.word_count));\n return {\n bookUrl: bu,\n name: o.title || \"\",\n author: o.author || \"\",\n kind: kindLabel,\n coverUrl: cover,\n intro: bits.join(\" · \"),\n tocUrl: bu,\n wordCount: fmtWords(o.word_count),\n type: BOOK_TYPE,\n latestChapterTitle: scoreLine(o)\n };\n }\n\n function reviewToBook(r) {\n if (!r || !r.code) return null;\n var code = String(r.code);\n var title = r.title || truncate(String(r.excerpt || \"\").replace(\/\\s+\/g, \" \"), 28) || \"(无标题书评)\";\n var bu = \"jzsm:r:\" + code + \":\" + metaJoin([title, r.user_nickname, r.book_title, r.book_author, normCover(r.book_cover_url), r.book_weighted_score, r.user_rating]);\n return {\n bookUrl: bu,\n name: \"💬 \" + title,\n author: r.user_nickname || \"\",\n kind: \"书评\" + (r.user_rating ? \" · \" + r.user_rating + \" 分\" : \"\") + (r.book_title ? \" · 《\" + r.book_title + \"》\" : \"\"),\n coverUrl: normCover(r.book_cover_url),\n intro: truncate(String(r.excerpt || \"\").replace(\/\\s+\/g, \" \"), 120),\n tocUrl: bu,\n wordCount: \"\",\n type: BOOK_TYPE,\n latestChapterTitle: (r.user_rating ? r.user_rating + \" 分\" : \"\") + (fmtDate(r.created_at) ? \" · \" + fmtDate(r.created_at) : \"\")\n };\n }\n\n function booklistToBook(b) {\n if (!b || !b.code) return null;\n var code = String(b.code);\n var bu = \"jzsm:l:\" + code + \":\" + metaJoin([b.title, b.user_nickname, b.item_count, b.intro]);\n return {\n bookUrl: bu,\n name: \"📋 \" + (b.title || \"\"),\n author: b.user_nickname || \"\",\n kind: \"书单 · \" + num(b.item_count, 0) + \" 本\",\n coverUrl: \"\",\n intro: truncate(String(b.intro || \"\").replace(\/<[^>]*>\/g, \" \").replace(\/\\s+\/g, \" \"), 120),\n tocUrl: bu,\n wordCount: \"\",\n type: BOOK_TYPE,\n latestChapterTitle: num(b.item_count, 0) + \" 本 · \" + fmtDate(b.updated_at || b.created_at)\n };\n }\n\n \/\/ ==================== 索引预取(书评面板共用) ====================\n async function fetchReviews(bookCode, pages, size) {\n var out = [], total = 0;\n size = size || REVIEW_PAGE_SIZE;\n for (var i = 1; i <= pages; i++) {\n var r = await publicGet(\"\/books\/\" + encodeURIComponent(bookCode) + \"\/reviews?page=\" + i + \"&size=\" + size, 15000);\n var d = r.json && r.json.data;\n if (!d || !d.list || !d.list.length) break;\n total = num(d.total, total);\n out = out.concat(d.list);\n if (total && out.length >= Math.min(total, pages * size)) break;\n }\n return { list: out, total: total };\n }\n\n async function fetchComments(targetType, targetCode, pages) {\n var out = [], total = 0;\n for (var i = 1; i <= (pages || 1); i++) {\n var r = await publicGet(\"\/comments?target_type=\" + encodeURIComponent(targetType) + \"&target_code=\" + encodeURIComponent(targetCode) + \"&page=\" + i + \"&size=20\", 12000);\n var d = r.json && r.json.data;\n if (!d || !d.list || !d.list.length) break;\n total = num(d.total, total);\n out = out.concat(d.list);\n if (total && out.length >= total) break;\n if (out.length >= 60) break;\n }\n return out;\n }\n\n \/\/ ==================== 核心函数 ====================\n\n \/\/ ---- 搜索(上游无分页:page>=2 直接返回空以终止翻页)----\n async function search(key, page, env) {\n try { autoSignin(); } catch (ex0) {} \/\/ 自动签到触发点(打开软件后搜索即触发)\n key = String(key == null ? \"\" : key).trim();\n page = num(page, 1);\n if (!key || page >= 2) return \"[]\";\n var r = await publicGet(\"\/search?q=\" + encodeURIComponent(key) + \"&limit=10\", 15000);\n var d = r.json && r.json.data;\n var books = (d && d.books) || [];\n var out = [];\n for (var i = 0; i < books.length; i++) {\n var b = bookToBook(books[i]);\n if (b) out.push(b);\n }\n if (!out.length) {\n try { await flutterBridge.log(\"[九州书盟] 搜索「\" + key + \"」无结果\"); } catch (e) {}\n }\n return JSON.stringify(out);\n }\n\n \/\/ ---- 详情 ----\n async function info(bookurl) {\n \/\/ 自动签到(打开软件后打开任一本书即触发;随机延迟、每天一次,不阻塞详情加载)\n try { autoSignin(); } catch (ex0) {}\n var p = parseBookUrl(bookurl);\n if (!p) return JSON.stringify({ bookUrl: bookurl });\n\n \/\/ 百度热榜中尚未入库的条目\n if (p.kind === \"h\") {\n var hm = buildMeta.call(p.f, \"h\");\n var hh = \"@html:\";\n hh += \"<b>\" + escHtml(hm.title) + \"<\/b>\" + (hm.author ? \" · \" + escHtml(hm.author) : \"\") + \"<br\/>\";\n hh += \"百度小说热榜第 \" + escHtml(p.code) + \" 名\" + (hm.type ? \" · \" + escHtml(hm.type) : \"\") + (hm.score ? \" · 评分 \" + escHtml(hm.score) : \"\") + \"<br\/>\";\n hh += \"<br\/>该条目尚未被九州书盟收录,因此没有站内评分与书评。<br\/>\";\n if (hm.intro) hh += \"<br\/><b>简介<\/b><br\/>\" + escHtml(hm.intro) + \"<br\/>\";\n hh += \"<br\/><button>🔍 站内搜索这本书@onclick:flutterBridge.searchbook('\" + escAttr(hm.title) + \"')<\/button>\";\n return JSON.stringify({\n bookUrl: bookurl, name: hm.title || \"\", author: hm.author || \"\",\n kind: \"热榜 · \" + (hm.type || \"\"), coverUrl: normCover(hm.cover), intro: hh,\n tocUrl: bookurl, wordCount: \"\", type: BOOK_TYPE,\n latestChapterTitle: \"百度热榜第 \" + escHtml(p.code) + \" 名\"\n });\n }\n\n if (p.kind === \"r\") {\n var m = buildMeta.call(p.f, \"r\");\n var bu0 = \"jzsm:r:\" + p.code + \":\" + metaJoin([m.title, m.nick, m.bookTitle, m.bookAuthor, m.bookCover, m.bookScore, m.rating]);\n var rr = await publicGet(\"\/reviews\/\" + encodeURIComponent(p.code), 15000);\n var rd = rr.json && rr.json.data;\n var html = \"@html:\";\n if (rd) {\n html += \"<b>\" + escHtml(rd.book_title || m.bookTitle || \"\") + \"<\/b> 的书评<br\/>\" +\n \"作者:\" + escHtml(rd.user_nickname || m.nick || \"—\") + (rd.user_level_title ? \"(\" + escHtml(rd.user_level_title) + \")\" : \"\") + \"<br\/>\" +\n (rd.user_rating ? \"评分:<b>\" + num(rd.user_rating, 0) + \" 分<\/b><br\/>\" : \"\") +\n \"时间:\" + (fmtDate(rd.created_at) || \"—\") + \"<br\/>\" +\n \"👍 \" + num(rd.like_count, 0) + \" · ⭐ \" + num(rd.favorite_count, 0) + \" · 💬 \" + num(rd.comment_count, 0) + \"<br\/>\";\n }\n html += \"<button>🌐 在网页版查看@onclick:flutterBridge.startBrowser('\" + WEB + \"\/reviews\/\" + escAttr(p.code) + \"','书评','{}')<\/button>\";\n if (m.bookTitle) {\n html += \"<button>🔍 搜索这本书@onclick:flutterBridge.searchbook('\" + escAttr(m.bookTitle) + \"')<\/button>\";\n }\n return JSON.stringify({\n bookUrl: bookurl, name: \"💬 \" + (m.title || (rd && rd.title) || \"书评\"),\n author: (rd && rd.user_nickname) || m.nick || \"\",\n kind: \"书评\" + (num((rd && rd.user_rating) || m.rating, 0) ? \" · \" + num((rd && rd.user_rating) || m.rating, 0) + \" 分\" : \"\"),\n coverUrl: normCover(m.bookCover), intro: html, tocUrl: bookurl,\n wordCount: \"\", type: BOOK_TYPE,\n latestChapterTitle: m.bookTitle ? (\"评《\" + m.bookTitle + \"》\") : \"\"\n });\n }\n\n if (p.kind === \"l\") {\n var lm = buildMeta.call(p.f, \"l\");\n var bl = (await publicGet(\"\/booklists\/\" + encodeURIComponent(p.code), 15000)).json;\n var bd = bl && bl.data;\n var lhtml = \"@html:\";\n if (bd) {\n lhtml += \"<b>\" + escHtml(bd.title || lm.title || \"\") + \"<\/b><br\/>\" +\n \"书单主:\" + escHtml(bd.user_nickname || lm.owner || \"—\") + \"<br\/>\" +\n \"收录:\" + num(bd.item_count, 0) + \" 本\" +\n (bd.main_category ? \" · \" + escHtml(bd.main_category) : \"\") + \"<br\/>\" +\n (Array.isArray(bd.tags) && bd.tags.length ? \"标签:\" + escHtml(bd.tags.join(\" \/ \")) + \"<br\/>\" : \"\") +\n \"👍 \" + num(bd.like_count, 0) + \" · ⭐ \" + num(bd.favorite_count, 0) + \" · 💬 \" + num(bd.comment_count, 0) + \"<br\/>\" +\n (bd.intro ? \"<br\/>\" + escHtml(String(bd.intro).replace(\/<[^>]*>\/g, \" \").replace(\/\\s+\/g, \" \").trim()) + \"<br\/>\" : \"\");\n }\n lhtml += \"<button>🌐 在网页版查看@onclick:flutterBridge.startBrowser('\" + WEB + \"\/booklists\/\" + escAttr(p.code) + \"','书单','{}')<\/button>\";\n return JSON.stringify({\n bookUrl: bookurl, name: \"📋 \" + ((bd && bd.title) || lm.title || \"书单\"),\n author: (bd && bd.user_nickname) || lm.owner || \"\",\n kind: \"书单 · \" + num(bd && bd.item_count, 0) + \" 本\",\n coverUrl: \"\", intro: lhtml, tocUrl: bookurl, wordCount: \"\", type: BOOK_TYPE,\n latestChapterTitle: num(bd && bd.item_count, 0) + \" 本\"\n });\n }\n\n \/\/ kind === \"b\"\n var bm = buildMeta.call(p.f, \"b\");\n var rg = await publicGet(\"\/books\/\" + encodeURIComponent(p.code), 15000);\n var d = rg.json && rg.json.data;\n var o = {\n id: (d && d.id) || bm.id, code: p.code, title: (d && d.title) || bm.title, author: (d && d.author) || bm.author,\n cover_url: (d && d.cover_url) || bm.cover, weighted_score: (d && d.weighted_score) || bm.score,\n category: (d && d.category) || bm.category, main_category: (d && d.main_category) || bm.mainCategory,\n status: (d && d.status) || bm.status, word_count: (d && d.word_count) || bm.wordCount,\n score_count: (d && d.score_count) || bm.scoreCount\n };\n var book = bookToBook(o);\n book.bookUrl = bookurl; \/\/ 必须原样回传\n book.tocUrl = bookurl;\n book.intro = bookIntroHtml(d, o, p.code);\n book.latestChapterTitle = scoreLine(o) + (o.status ? \" · \" + o.status : \"\");\n return JSON.stringify(book);\n }\n\n function bookIntroHtml(d, o, code) {\n var h = \"@html:\";\n h += \"<b>\" + escHtml(o.title) + \"<\/b>\";\n if (o.author) h += \" · \" + escHtml(o.author);\n h += \"<br\/>\";\n var s = fmtScore(d && d.weighted_score !== undefined ? d.weighted_score : o.weighted_score);\n var mean = d && d.mean_score !== undefined ? fmtScore(d.mean_score) : \"\";\n var n = num((d && d.score_count) || o.score_count, 0);\n if (s) h += \"加权评分:<b>\" + s + \"<\/b>\" + (mean ? \"(均分 \" + mean + \")\" : \"\") + (n ? \" · \" + n + \" 人评分\" : \"\") + \"<br\/>\";\n var kindLabel = [o.main_category, o.category].filter(Boolean).join(\" · \");\n if (kindLabel) h += \"分类:\" + escHtml(kindLabel) + \"<br\/>\";\n if (o.status) h += \"状态:\" + escHtml(o.status) + \"<br\/>\";\n if (fmtWords(o.word_count)) h += \"字数:\" + fmtWords(o.word_count) + \"<br\/>\";\n if (d && Array.isArray(d.tags) && d.tags.length) h += \"标签:\" + escHtml(d.tags.join(\" \/ \")) + \"<br\/>\";\n if (d && d.source_url) h += \"原始页:\" + escHtml(String(d.source_url).slice(0, 90)) + \"<br\/>\";\n var intro = (d && d.intro) || \"\";\n if (intro) h += \"<br\/><b>简介<\/b><br\/>\" + escHtml(intro.replace(\/<[^>]*>\/g, \"\")).replace(\/\\n\/g, \"<br\/>\") + \"<br\/>\";\n h += \"<br\/>\";\n h += \"<button>💬 书评面板@onclick:openReviewPanel('\" + escAttr(code) + \"')<\/button>\";\n h += \"<button>🌐 网页版@onclick:flutterBridge.startBrowser('\" + WEB + \"\/books\/\" + escAttr(code) + \"','\" + escAttr(o.title) + \"','{}')<\/button>\";\n h += \"<button>🔍 找同名书@onclick:flutterBridge.searchbook('\" + escAttr(o.title) + \"')<\/button>\";\n return h;\n }\n\n \/\/ ---- 目录 ----\n async function chapter(tocUrl, bookurl) {\n var p = parseBookUrl(bookurl || tocUrl);\n if (!p) return \"[]\";\n\n \/\/ 百度热榜未入库条目:只有一章说明\n if (p.kind === \"h\") {\n return JSON.stringify([{\n name: \"ℹ️ 该条目未入库,点此查看说明\", chapterId: \"z:hot\", index: 0,\n isPay: false, isVip: false, isVolume: false, tag: \"说明\"\n }]);\n }\n\n \/\/ 书评书:只有一章\n if (p.kind === \"r\") {\n var m = buildMeta.call(p.f, \"r\");\n return JSON.stringify([{\n name: \"💬 查看书评全文\" + (m.bookTitle ? \"(评《\" + m.bookTitle + \"》)\" : \"\"),\n chapterId: \"r:\" + p.code, index: 0, isPay: false, isVip: false, isVolume: false,\n tag: m.nick || \"\"\n }]);\n }\n\n \/\/ 书单书:一章 = 一本书\n if (p.kind === \"l\") {\n var bl = (await publicGet(\"\/booklists\/\" + encodeURIComponent(p.code), 20000)).json;\n var bd = bl && bl.data;\n var items = (bd && bd.items) || [];\n if (!items.length) {\n return JSON.stringify([{ name: \"⚠️ 这个书单没有取到条目(可能已被作者删除或设为私密)\", chapterId: \"z:nolist\", index: 0, isPay: false, isVip: false, isVolume: false, tag: \"诊断\" }]);\n }\n var lchs = [{ name: \"📌 书单说明\", chapterId: \"li:\" + p.code, index: 0, isPay: false, isVip: false, isVolume: true, tag: \"\" }];\n for (var i = 0; i < items.length; i++) {\n var it = items[i];\n if (!it || !it.book_code) continue;\n var idx = lchs.length;\n lchs.push({\n name: (idx < 10 ? \"0\" + idx : String(idx)) + \". \" + (it.title || \"\") + (it.author ? \" · \" + it.author : \"\"),\n chapterId: \"lb:\" + it.book_code,\n index: idx, isPay: false, isVip: false, isVolume: false,\n tag: (fmtScore(it.weighted_score) ? fmtScore(it.weighted_score) + \" 分\" : \"\") + (it.category ? \" · \" + it.category : \"\")\n });\n }\n return JSON.stringify(lchs);\n }\n\n \/\/ 书籍:卷1 资料 \/ 卷2 书评 \/ 卷3 相关\n var rg = await publicGet(\"\/books\/\" + encodeURIComponent(p.code), 15000);\n var d = rg.json && rg.json.data;\n var bm = buildMeta.call(p.f, \"b\");\n var title = (d && d.title) || bm.title || \"\";\n var rv = await fetchReviews(p.code, MAX_REVIEW_PAGES, REVIEW_PAGE_SIZE);\n\n var chs = [];\n chs.push({ name: \"📌 书籍资料\", chapterId: \"z:vol\", index: chs.length, isPay: false, isVip: false, isVolume: true, tag: \"\" });\n chs.push({ name: \"① 基本信息与简介\", chapterId: \"i:\" + p.code, index: chs.length, isPay: false, isVip: false, isVolume: false, tag: \"资料\" });\n chs.push({ name: \"② 评分分布\", chapterId: \"d:\" + p.code, index: chs.length, isPay: false, isVip: false, isVolume: false, tag: (d && d.score_count) ? (num(d.score_count, 0) + \" 人评分\") : \"评分\" });\n\n var total = num(rv.total, 0);\n chs.push({ name: \"📝 书评(共 \" + (total || rv.list.length) + \" 篇)\", chapterId: \"z:vol2\", index: chs.length, isPay: false, isVip: false, isVolume: true, tag: \"\" });\n if (!rv.list.length) {\n chs.push({ name: \"⚠️ 这本书还没有书评\", chapterId: \"z:noreview\", index: chs.length, isPay: false, isVip: false, isVolume: false, tag: \"诊断\" });\n } else {\n for (var k = 0; k < rv.list.length; k++) {\n var r = rv.list[k] || {};\n var rt = r.title || truncate(String(r.excerpt || \"\").replace(\/\\s+\/g, \" \"), 30) || \"(无标题书评)\";\n chs.push({\n name: (num(r.user_rating, 0) ? \"[\" + r.user_rating + \"分] \" : \"\") + rt,\n chapterId: \"r:\" + (r.code || \"\"),\n index: chs.length, isPay: false, isVip: false, isVolume: false,\n tag: (r.user_nickname || \"\") + (fmtDate(r.created_at) ? \" · \" + fmtDate(r.created_at) : \"\") +\n (num(r.like_count, 0) ? \" · 👍\" + num(r.like_count, 0) : \"\")\n });\n }\n }\n if (total > rv.list.length) {\n chs.push({\n name: \"(本书共 \" + total + \" 篇书评,此处已载入前 \" + rv.list.length + \" 篇;完整列表请用「书评面板」)\",\n chapterId: \"z:more\", index: chs.length, isPay: false, isVip: false, isVolume: true, tag: \"\"\n });\n }\n\n chs.push({ name: \"🔗 相关与推荐\", chapterId: \"z:vol3\", index: chs.length, isPay: false, isVip: false, isVolume: true, tag: \"\" });\n chs.push({ name: \"③ 相似作品推荐\", chapterId: \"s:\" + p.code, index: chs.length, isPay: false, isVip: false, isVolume: false, tag: \"推荐\" });\n chs.push({ name: \"④ 收录此书的书单\", chapterId: \"l:\" + p.code, index: chs.length, isPay: false, isVip: false, isVolume: false, tag: \"书单\" });\n chs.push({ name: \"⑤ 在网页版打开\", chapterId: \"w:\" + p.code, index: chs.length, isPay: false, isVip: false, isVolume: false, tag: \"网页\" });\n return JSON.stringify(chs);\n }\n\n \/\/ ---- 正文 ----\n async function content(url, bookurl) {\n url = String(url || \"\");\n var p = parseBookUrl(bookurl);\n\n if (url.indexOf(\"z:\") === 0) {\n var z = url.slice(2);\n if (z === \"noreview\") return \"这本书暂时还没有书评。\\n\\n可以在「书评面板」中查看评分分布与书籍资料,或到网页版发表第一篇书评。\";\n if (z === \"nolist\") return \"这个书单没有取到条目。\\n\\n可能已被作者删除、设为私密,或接口临时不可用。\";\n if (z === \"hot\") return \"该条目来自百度小说热榜,但尚未被九州书盟收录,因此没有站内评分与书评。\\n\\n可返回详情页,点「🔍 站内搜索这本书」查找同名书籍。\";\n if (z === \"more\") return \"已载入的书评数量达到上限。\\n\\n请点击详情页的「💬 书评面板」查看更完整的书评列表。\";\n return \"(分组标题)\";\n }\n\n \/\/ 书评正文\n if (url.indexOf(\"r:\") === 0) {\n var rc = url.slice(2);\n if (!rc) return \"书评标识缺失。\";\n var rr = await publicGet(\"\/reviews\/\" + encodeURIComponent(rc), 20000);\n var d = rr.json && rr.json.data;\n if (!d) return \"❌ 未能取到这篇书评。\\n\\n可能已被删除,或接口临时不可用,请稍后重试。\";\n var head = \"\";\n head += (d.book_title ? \"《\" + d.book_title + \"》\" : \"\") + (d.user_nickname ? \" 作者:\" + d.user_nickname : \"\") + \"\\n\";\n head += (num(d.user_rating, 0) ? \"评分:\" + num(d.user_rating, 0) + \" 分 \" : \"\") +\n (fmtDate(d.created_at) ? \"时间:\" + fmtDate(d.created_at) + \" \" : \"\") +\n \"👍\" + num(d.like_count, 0) + \" ⭐\" + num(d.favorite_count, 0) + \" 💬\" + num(d.comment_count, 0) + \"\\n\";\n head += \"————————————————\\n\";\n if (d.title) head += \"【\" + d.title + \"】\\n\";\n var body = mdToText(d.content_md || d.content_html || \"\");\n if (!body) body = \"(本篇书评没有正文内容)\";\n return head + body;\n }\n\n \/\/ 书单内某一本书的资料\n if (url.indexOf(\"lb:\") === 0) {\n var lbc = url.slice(3);\n var bg = await publicGet(\"\/books\/\" + encodeURIComponent(lbc), 15000);\n var bd2 = bg.json && bg.json.data;\n if (!bd2) return \"❌ 未能取到这本书的资料。\";\n return bookDetailText(bd2);\n }\n \/\/ 书单说明\n if (url.indexOf(\"li:\") === 0) {\n var blc = url.slice(3);\n var blg = (await publicGet(\"\/booklists\/\" + encodeURIComponent(blc), 20000)).json;\n var bld = blg && blg.data;\n if (!bld) return \"❌ 未能取到书单说明。\";\n var t = \"《\" + (bld.title || \"\") + \"》\\n书单主:\" + (bld.user_nickname || \"—\") + \"\\n收录:\" + num(bld.item_count, 0) + \" 本\\n\";\n if (bld.main_category) t += \"分类:\" + bld.main_category + \"\\n\";\n if (Array.isArray(bld.tags) && bld.tags.length) t += \"标签:\" + bld.tags.join(\" \/ \") + \"\\n\";\n t += \"👍\" + num(bld.like_count, 0) + \" ⭐\" + num(bld.favorite_count, 0) + \" 💬\" + num(bld.comment_count, 0) + \"\\n\";\n var bi = String(bld.intro || \"\").replace(\/<[^>]*>\/g, \" \").replace(\/ \/g, \" \").replace(\/\\s+\/g, \" \").trim();\n if (bi) t += \"————————————————\\n\" + bi;\n return t;\n }\n\n var code = p ? p.code : \"\";\n\n \/\/ 书籍资料\n if (url.indexOf(\"i:\") === 0) {\n var code1 = url.slice(2) || code;\n var g1 = await publicGet(\"\/books\/\" + encodeURIComponent(code1), 15000);\n if (!g1.json || !g1.json.data) return \"❌ 未能取到书籍资料,请检查网络或稍后重试。\";\n return bookDetailText(g1.json.data);\n }\n\n \/\/ 评分分布\n if (url.indexOf(\"d:\") === 0) {\n var code2 = url.slice(2) || code;\n var g2 = await publicGet(\"\/books\/\" + encodeURIComponent(code2), 15000);\n if (!g2.json || !g2.json.data) return \"❌ 未能取到评分分布。\";\n return ratingDistText(g2.json.data);\n }\n\n \/\/ 相似推荐\n if (url.indexOf(\"s:\") === 0) {\n var bm = p ? buildMeta.call(p.f, \"b\") : { id: \"\", title: \"\" };\n var bid = bm.id;\n if (!bid) {\n var gb = await publicGet(\"\/books\/\" + encodeURIComponent(p ? p.code : url.slice(2)), 15000);\n bid = gb.json && gb.json.data && gb.json.data.id;\n }\n if (!bid) return \"❌ 无法确定书籍 ID,未能取到相似推荐。\";\n var sim = await publicGet(\"\/recommend\/books\/similar?id=\" + encodeURIComponent(bid), 15000);\n var list = (sim.json && sim.json.data && sim.json.data.list) || [];\n if (!list.length) return \"暂时没有相似推荐。\";\n var st = \"相似作品推荐(可在搜索框输入书名找到):\\n\";\n for (var i = 0; i < list.length; i++) {\n var o = list[i];\n st += \"\\n\" + (i + 1) + \". 《\" + (o.title || \"\") + \"》\" + (o.author ? \" · \" + o.author : \"\") + \"\\n\" +\n \" \" + scoreLine(o) + (o.main_category ? \" · \" + o.main_category : \"\") + \"\\n\";\n }\n return st;\n }\n\n \/\/ 收录此书的书单\n if (url.indexOf(\"l:\") === 0) {\n var code3 = url.slice(2) || code;\n var lb = await publicGet(\"\/books\/\" + encodeURIComponent(code3) + \"\/booklists\", 15000);\n var bl3 = (lb.json && lb.json.data && lb.json.data.list) || [];\n if (!bl3.length) return \"暂时没有书单收录这本书。\";\n var lt = \"收录这本书的书单:\\n\";\n for (var j = 0; j < bl3.length; j++) {\n var b3 = bl3[j];\n lt += \"\\n· 《\" + (b3.title || \"\") + \"》 \" + (b3.user_nickname || \"\") + \"\\n\" +\n \" 收录 \" + num(b3.item_count, 0) + \" 本 · 👍\" + num(b3.like_count, 0) + \"\\n\";\n }\n return lt;\n }\n\n \/\/ 网页版\n if (url.indexOf(\"w:\") === 0) {\n var wc = url.slice(2) || code;\n try { await flutterBridge.startBrowser(WEB + \"\/books\/\" + wc, \"九州书盟 · 网页版\", \"{}\"); } catch (e) {}\n return \"已尝试在网页版中打开这本书:\\n\\n\" + WEB + \"\/books\/\" + wc +\n \"\\n\\n如果没有自动跳转,可长按复制上面的地址,在浏览器中打开。\";\n }\n\n return \"无法识别的章节(\" + url + \")。\";\n }\n\n function bookDetailText(d) {\n var t = \"\";\n t += \"《\" + (d.title || \"\") + \"》\\n\";\n if (d.author) t += \"作者:\" + d.author + \"\\n\";\n var s = fmtScore(d.weighted_score);\n if (s) t += \"加权评分:\" + s + (d.mean_score !== undefined ? \"(均分 \" + fmtScore(d.mean_score) + \")\" : \"\") + (num(d.score_count, 0) ? \" · \" + num(d.score_count, 0) + \" 人评分\" : \"\") + \"\\n\";\n var kl = [d.main_category, d.category].filter(Boolean).join(\" · \");\n if (kl) t += \"分类:\" + kl + \"\\n\";\n if (d.status) t += \"状态:\" + d.status + \"\\n\";\n if (fmtWords(d.word_count)) t += \"字数:\" + fmtWords(d.word_count) + \"\\n\";\n if (Array.isArray(d.tags) && d.tags.length) t += \"标签:\" + d.tags.join(\" \/ \") + \"\\n\";\n if (d.source_url) t += \"原始页:\" + d.source_url + \"\\n\";\n t += \"网页版:\" + WEB + \"\/books\/\" + (d.code || \"\") + \"\\n\";\n var intro = String(d.intro || \"\").replace(\/<[^>]*>\/g, \"\").replace(\/\\s+\/g, \" \").trim();\n if (intro) t += \"————————————————\\n简介:\\n\" + intro;\n return t;\n }\n\n function ratingDistText(d) {\n var dist = Array.isArray(d.rating_dist) ? d.rating_dist : [];\n var labels = [\"1-2 分\", \"3-4 分\", \"5-6 分\", \"7-8 分\", \"9-10 分\"];\n var total = 0;\n for (var i = 0; i < dist.length; i++) total += num(dist[i], 0);\n var t = \"《\" + (d.title || \"\") + \"》评分分布\\n\";\n t += \"加权评分 \" + (fmtScore(d.weighted_score) || \"—\") + \" 均分 \" + (fmtScore(d.mean_score) || \"—\") + \" 共 \" + total + \" 人评分\\n\";\n t += \"————————————————\\n\";\n if (!total) return t + \"暂无评分数据。\";\n var max = 1;\n for (var k = 0; k < dist.length; k++) max = Math.max(max, num(dist[k], 0));\n var order = [];\n for (var q = dist.length - 1; q >= 0; q--) order.push(q);\n for (var n = 0; n < order.length; n++) {\n var idx = order[n];\n var v = num(dist[idx], 0);\n var pct = total ? Math.round(v * 1000 \/ total) \/ 10 : 0;\n var bars = Math.round(v * 20 \/ max);\n var bar = \"\";\n for (var b = 0; b < bars; b++) bar += \"█\";\n t += labels[idx] + \" \" + (v < 100 ? \" \" : \"\") + (v < 10 ? \" \" : \"\") + v + \" \" + bar + \" \" + pct + \"%\\n\";\n }\n return t;\n }\n\n \/\/ ---- 发现 ----\n async function getfinds() {\n \/\/ 自动签到主入口(App 启动通常停在发现页)+ 随机延迟执行;失败不影响浏览,故不阻塞\n try { autoSignin(); } catch (ex0) {}\n var st = \"\";\n try {\n var u = JSON.parse(String((await cache.get(K_USER)) || \"null\"));\n if (u && u.nickname) st = \" · \" + u.nickname;\n } catch (e) {}\n\n var f = [];\n\n \/\/ 榜单(一次取全,不分页)\n for (var i = 0; i < RANKINGS.length; i++) {\n f.push({ title: RANKINGS[i].label, url: \"rk:\" + RANKINGS[i].key, type: 0, width: 0 });\n }\n\n \/\/ 主分类(分页)\n for (var c = 0; c < MAIN_CATEGORIES.length; c++) {\n f.push({ title: \"📚 \" + MAIN_CATEGORIES[c], url: \"cat:\" + b64u(MAIN_CATEGORIES[c]) + \":page{{page}}\", type: 0, width: 0 });\n }\n\n \/\/ 社区\n f.push({ title: \"💬 书评广场\", url: \"rv:page{{page}}\", type: 0, width: 0 });\n f.push({ title: \"📋 书单广场\", url: \"bl:page{{page}}\", type: 0, width: 0 });\n f.push({ title: \"📈 百度小说热榜\", url: \"hot\", type: 0, width: 0 });\n f.push({ title: \"✨ 编辑推荐\", url: \"rec\", type: 0, width: 0 });\n\n \/\/ 热标签\n try {\n var tg = await withTimeout(publicGet(\"\/tags\/cloud?target_type=book\", 12000), 12000, null);\n var tags = (tg && tg.json && tg.json.data && tg.json.data.tags) || [];\n for (var t = 0; t < Math.min(tags.length, TOP_TAG_COUNT); t++) {\n if (!tags[t] || !tags[t].tag) continue;\n f.push({ title: \"🏷 \" + tags[t].tag, url: \"tag:\" + b64u(tags[t].tag) + \":page{{page}}\", type: 0, width: 0 });\n }\n } catch (e) {}\n\n \/\/ 书单标签\n try {\n var bt = await withTimeout(publicGet(\"\/booklists\/meta\/tags\", 12000), 12000, null);\n var btags = (bt && bt.json && bt.json.data && bt.json.data.tags) || [];\n for (var bt2 = 0; bt2 < btags.length; bt2++) {\n if (!btags[bt2]) continue;\n f.push({ title: \"📋 书单 · \" + btags[bt2], url: \"blt:\" + b64u(btags[bt2]) + \":page{{page}}\", type: 0, width: 0 });\n }\n } catch (e) {}\n\n \/\/ 账号\n var acct = \"👤 账号:签到 \/ 登录\" + st;\n f.push({ title: acct, url: \"\", type: 4, action: \"showAccountPanel()\", width: 0 });\n\n return JSON.stringify(f);\n }\n\n async function find(url, page) {\n try { autoSignin(); } catch (ex0) {} \/\/ 自动签到触发点(点发现项即触发)\n url = String(url || \"\");\n page = num(page, 1);\n if (page > MAX_FIND_PAGE) return \"[]\";\n var r, d, list, out, i;\n\n \/\/ 分类浏览(上游可翻页;页码越界不返回空,故自行设上限)\n if (url.indexOf(\"cat:\") === 0) {\n var main = unb64u(url.split(\":\")[1] || \"\");\n if (!main) return \"[]\";\n r = await publicGet(\"\/books?main_category=\" + encodeURIComponent(main) + \"&page=\" + page + \"&size=\" + LIST_PAGE_SIZE, 15000);\n list = (r.json && r.json.data && r.json.data.list) || [];\n if (!list.length) return \"[]\";\n out = [];\n for (i = 0; i < list.length; i++) { var b1 = bookToBook(list[i]); if (b1) out.push(b1); }\n return JSON.stringify(out);\n }\n\n \/\/ 标签浏览\n if (url.indexOf(\"tag:\") === 0) {\n var tag = unb64u(url.split(\":\")[1] || \"\");\n if (!tag) return \"[]\";\n r = await publicGet(\"\/books?tag=\" + encodeURIComponent(tag) + \"&page=\" + page + \"&size=\" + LIST_PAGE_SIZE, 15000);\n list = (r.json && r.json.data && r.json.data.list) || [];\n if (!list.length) return \"[]\";\n out = [];\n for (i = 0; i < list.length; i++) { var b2 = bookToBook(list[i]); if (b2) out.push(b2); }\n return JSON.stringify(out);\n }\n\n \/\/ 榜单(一次全给,不分页)\n if (url.indexOf(\"rk:\") === 0) {\n if (page >= 2) return \"[]\";\n var key = url.slice(3);\n r = await publicGet(\"\/rankings\/\" + encodeURIComponent(key), 15000);\n list = (r.json && r.json.data && r.json.data.list) || [];\n if (!list.length) {\n return JSON.stringify([{\n bookUrl: \"jzsm:b:0:\" + metaJoin([\"\", \"⚠️ 榜单暂时不可用\", \"\", \"\", \"\", \"\", \"\", \"\", \"\", \"\"]),\n name: \"⚠️ 榜单暂时不可用\", author: \"\", kind: \"诊断\", coverUrl: \"\",\n intro: \"该榜单接口暂时没有返回数据,请稍后重试。\", tocUrl: \"jzsm:b:0:\" + metaJoin([\"\", \"诊断\"]),\n wordCount: \"\", type: BOOK_TYPE, latestChapterTitle: \"\"\n }]);\n }\n out = [];\n for (i = 0; i < list.length; i++) { var b3 = bookToBook(list[i]); if (b3) out.push(b3); }\n return JSON.stringify(out);\n }\n\n \/\/ 书评广场(长评 → 每条书评当一本书)\n if (url.indexOf(\"rv:\") === 0) {\n r = await publicGet(\"\/reviews?page=\" + page + \"&size=20\", 15000);\n list = (r.json && r.json.data && r.json.data.list) || [];\n if (!list.length) return \"[]\";\n out = [];\n for (i = 0; i < list.length; i++) { var rb = reviewToBook(list[i]); if (rb) out.push(rb); }\n return JSON.stringify(out);\n }\n\n \/\/ 书单广场\n if (url.indexOf(\"blt:\") === 0) {\n var btag = unb64u(url.split(\":\")[1] || \"\");\n var qs = btag ? (\"?tag=\" + encodeURIComponent(btag) + \"&page=\" + page) : (\"?page=\" + page);\n r = await publicGet(\"\/booklists\" + qs, 15000);\n list = (r.json && r.json.data && r.json.data.list) || [];\n if (!list.length) return \"[]\";\n out = [];\n for (i = 0; i < list.length; i++) { var lb1 = booklistToBook(list[i]); if (lb1) out.push(lb1); }\n return JSON.stringify(out);\n }\n if (url.indexOf(\"bl:\") === 0) {\n r = await publicGet(\"\/booklists?page=\" + page, 15000);\n list = (r.json && r.json.data && r.json.data.list) || [];\n if (!list.length) return \"[]\";\n out = [];\n for (i = 0; i < list.length; i++) { var lb2 = booklistToBook(list[i]); if (lb2) out.push(lb2); }\n return JSON.stringify(out);\n }\n\n \/\/ 百度小说热榜(条目自带 book_code,可直接映射为真实书籍)\n if (url === \"hot\") {\n if (page >= 2) return \"[]\";\n r = await publicGet(\"\/hot\/baidu_novel\", 15000);\n list = (r.json && r.json.data && r.json.data.list) || [];\n out = [];\n for (i = 0; i < list.length; i++) {\n var h = list[i];\n if (!h) continue;\n if (h.book_code) {\n var hb = bookToBook({\n id: h.book_id, code: h.book_code, title: h.title, author: h.author,\n cover_url: h.cover_url, weighted_score: h.weighted_score, category: h.type,\n main_category: \"\", status: \"\", word_count: 0, score_count: h.score_count\n });\n if (hb) { hb.latestChapterTitle = \"热榜第 \" + num(h.rank, i + 1) + \" 名\" + (hb.latestChapterTitle ? \" · \" + hb.latestChapterTitle : \"\"); out.push(hb); }\n } else {\n var hrank = String(num(h.rank, i + 1));\n var hbu = \"jzsm:h:\" + hrank + \":\" + metaJoin([h.title, h.author, normCover(h.cover_url), fmtScore(h.weighted_score), h.type, truncate(String(h.intro || \"\").replace(\/\\s+\/g, \" \"), 200)]);\n out.push({\n bookUrl: hbu, name: h.title || \"\", author: h.author || \"\", kind: \"热榜 · \" + (h.type || \"\"),\n coverUrl: normCover(h.cover_url), intro: truncate(String(h.intro || \"\").replace(\/\\s+\/g, \" \"), 120),\n tocUrl: hbu, wordCount: \"\", type: BOOK_TYPE, latestChapterTitle: \"热榜第 \" + hrank + \" 名\"\n });\n }\n }\n return JSON.stringify(out);\n }\n\n \/\/ 编辑推荐\n if (url === \"rec\") {\n if (page >= 2) return \"[]\";\n r = await publicGet(\"\/recommend\/books\", 15000);\n list = (r.json && r.json.data && r.json.data.list) || [];\n out = [];\n for (i = 0; i < list.length; i++) { var b5 = bookToBook(list[i]); if (b5) out.push(b5); }\n return JSON.stringify(out);\n }\n\n \/\/ 直接搜索(发现页未使用,保留给手工入口)\n if (url.indexOf(\"q:\") === 0) {\n if (page >= 2) return \"[]\";\n var kw = unb64u(url.slice(2));\n var sr = await publicGet(\"\/search?q=\" + encodeURIComponent(kw) + \"&limit=10\", 15000);\n var sb = (sr.json && sr.json.data && sr.json.data.books) || [];\n out = [];\n for (i = 0; i < sb.length; i++) { var b6 = bookToBook(sb[i]); if (b6) out.push(b6); }\n return JSON.stringify(out);\n }\n\n return \"[]\";\n }\n\n \/\/ ==================== 书评面板(定制按钮 + 详情页按钮共用) ====================\n \/\/ 面板主题(面板内切换后**只写 localStorage**——面板脚本不能碰原生桥接,否则真机闪退;\n \/\/ 这里反向往回读:书源 WebView 与面板同源则能读到同一份 localStorage,读不到再回落宿主缓存)\n async function readPanelTheme() {\n try {\n var ls = null;\n try { ls = localStorage.getItem(K_THEME); } catch (e0) { ls = null; }\n if (ls === \"dark\" || ls === \"light\" || ls === \"auto\") return ls;\n } catch (e) {}\n try {\n var v = String((await cache.get(K_THEME)) || \"\");\n return (v === \"dark\" || v === \"light\" || v === \"auto\") ? v : \"\";\n } catch (e) { return \"\"; }\n }\n\n \/\/ 面板主题调色板(书评面板 \/ 账号面板共用一套变量)\n const PANEL_LIGHT = \"--bg:#eaeef5;--fg:#0a0c11;--fg2:#6c7380;--fg3:#9aa2b1;--glass:rgba(255,255,255,.80);--glass2:rgba(255,255,255,.55);--line:rgba(255,255,255,.85);--hair:rgba(10,12,17,.08);--card:rgba(255,255,255,.86);--shadow:0 6px 18px rgba(16,24,48,.10),0 1px 2px rgba(16,24,48,.05);--inner:inset 0 1px 0 rgba(255,255,255,.9);--accent:#c2491e;--hlbg:rgba(194,73,30,.10);--hlline:rgba(194,73,30,.35)\";\n const PANEL_DARK = \"--bg:#05060a;--fg:#f2f4f8;--fg2:#98a0ad;--fg3:#666e7d;--glass:rgba(18,20,26,.88);--glass2:rgba(255,255,255,.07);--line:rgba(255,255,255,.13);--hair:rgba(255,255,255,.09);--card:rgba(22,24,31,.92);--shadow:0 8px 22px rgba(0,0,0,.50),0 1px 2px rgba(0,0,0,.40);--inner:inset 0 1px 0 rgba(255,255,255,.10);--accent:#ff7a45;--hlbg:rgba(255,122,69,.12);--hlline:rgba(255,122,69,.4)\";\n\n \/\/ 主题控件样式(书评面板 \/ 账号面板共用;行为在 themeBootJs 里)\n \/\/ 采用「主题跟随诊断器」验证过的形态:按钮显示当前档位,点开是一个能看到\n \/\/ 「系统报告值 \/ 变化次数 \/ 最近变化时间」的小面板 —— 让「跟随」这件事变得可见、可验证。\n const THEME_CSS =\n \"#thBtn{position:fixed;right:14px;top:calc(13px + env(safe-area-inset-top));padding:7px 11px;\" +\n \"border:1px solid var(--line);border-radius:999px;background:var(--glass2);color:var(--fg2);\" +\n \"font:600 12px\/1 -apple-system,'PingFang SC','Microsoft YaHei',sans-serif;box-shadow:var(--inner);\" +\n \"-webkit-tap-highlight-color:transparent;z-index:31}\" +\n \"#thBtn.on{color:var(--fg)}\" +\n \"#thPanel{position:fixed;right:12px;top:calc(50px + env(safe-area-inset-top));z-index:30;\" +\n \"box-sizing:border-box;\" + \/\/ ⚠️ 必须显式声明:书评面板有 *{box-sizing:border-box},账号面板没有 → 同一个 width 会差 26px(padding+border)\n \"width:min(78vw,272px);max-width:calc(100vw - 24px);background:var(--card);border:1px solid var(--line);border-radius:16px;\" +\n \"padding:11px 12px;box-shadow:var(--shadow),var(--inner)}\" +\n \".thseg{display:flex;gap:3px;padding:3px;border-radius:999px;background:var(--glass2);border:1px solid var(--line)}\" +\n \".thseg b{flex:1;text-align:center;padding:9px 0;border-radius:999px;font-weight:700;font-size:11.5px;\" +\n \"color:var(--fg2);-webkit-tap-highlight-color:transparent}\" +\n \".thseg b.on{background:var(--card);color:var(--accent);box-shadow:var(--shadow),var(--inner),inset 0 0 0 1px var(--hlline)}\" +\n \".throw{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:6px 0;\" +\n \"border-bottom:1px dashed var(--hair);font-size:12.5px;color:var(--fg2)}\" +\n \".throw b{font-weight:700;color:var(--fg);font-variant-numeric:tabular-nums}\" +\n \".thfoot{display:flex;align-items:center;gap:9px;margin-top:9px}\" +\n \".thfoot button{padding:8px 12px;border:1px solid var(--line);border-radius:999px;\" +\n \"background:var(--glass2);color:var(--fg2);font-weight:700;font-size:12px;-webkit-tap-highlight-color:transparent}\" +\n \"#thHint{font-size:11px;color:var(--fg3);line-height:1.4}\";\n\n \/**\n * 面板主题引导脚本(两个面板共用)。\n *\n * ⚠️ 关键 1:**不要**用 `@media (prefers-color-scheme:dark)` 直接写深色。\n * Android WebView 的该媒体查询只跟随**系统**主题,不跟随 App 的夜间开关,\n * 所以只要系统是浅色,App 切到夜间后面板依旧全白(真机反馈的原 bug)。\n * 正解:由本脚本决定,在 <html> 上打 `.dark`,CSS 用 `html.dark{...}` 接管。\n *\n * ⚠️ 关键 2【真机闪退·务必遵守】:**面板脚本里绝对不能出现 `flutter_inappwebview.callHandler`**。\n * 半屏面板是 App 用 `startBrowserDpwithHtml` **独立创建**的 WebView,**不保证**继承\n * 书源主 WebView 的 handler 注册表;一旦命中未注册的 handler,native 侧会抛\n * PlatformException,宿主未捕获就是 **App 闪退**(真机实测:点主题按钮即闪退)。\n * ⇒ 面板只做纯 Web 操作(DOM \/ localStorage \/ fetch \/ XHR)。\n * 需要「面板 → 书源侧」回写的数据,一律反过来做:**由书源侧主动读取**(见 readPanelTheme)。\n *\n * ⚠️ 关键 3【性能】:首屏有 100 张书评卡,**卡片上不能挂 `backdrop-filter`**。\n * 每个 backdrop-filter 元素都会生成独立合成层,切主题时全部重算背景模糊 →\n * GPU 瞬时峰值 → WebView 渲染进程崩溃。玻璃感改由半透明底色 + 内高光 + 阴影表达。\n *\n * 档位:auto(跟随系统)\/ dark \/ light。\n * 取值优先级:localStorage(本机最近一次选择)→ 书源侧缓存 initial → auto。\n *\n * 交互【对齐「主题跟随诊断器」已验证的形态】—— 让「跟随」可见、可验证:\n * · 右上角 #thBtn 显示当前档位(跟随 \/ 夜间 \/ 日间),点一下展开或收起 #thPanel\n * · #thPanel 默认隐藏,**不占首屏空间**(书评列表不被挤)\n * · 面板里三档一目了然(跟随系统 \/ 强制深色 \/ 强制浅色)\n * · 面板里能看到「系统报告值 \/ 变化次数 \/ 最近变化时间」,还有「重新检测」按钮\n * —— 用户在系统设置切完主题回来,点一下就知道有没有跟上,不必关掉面板重开\n * · 点面板外部区域自动收起\n *\/\n function themeBootJs(initial) {\n return [\n \"var TH_KEY='jzsm_panel_theme';\",\n \"var th='auto';\",\n \"var sv=null;try{sv=localStorage.getItem(TH_KEY);}catch(e){}\",\n \"if(sv==='dark'||sv==='light'||sv==='auto'){th=sv;}else{var iv=\" + JSON.stringify(String(initial || \"\")) + \";if(iv==='dark'||iv==='light'){th=iv;}}\",\n \"var mq=null;try{mq=window.matchMedia?window.matchMedia('(prefers-color-scheme: dark)'):null;}catch(e){}\",\n \"var thCnt=0,thAt='\\\\u2014',thLast=null;\",\n \"function sysDark(){try{return !!(mq&&mq.matches)}catch(e){return false}}\",\n \"function effDark(){return th==='dark'||(th==='auto'&&sysDark())}\",\n \"function thLabel(){return th==='dark'?'\\\\u25cf \\\\u591c\\\\u95f4':(th==='light'?'\\\\u25cf \\\\u65e5\\\\u95f4':'\\\\u25cf \\\\u8ddf\\\\u968f')}\",\n \"function pad2(x){return (x<10?'0':'')+x}\",\n \"function hms(){var d=new Date();return pad2(d.getHours())+':'+pad2(d.getMinutes())+':'+pad2(d.getSeconds())}\",\n \"(function(){\",\n \" try{\",\n \" if(document.getElementById('thPanel'))return;\",\n \" var d=document.createElement('div');\",\n \" d.id='thPanel';\",\n \" d.style.display='none';\",\n \" d.innerHTML='<div class=thseg>'\",\n \" +'<b data-th=auto>\\\\u8ddf\\\\u968f\\\\u7cfb\\\\u7edf<\/b>'\",\n \" +'<b data-th=dark>\\\\u5f3a\\\\u5236\\\\u6df1\\\\u8272<\/b>'\",\n \" +'<b data-th=light>\\\\u5f3a\\\\u5236\\\\u6d45\\\\u8272<\/b><\/div>'\",\n \" +'<div class=throw><span>\\\\u7cfb\\\\u7edf\\\\u62a5\\\\u544a<\/span><b id=thSys>\\\\u2014<\/b><\/div>'\",\n \" +'<div class=throw><span>\\\\u53d8\\\\u5316\\\\u6b21\\\\u6570<\/span><b id=thCnt>0<\/b><\/div>'\",\n \" +'<div class=throw><span>\\\\u6700\\\\u8fd1\\\\u53d8\\\\u5316<\/span><b id=thAt>\\\\u2014<\/b><\/div>'\",\n \" +'<div class=thfoot><button id=thRe type=button>\\\\u91cd\\\\u65b0\\\\u68c0\\\\u6d4b<\/button><span id=thHint><\/span><\/div>';\",\n \" document.body.appendChild(d);\",\n \" }catch(e){}\",\n \"})();\",\n \/\/ 系统主题变化检测(靠计数与时间戳让用户「看得见」,不靠肉眼记忆)\n \"function thSync(origin){\",\n \" var sd=sysDark();\",\n \" if(thLast===null){thLast=sd;return false}\",\n \" if(sd===thLast)return false;\",\n \" thLast=sd;thCnt++;thAt=hms();return true\",\n \"}\",\n \"function applyTheme(){\",\n \" var d=effDark(),sd=sysDark();\",\n \" try{document.documentElement.classList.toggle('dark',d);}catch(e){}\",\n \" var b=document.getElementById('thBtn');\",\n \" if(b){b.textContent=thLabel()+' \\\\u25be';try{b.classList.toggle('on',d);}catch(e){}}\",\n \" var seg=document.querySelectorAll('#thPanel b[data-th]');\",\n \" for(var i=0;i<seg.length;i++){seg[i].className=(seg[i].getAttribute('data-th')===th?'on':'')}\",\n \" var e1=document.getElementById('thSys');if(e1)e1.textContent=mq?(sd?'\\\\u6df1\\\\u8272':'\\\\u6d45\\\\u8272'):'\\\\u4e0d\\\\u652f\\\\u6301';\",\n \" var e2=document.getElementById('thCnt');if(e2)e2.textContent=String(thCnt);\",\n \" var e3=document.getElementById('thAt');if(e3)e3.textContent=thAt;\",\n \" var e4=document.getElementById('thHint');\",\n \" if(e4)e4.textContent=!mq?'\\\\u672c WebView \\\\u4e0d\\\\u652f\\\\u6301\\\\u8ddf\\\\u968f\\\\uff0c\\\\u8bf7\\\\u7528\\\\u5f3a\\\\u5236\\\\u6863'\",\n \" :(th==='auto'?('\\\\u6b63\\\\u5728\\\\u8ddf\\\\u968f\\\\u624b\\\\u673a\\\\u7cfb\\\\u7edf \\\\u00b7 \\\\u5f53\\\\u524d'+(d?'\\\\u6df1\\\\u8272':'\\\\u6d45\\\\u8272'))\",\n \" :('\\\\u56fa\\\\u5b9a'+(d?'\\\\u6df1\\\\u8272':'\\\\u6d45\\\\u8272')+'\\\\uff0c\\\\u4e0d\\\\u968f\\\\u7cfb\\\\u7edf\\\\u53d8'));\",\n \"}\",\n \"function thToggle(){\",\n \" var p=document.getElementById('thPanel');if(!p)return;\",\n \" p.style.display=(p.style.display==='none'?'block':'none');\",\n \" applyTheme();\",\n \"}\",\n \"function thHide(){var p=document.getElementById('thPanel');if(p)p.style.display='none'}\",\n \"function thSet(v){\",\n \" if(v!=='auto'&&v!=='dark'&&v!=='light')return;\",\n \" th=v;try{localStorage.setItem(TH_KEY,v);}catch(e){}\",\n \" applyTheme();\",\n \"}\",\n \"function thRecheck(){thSync('\\\\u624b\\\\u52a8\\\\u91cd\\\\u65b0\\\\u68c0\\\\u6d4b');applyTheme()}\",\n \/\/ 单个 document 级委托:thBtn \/ 三档 \/ 重新检测 \/ 点外部收起\n \"document.addEventListener('click',function(e){\",\n \" var t=e.target,hitBtn=false,hitPanel=false;\",\n \" while(t&&t!==document.body){\",\n \" if(t.id==='thBtn')hitBtn=true;\",\n \" if(t.id==='thPanel')hitPanel=true;\",\n \" var dt=t.getAttribute&&t.getAttribute('data-th');\",\n \" if(dt&&String((t.parentNode&&t.parentNode.className)||'').indexOf('thseg')>=0){thSet(dt);return}\",\n \" if(t.id==='thRe'){thRecheck();return}\",\n \" t=t.parentNode\",\n \" }\",\n \" if(hitBtn)thToggle();\",\n \" else if(!hitPanel)thHide();\",\n \"});\",\n \"if(mq){var onCh=function(){thSync('change \\\\u4e8b\\\\u4ef6');applyTheme()};\",\n \" try{if(mq.addEventListener)mq.addEventListener('change',onCh);else if(mq.addListener)mq.addListener(onCh)}catch(e){}}\",\n \"document.addEventListener('visibilitychange',function(){if(!document.hidden&&thSync('\\\\u56de\\\\u5230\\\\u9875\\\\u9762\\\\u65f6\\\\u8865\\\\u68c0'))applyTheme()});\",\n \"window.addEventListener('pageshow',function(){thSync('\\\\u9875\\\\u9762\\\\u6062\\\\u590d\\\\u65f6\\\\u8865\\\\u68c0');applyTheme()});\",\n \"thLast=sysDark();applyTheme();\"\n ].join(\"\\n\");\n }\n\n async function collectReviewPanelData(code) {\n var data = {\n code: code,\n web: WEB + \"\/books\/\" + code,\n api: API, \/\/ 面板侧按需续拉要用\n size: PANEL_FETCH_SIZE, \/\/ 每次续拉的条数\n page: 0, \/\/ 已预载到第几页(服务端页)\n theme: await readPanelTheme(),\/\/ 面板初始主题(\"\"→面板自己决定)\n reviews: [], loaded: 0, total: 0, comments: 0\n };\n var g = await publicGet(\"\/books\/\" + encodeURIComponent(code), 15000);\n var d = g.json && g.json.data;\n if (d) {\n data.title = d.title || \"\";\n data.author = d.author || \"\";\n data.cover = normCover(d.cover_url);\n data.score = fmtScore(d.weighted_score);\n data.mean = fmtScore(d.mean_score);\n data.scoreCount = num(d.score_count, 0);\n data.mainCategory = d.main_category || \"\";\n data.category = d.category || \"\";\n data.status = d.status || \"\";\n data.wordCount = fmtWords(d.word_count);\n data.tags = Array.isArray(d.tags) ? d.tags : [];\n data.ratingDist = Array.isArray(d.rating_dist) ? d.rating_dist : [];\n data.intro = String(d.intro || \"\").replace(\/<[^>]*>\/g, \"\").replace(\/\\s+\/g, \" \").trim();\n data.sourceUrl = d.source_url || \"\";\n }\n var rv = await fetchReviews(code, PANEL_REVIEW_PAGES, REVIEW_PAGE_SIZE);\n data.total = num(rv.total, 0);\n var items = [];\n for (var i = 0; i < rv.list.length; i++) {\n var r = rv.list[i] || {};\n items.push({\n code: r.code || \"\",\n title: r.title || \"\",\n excerpt: r.excerpt || \"\",\n nick: r.user_nickname || \"\",\n level: r.user_level_title || \"\",\n rating: num(r.user_rating, 0),\n like: num(r.like_count, 0),\n favorite: num(r.favorite_count, 0),\n comment: num(r.comment_count, 0),\n date: fmtDate(r.created_at),\n comments: []\n });\n }\n \/\/ 只为「确实有评论」的长评拉取评论(当前社区评论极少,避免无谓请求)\n var fetched = 0;\n for (var j = 0; j < items.length && fetched < PANEL_COMMENT_MAX; j++) {\n if (!items[j].comment || !items[j].code) continue;\n var cs = await withTimeout(fetchComments(\"review\", items[j].code, 1), 10000, []);\n items[j].comments = (cs || []).map(function (c) {\n return {\n nick: c.user_nickname || \"\",\n level: c.user_level_title || \"\",\n content: String(c.content || c.content_md || \"\").replace(\/\\s+\/g, \" \").trim(),\n date: fmtDate(c.created_at),\n like: num(c.like_count, 0)\n };\n });\n data.comments += items[j].comments.length;\n fetched++;\n await sleepMs(120);\n }\n data.reviews = items;\n data.loaded = items.length;\n data.page = Math.ceil(items.length \/ PANEL_FETCH_SIZE) || 0; \/\/ 面板续拉从 page+1 开始\n return data;\n }\n\n function renderReviewPanel(data) {\n var payload = JSON.stringify(data).replace(\/<\/g, \"\\\\u003c\").replace(\/\\u2028\/g, \"\").replace(\/\\u2029\/g, \"\");\n var css = [\n \"*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}\",\n \":root{\" + PANEL_LIGHT + \"}\",\n \"html.dark{\" + PANEL_DARK + \"}\",\n \"html,body{margin:0;padding:0;background:var(--bg);color:var(--fg);font:15px\/1.62 -apple-system,BlinkMacSystemFont,'PingFang SC','Noto Sans CJK SC','Microsoft YaHei',sans-serif;overscroll-behavior-y:none}\",\n \".bg{position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden}\",\n \".bg i{position:absolute;display:block;border-radius:50%;filter:blur(64px);opacity:.55}\",\n \".bg i:nth-child(1){width:320px;height:320px;left:-80px;top:-60px;background:radial-gradient(circle,#ffb08a,transparent 70%)}\",\n \".bg i:nth-child(2){width:280px;height:280px;right:-70px;top:120px;background:radial-gradient(circle,#8ab6ff,transparent 70%)}\",\n \".bg i:nth-child(3){width:260px;height:260px;left:30%;bottom:-90px;background:radial-gradient(circle,#c9a6ff,transparent 70%)}\",\n \"html.dark .bg i{opacity:.3}\",\n \"header{position:sticky;top:0;z-index:5;padding:calc(12px + env(safe-area-inset-top)) 106px 10px 16px;background:var(--glass);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--hair)}\",\n THEME_CSS,\n \"header .t{font-size:17px;font-weight:700;line-height:1.35}\",\n \"header .s{margin-top:2px;font-size:12.5px;color:var(--fg2)}\",\n \".seg{display:flex;gap:4px;margin:10px 16px 4px;padding:4px;border-radius:999px;background:var(--glass2);border:1px solid var(--line);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);position:relative;z-index:4}\",\n \".seg b{flex:1;text-align:center;padding:7px 0;border-radius:999px;font-size:13.5px;font-weight:600;color:var(--fg2);transition:all .3s cubic-bezier(.22,.8,.2,1)}\",\n \".seg b.on{background:var(--card);color:var(--fg);box-shadow:var(--shadow),var(--inner)}\",\n \"main{position:relative;z-index:1;padding:10px 14px calc(112px + env(safe-area-inset-bottom))}\",\n \".card{background:var(--card);border:1px solid var(--line);border-radius:22px;padding:14px 15px;margin:0 0 12px;box-shadow:var(--shadow),var(--inner);transition:transform .18s cubic-bezier(.22,.8,.2,1)}\",\n \".card:active{transform:scale(.986)}\",\n \".row{display:flex;gap:12px;align-items:flex-start}\",\n \".cover{width:78px;height:104px;flex:0 0 auto;border-radius:12px;object-fit:cover;background:var(--glass2);border:1px solid var(--hair)}\",\n \".m{flex:1;min-width:0}\",\n \".name{font-size:16px;font-weight:700;line-height:1.35}\",\n \".meta{margin-top:3px;font-size:12.5px;color:var(--fg2)}\",\n \".score{display:inline-block;margin-top:6px;padding:2px 9px;border-radius:999px;background:var(--accent);color:#fff;font-size:13px;font-weight:700}\",\n \".chips{margin-top:8px;display:flex;flex-wrap:wrap;gap:6px}\",\n \".chip{padding:3px 9px;border-radius:999px;background:var(--glass2);border:1px solid var(--line);font-size:12px;color:var(--fg2)}\",\n \".rv{padding-top:12px;margin-top:12px;border-top:1px solid var(--hair)}\",\n \".rv:first-child{padding-top:0;margin-top:0;border-top:0}\",\n \".rv .h{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}\",\n \".rv .n{font-weight:700;font-size:14.5px}\",\n \".rv .r{margin-left:auto;color:var(--accent);font-weight:700;font-size:14px}\",\n \".rv .x{margin-top:5px;font-size:14px;color:var(--fg);white-space:pre-wrap;word-break:break-word}\",\n \".rv .f{margin-top:6px;font-size:12px;color:var(--fg3)}\",\n \".cbox{margin-top:9px;padding:9px 11px;border-radius:14px;background:var(--glass2);border:1px solid var(--line)}\",\n \".cbox .c{margin-top:6px;font-size:13px;color:var(--fg);white-space:pre-wrap}\",\n \".cbox .c:first-child{margin-top:0}\",\n \".cbox .cm{font-size:11.5px;color:var(--fg3);margin-top:2px}\",\n \".bar{display:flex;align-items:center;gap:8px;margin-top:6px;font-size:12.5px;color:var(--fg2)}\",\n \".bar .lb{width:52px;flex:0 0 auto}\",\n \".bar .tr{flex:1;height:8px;border-radius:999px;background:var(--glass2);overflow:hidden;border:1px solid var(--hair)}\",\n \".bar .fl{height:100%;border-radius:999px;background:linear-gradient(90deg,#ffb48a,var(--accent))}\",\n \".bar .vv{width:76px;flex:0 0 auto;text-align:right;color:var(--fg3)}\",\n \".intro{margin-top:8px;font-size:13.5px;color:var(--fg2);white-space:pre-wrap;word-break:break-word}\",\n \".empty{padding:34px 10px;text-align:center;color:var(--fg3);font-size:13.5px}\",\n \".pager{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(16px + env(safe-area-inset-bottom));z-index:9;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:999px;background:var(--glass);border:1px solid var(--line);box-shadow:var(--shadow),var(--inner);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px)}\",\n \".pager span{min-width:74px;text-align:center;font-size:13px;color:var(--fg2)}\",\n \".pager button{width:34px;height:34px;border:0;border-radius:50%;background:var(--glass2);color:var(--fg);display:flex;align-items:center;justify-content:center;font-size:16px;box-shadow:var(--inner)}\",\n \".pager button:disabled{opacity:.28}\",\n \".hint{margin-top:14px;font-size:12px;color:var(--fg3);text-align:center}\",\n \".more{margin-top:10px;padding:12px 14px;border:1px dashed var(--line);border-radius:16px;background:var(--glass2);color:var(--fg);font-size:14px;font-weight:600;text-align:center;transition:transform .18s cubic-bezier(.22,.8,.2,1);-webkit-tap-highlight-color:transparent}\",\n \".more:active{transform:scale(.99)}\",\n \".more.done{opacity:.6;font-weight:500;border-style:solid}\",\n \".more.fb{color:var(--accent);border:1px solid var(--accent);border-style:solid;background:transparent;font-weight:500;font-size:13px}\"\n ].join(\"\");\n var js = [\n \"var D=window.__D__;\",\n \"var st='rv',pg=0,PS=\" + PANEL_PAGE_SIZE + \",loading=false,ch='';\",\n themeBootJs(data.theme),\n \"var LAB=['1-2 分','3-4 分','5-6 分','7-8 分','9-10 分'];\",\n \"function esc(s){return String(s==null?'':s).replace(\/&\/g,'&').replace(\/<\/g,'<').replace(\/>\/g,'>').replace(\/\\\"\/g,'"')}\",\n \"function fmtDate(t){t=Number(t)||0;if(!t)return '';var d=new Date(t*1000),p=function(x){return (x<10?'0':'')+x};return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())}\",\n \"function R(){return D.reviews||[]}\",\n \"function TOTAL(){return Number(D.total||0)}\",\n \"function pageCount(a){return Math.max(1,Math.ceil(a.length\/PS))}\",\n \"function pc(){return pageCount(R())}\",\n \"function moreAvail(){return R().length<TOTAL()}\",\n \"function chName(){return ch==='fetch'?'页内直连':(ch==='xhr'?'页内兼容':(ch==='none'?'受限':'待检测'))}\",\n \"\/\/ ⚠️ 纯 Web 续拉:只用 fetch \/ XHR,绝不调用任何原生桥接(面板 WebView 非书源 WebView,命中未注册 handler 会闪退)\",\n \"function xhrJson(url,acc){\",\n \" return new Promise(function(res){\",\n \" var x=null;try{x=new XMLHttpRequest()}catch(e){res(null);return}\",\n \" if(!x){res(null);return}\",\n \" try{x.open('GET',url,true);x.timeout=12000;x.setRequestHeader('Accept',acc['Accept']);\",\n \" x.onload=function(){var j=null;try{j=JSON.parse(x.responseText)}catch(e){}res(j)};\",\n \" x.onerror=function(){res(null)};x.ontimeout=function(){res(null)};\",\n \" x.send();}catch(e){res(null)}\",\n \" })}\",\n \"async function panelFetch(page){\",\n \" var url=D.api+'\/books\/'+encodeURIComponent(D.code)+'\/reviews?page='+page+'&size='+D.size;\",\n \" var acc={'Accept':'application\/json, text\/plain, *\/*'};\",\n \" if(ch===''||ch==='fetch'){\",\n \" try{var r=await fetch(url,{headers:acc});if(r&&r.ok){var j=await r.json();if(j&&j.data){ch='fetch';return j.data}}}catch(e){}\",\n \" }\",\n \" if(ch===''||ch==='xhr'){\",\n \" try{var o=await xhrJson(url,acc);if(o&&o.data){ch='xhr';return o.data}}catch(e){}\",\n \" }\",\n \" if(ch==='')ch='none';\",\n \" return null}\",\n \"async function loadMore(){\",\n \" if(loading)return false;\",\n \" loading=true;if(st==='rv')render();\",\n \" var d=null;try{d=await panelFetch((Number(D.page)||0)+1)}catch(e){d=null}\",\n \" loading=false;\",\n \" if(!d||!d.list||!d.list.length){if(ch==='')ch='none';return false}\",\n \" D.page=(Number(D.page)||0)+1;\",\n \" if(d.total)D.total=Number(d.total)||D.total;\",\n \" var add=[];for(var i=0;i<d.list.length;i++){var r=d.list[i]||{};add.push({nick:r.user_nickname||'',level:r.user_level_title||'',rating:Number(r.user_rating)||0,title:r.title||'',excerpt:r.excerpt||'',like:Number(r.like_count)||0,favorite:Number(r.favorite_count)||0,comment:Number(r.comment_count)||0,date:fmtDate(r.created_at),comments:[]})}\",\n \" D.reviews=R().concat(add);\",\n \" return true}\",\n \"function head(){\",\n \" var h='';\",\n \" var s=D.score?('<span class=\\\"score\\\">'+esc(D.score)+'<\/span>'):'';\",\n \" h+='<div class=\\\"card\\\"><div class=\\\"row\\\">';\",\n \" if(D.cover) h+='<img class=\\\"cover\\\" src=\\\"'+esc(D.cover)+'\\\" onerror=\\\"this.style.display="none"\\\"\/>';\",\n \" h+='<div class=\\\"m\\\"><div class=\\\"name\\\">'+esc(D.title||'')+'<\/div>';\",\n \" var meta=[];if(D.author)meta.push(esc(D.author));if(D.mainCategory||D.category)meta.push(esc([D.mainCategory,D.category].filter(Boolean).join(' · ')));if(D.status)meta.push(esc(D.status));if(D.wordCount)meta.push(esc(D.wordCount));\",\n \" h+='<div class=\\\"meta\\\">'+meta.join(' ')+'<\/div>';\",\n \" h+=s;\",\n \" if(D.tags&&D.tags.length){h+='<div class=\\\"chips\\\">';for(var i=0;i<D.tags.length;i++)h+='<span class=\\\"chip\\\">'+esc(D.tags[i])+'<\/span>';h+='<\/div>';}\",\n \" h+='<\/div><\/div><\/div>';\",\n \" return h}\",\n \"function dist(){\",\n \" var a=D.ratingDist||[];var tot=0;for(var i=0;i<a.length;i++)tot+=Number(a[i])||0;\",\n \" var h='<div class=\\\"card\\\"><div class=\\\"name\\\">评分分布<\/div>';\",\n \" h+='<div class=\\\"meta\\\">均分 '+esc(D.mean||'—')+' · 共 '+tot+' 人评分<\/div>';\",\n \" if(!tot){h+='<div class=\\\"empty\\\">暂无评分<\/div><\/div>';return h}\",\n \" var max=1;for(var k=0;k<a.length;k++)max=Math.max(max,Number(a[k])||0);\",\n \" for(var j=a.length-1;j>=0;j--){var v=Number(a[j])||0;var pct=tot?Math.round(v*1000\/tot)\/10:0;\",\n \" h+='<div class=\\\"bar\\\"><span class=\\\"lb\\\">'+LAB[j]+'<\/span><span class=\\\"tr\\\"><i class=\\\"fl\\\" style=\\\"width:'+(v*100\/max)+'%\\\"><\/i><\/span><span class=\\\"vv\\\">'+v+' · '+pct+'%<\/span><\/div>'}\",\n \" return h+'<\/div>'}\",\n \"function introCard(){if(!D.intro)return '';return '<div class=\\\"card\\\"><div class=\\\"name\\\">简介<\/div><div class=\\\"intro\\\">'+esc(D.intro)+'<\/div><\/div>'}\",\n \"function foot(){\",\n \" var h='<div class=\\\"hint\\\">共 '+TOTAL()+' 篇 · 已载入 '+R().length+' 篇 · '+chName()+'<\/div>';\",\n \" if(loading)h+='<div class=\\\"more\\\">正在加载第 '+((Number(D.page)||0)+1)+' 页…<\/div>';\",\n \" else if(moreAvail())h+='<div class=\\\"more\\\" id=\\\"more\\\">加载更多(还有 '+(TOTAL()-R().length)+' 篇)<\/div>';\",\n \" else h+='<div class=\\\"more done\\\">已全部载入<\/div>';\",\n \" if(ch==='none')h+='<div class=\\\"more fb\\\" id=\\\"fb\\\">本机不允许页内取数,点此用浏览器打开原站全部书评<\/div>';\",\n \" return h}\",\n \"function reviews(){\",\n \" var a=R();\",\n \" if(!a.length){\",\n \" if(!TOTAL())return '<div class=\\\"card\\\"><div class=\\\"empty\\\">这本书还没有书评<\/div><\/div>';\",\n \" return '<div class=\\\"card\\\"><div class=\\\"empty\\\">暂无已载入的书评<\/div><\/div>'+foot()}\",\n \" var tp=pageCount(a);if(pg>=tp)pg=tp-1;if(pg<0)pg=0;\",\n \" var h='<div class=\\\"card\\\">';var start=pg*PS,end=Math.min(a.length,start+PS);\",\n \" for(var i=start;i<end;i++){var r=a[i];\",\n \" h+='<div class=\\\"rv\\\"><div class=\\\"h\\\"><span class=\\\"n\\\">'+esc(r.nick||'匿名')+'<\/span>';\",\n \" if(r.level)h+='<span class=\\\"chip\\\">'+esc(r.level)+'<\/span>';\",\n \" if(r.rating)h+='<span class=\\\"r\\\">'+r.rating+' 分<\/span>';\",\n \" h+='<\/div>';\",\n \" if(r.title)h+='<div class=\\\"n\\\" style=\\\"margin-top:4px\\\">'+esc(r.title)+'<\/div>';\",\n \" h+='<div class=\\\"x\\\">'+esc(r.excerpt||'')+'<\/div>';\",\n \" h+='<div class=\\\"f\\\">'+esc(r.date||'')+' · 👍'+r.like+' · ⭐'+r.favorite+' · 💬'+r.comment+'<\/div>';\",\n \" if(r.comments&&r.comments.length){h+='<div class=\\\"cbox\\\">';for(var c=0;c<r.comments.length;c++){var cm=r.comments[c];h+='<div class=\\\"c\\\">'+esc(cm.nick||'')+':'+esc(cm.content||'')+'<\/div><div class=\\\"cm\\\">'+esc(cm.date||'')+'<\/div>'}h+='<\/div>'}\",\n \" h+='<\/div>'}\",\n \" h+='<\/div>';\",\n \" return h+foot()}\",\n \"function nextVisible(){return pc()>1||moreAvail()}\",\n \"async function pull(){\",\n \" if(loading)return false;\",\n \" if(!moreAvail())return false;\",\n \" if(ch==='none')ch='';\",\n \" var before=R().length;\",\n \" await loadMore();\",\n \" if(R().length>before){pg=Math.floor(before\/PS);return true}\",\n \" return false}\",\n \"async function nx(){\",\n \" if(loading)return;\",\n \" if(pg<pc()-1){pg++;render();return}\",\n \" await pull();\",\n \" render()}\",\n \"async function nth(){\",\n \" if(loading)return;\",\n \" await pull();\",\n \" render()}\",\n \"function render(){\",\n \" var m=document.getElementById('list'),p=document.getElementById('pager');\",\n \" if(st==='rv'){m.innerHTML=reviews();p.style.display=nextVisible()?'flex':'none'}\",\n \" else if(st==='dt'){m.innerHTML=dist()+introCard()+head();p.style.display='none'}\",\n \" else{m.innerHTML=head()+dist()+introCard();p.style.display='none'}\",\n \" if(st==='rv'){var tp=pc();\",\n \" document.getElementById('pn').textContent=(pg+1)+' \/ '+tp+(moreAvail()?'+':'');\",\n \" document.getElementById('pv').disabled=pg<=0;\",\n \" document.getElementById('nx').disabled=(pg>=tp-1&&!moreAvail())||loading}\",\n \" var b=document.querySelectorAll('.seg b');for(var i=0;i<b.length;i++)b[i].className=(b[i].getAttribute('data-s')===st?'on':'')}\",\n \"document.addEventListener('click',function(e){\",\n \" var t=e.target;while(t&&t.tagName!=='BODY'){\",\n \" if(t.getAttribute&&t.getAttribute('data-s')){st=t.getAttribute('data-s');pg=0;render();return}\",\n \" if(t.id==='pv'){if(pg>0){pg--;render()}return}\",\n \" if(t.id==='nx'){nx();return}\",\n \" if(t.id==='more'){nth();return}\",\n \" if(t.id==='fb'){location.href=D.web;return}\",\n \" t=t.parentNode}});\",\n \"window.addEventListener('load',render);render();\"\n ].join(\"\\n\");\n\n var body = \"\";\n body += \"<div class=\\\"bg\\\"><i><\/i><i><\/i><i><\/i><\/div>\";\n body += \"<header><div class=\\\"t\\\">\" + escHtml(data.title || \"书评\") + \"<\/div><div class=\\\"s\\\">💬 九州书盟 · 书评面板 · \" + (data.total || 0) + \" 篇长评<\/div>\";\n body += \"<button id=\\\"thBtn\\\" type=\\\"button\\\" title=\\\"切换面板主题:跟随系统 \/ 夜间 \/ 日间\\\">● 跟随 ▾<\/button><\/header>\";\n body += \"<div class=\\\"seg\\\"><b data-s=\\\"rv\\\" class=\\\"on\\\">书评<\/b><b data-s=\\\"dt\\\">评分详情<\/b><b data-s=\\\"in\\\">书籍资料<\/b><\/div>\";\n body += \"<main id=\\\"list\\\"><\/main>\";\n body += \"<div class=\\\"pager\\\" id=\\\"pager\\\"><button id=\\\"pv\\\">‹<\/button><span id=\\\"pn\\\">1 \/ 1<\/span><button id=\\\"nx\\\">›<\/button><\/div>\";\n body += \"<scr\" + \"ipt>window.__D__=\" + payload + \";\" + js + \"<\/scr\" + \"ipt>\";\n\n return \"<!DOCTYPE html><html lang=\\\"zh\\\"><head><meta charset=\\\"utf-8\\\">\" +\n \"<meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1,viewport-fit=cover\\\">\" +\n \"<title>\" + escHtml(data.title || \"书评\") + \" · 书评面板<\/title><style>\" + css + \"<\/style><\/head><body>\" + body + \"<\/body><\/html>\";\n }\n\n \/\/ 详情页 intro 按钮 \/ 自定义按钮 都会走到这里\n async function openReviewPanel(code) {\n try {\n code = String(code || \"\");\n if (!code) { try { await flutterBridge.showToast(\"未识别到书籍标识\"); } catch (e) {} return; }\n try { await flutterBridge.showToast(\"正在加载书评…\"); } catch (e) {}\n var data = await withTimeout(collectReviewPanelData(code), 30000, null);\n if (!data) { try { await flutterBridge.showLongToast(\"书评加载超时,请稍后重试\"); } catch (e) {} return; }\n var html = renderReviewPanel(data);\n \/\/ 第 1 参数会作为 WebView 的 baseUrl:传站点域名可让面板**同源**,\n \/\/ 页内 fetch 才能直接续拉下一页(传 \"\" 会退化成 opaque origin → CORS 拦截)\n await flutterBridge.startBrowserDpwithHtml(WEB, html, (data.title || \"书评\") + \" · 书评\");\n } catch (e) {\n try { await flutterBridge.showLongToast(\"书评面板打开失败:\" + (e && e.message ? e.message : e)); } catch (e2) {}\n }\n }\n window.openReviewPanel = openReviewPanel;\n\n async function showAccountPanel() {\n try {\n var a = await readAuth();\n var u = a.user || {};\n var last = \"\";\n try { last = String((await cache.get(K_SIGNIN)) || \"\"); } catch (e) {}\n var signedToday = last === todayKey();\n var sig = signedToday ? null : await withTimeout(ensureSignin(true), 12000, null);\n var cred = await readCred();\n var mail = String((u && (u.email || u.Email)) || (cred && cred.email) || \"\");\n var lines = [];\n lines.push(\"<div class='card'><div class='name'>账号状态<\/div>\");\n lines.push(\"<div class='meta'>\" + (u.nickname ? (\"已登录:\" + escHtml(u.nickname)) : (a.at ? \"已登录\" : \"未登录\")) + \" · \" + (mail ? escHtml(mail) : \"未配置账号\") + \"<\/div>\");\n if (u.level_title) lines.push(\"<div class='meta'>\" + escHtml(u.level_title) + \" Lv.\" + num(u.level, 0) + \" · 经验 \" + num(u.exp, 0) + \"<\/div>\");\n lines.push(\"<div class='meta'>今日签到:\" + (signedToday ? \"已完成\" : (sig && sig.ok ? \"刚刚完成\" : \"未完成\")) + \"<\/div>\");\n if (sig && sig.text) lines.push(\"<div class='meta'>\" + escHtml(sig.text) + \"<\/div>\");\n if (!mail) {\n lines.push(\"<div class='hl'>本源码不含任何账号信息,可放心分享。<\/div>\");\n lines.push(\"<div class='meta'>首次使用请在「书源管理 → 本源的登录」中填写九州书盟的邮箱与密码,只需填一次;\" +\n \"之后每天打开软件会自动登录,并在随机 5~40 秒内悄悄完成签到,无需手动点。<\/div>\");\n } else if (!a.at) {\n lines.push(\"<div class='hl'>已保存账号,但登录未成功<\/div>\");\n lines.push(\"<div class='meta'>请到「书源管理 → 本源的登录」重新填写(密码可能已变更)。<\/div>\");\n }\n lines.push(\"<\/div>\");\n var th = await readPanelTheme();\n var html = \"<!DOCTYPE html><html lang='zh'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>\" +\n \"<style>:root{\" + PANEL_LIGHT + \"}html.dark{\" + PANEL_DARK + \"}\" +\n \"body{margin:0;padding:calc(46px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right)) calc(18px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left));background:var(--bg);color:var(--fg);font:15px\/1.6 -apple-system,'PingFang SC','Microsoft YaHei',sans-serif}\" +\n \".card{background:var(--card);border:1px solid var(--line);border-radius:22px;padding:16px;box-shadow:var(--shadow),var(--inner);margin-bottom:14px}\" +\n \".name{font-size:16px;font-weight:700;margin-bottom:6px}.meta{font-size:13px;color:var(--fg2);margin-top:3px}\" +\n \".hl{margin-top:10px;padding:11px 13px;border-radius:14px;background:var(--hlbg);border:1px solid var(--hlline);color:var(--accent);font-size:13.5px;font-weight:600}\" +\n THEME_CSS +\n \"<\/style><\/head><body>\" +\n \"<button id=\\\"thBtn\\\" type=\\\"button\\\">● 跟随 ▾<\/button>\" +\n lines.join(\"\") +\n \"<scr\" + \"ipt>\" + themeBootJs(th) + \"<\/scr\" + \"ipt>\" +\n \"<\/body><\/html>\";\n \/\/ 与书评面板一致:用站点域名作 baseUrl,面板同源,localStorage 才能稳定持久\n await flutterBridge.startBrowserDpwithHtml(WEB, html, \"账号与签到\");\n if (!signedToday && sig && sig.ok) {\n try { await flutterBridge.showToast(sig.text || \"签到完成\"); } catch (e) {}\n }\n } catch (e) {\n try { await flutterBridge.showLongToast(\"账号面板打开失败\"); } catch (e2) {}\n }\n }\n window.showAccountPanel = showAccountPanel;\n\n async function manualLogin() {\n try {\n var ok = await doLogin(false);\n if (!ok) {\n var has = await hasCred();\n try { await flutterBridge.showLongToast(has\n ? \"❌ 登录失败,请检查账号密码\"\n : \"尚未配置账号\\n请在「书源管理 → 本源的登录」中填写九州书盟的邮箱与密码\"); } catch (e) {}\n return;\n }\n var sg = await withTimeout(ensureSignin(true), 12000, null);\n try { await flutterBridge.showToast((sg && sg.text) || \"✅ 登录成功\"); } catch (e) {}\n } catch (e) {}\n }\n window.manualLogin = manualLogin;\n\n \/\/ ---- 登录弹窗 ----\n \/\/ 源码不含账号,所以这里只回填本机已保存的邮箱;密码一律留空(不回填、不外泄)\n async function getloginurl() {\n var cred = await readCred();\n var ui = [\n { name: \"邮箱\", type: \"text\", default: (cred && cred.email) || \"\", action: \"\" },\n { name: \"密码\", type: \"password\", default: \"\", action: \"\" },\n { name: \"登录并签到(首次请先填邮箱与密码)\", type: \"button\", action: \"login_form_submit()\" }\n ];\n return JSON.stringify(ui);\n }\n async function login() {\n \/\/ ⚠️ login() 是「配置变更回调」:用户每改完一个字段就会被调用一次。\n \/\/ 这里只提示、不清任何缓存(尤其不能清 LoginInfo)。\n try {\n var info = await cache.getLoginInfo();\n if (info) {\n var o = safeJson(info) || {};\n var e = String(o[\"邮箱\"] || o.email || o.Email || \"\").trim();\n var p = String(o[\"密码\"] || o.password || o.Password || \"\").trim();\n try { await flutterBridge.log(\"[九州书盟] 登录信息读取:邮箱=\" + (e || \"(空)\") + \" 密码位数=\" + p.length); } catch (er) {}\n if (e && p) {\n var r = await apiJson(\"\/auth\/login\", { method: \"POST\", body: JSON.stringify({ email: e, password: p }), ms: 15000 });\n if (r.ok && r.json && r.json.data) {\n await saveAuth(r.json.data);\n try { await cache.set(\"jzsm_cred\", JSON.stringify({ email: e, password: p })); } catch (er2) {}\n try { await flutterBridge.showToast(\"✅ 登录成功:\" + ((r.json.data.user && r.json.data.user.nickname) || e)); } catch (er3) {}\n await withTimeout(ensureSignin(true), 10000, null);\n } else {\n try { await flutterBridge.showLongToast(\"❌ 登录失败:\" + ((r.json && r.json.message) || r.error || \"\")); } catch (er4) {}\n }\n } else {\n try { await flutterBridge.showToast(\"请填写邮箱与密码\"); } catch (er5) {}\n }\n }\n } catch (e) {}\n }\n\n window.login_form_submit = async function () {\n try {\n var info = await cache.getLoginInfo();\n var o = safeJson(info) || {};\n var e = String(o[\"邮箱\"] || o.email || \"\").trim();\n var p = String(o[\"密码\"] || o.password || \"\").trim();\n if (!e || !p) { await flutterBridge.showToast(\"请填写邮箱与密码\"); return; }\n var r = await apiJson(\"\/auth\/login\", { method: \"POST\", body: JSON.stringify({ email: e, password: p }), ms: 15000 });\n if (r.ok && r.json && r.json.data) {\n await saveAuth(r.json.data);\n try { await cache.set(\"jzsm_cred\", JSON.stringify({ email: e, password: p })); } catch (er) {}\n await flutterBridge.showToast(\"✅ 登录成功\");\n await withTimeout(ensureSignin(true), 10000, null);\n } else {\n await flutterBridge.showLongToast(\"❌ 登录失败:\" + ((r.json && r.json.message) || r.error || \"\"));\n }\n } catch (e) {}\n };\n\n \/\/ ==================== 其他 ====================\n async function gethelp() {\n return \"@html:<h3>九州书盟 · 书评社区(逆向自《九州书盟》Android 1.0.13)<\/h3>\" +\n \"<p>本源的接口来自对该 App 的逆向分析。App 与官网 <b>jiuzhoushumeng.com<\/b>(优书网|九州书盟)同源,\" +\n \"接口为 <code>https:\/\/jiuzhoushumeng.com\/api\/v1<\/code>,公开数据<b>免鉴权<\/b>即可读取。<\/p>\" +\n \"<h4>能做什么<\/h4>\" +\n \"<ul>\" +\n \"<li><b>搜索<\/b>:站内书籍搜索(上游无分页,仅第 1 页 10 条)。<\/li>\" +\n \"<li><b>详情<\/b>:加权评分、均分、评分人数、分类、字数、状态、标签、简介,并可一键打开网页版。<\/li>\" +\n \"<li><b>目录<\/b>:① 书籍资料 ② 评分分布 ③ <b>书评列表<\/b>(长评,目录内最多载入前 100 篇;要看全部请用书评面板)④ 相似推荐 ⑤ 收录书单 ⑥ 网页版。<\/li>\" +\n \"<li><b>发现<\/b>:5 个榜单 + 10 个主分类 + 24 个热标签 + 书评广场 + 书单广场(含书单标签)+ 百度小说热榜 + 编辑推荐。<\/li>\" +\n \"<li><b>书评面板<\/b>:详情页底部的「💬 书评面板」按钮(即书源自定义按钮)用<b>半屏面板<\/b>展示评分分布、书评列表与评论;\" +\n \"首屏预载 100 篇,翻到末尾会<b>自动续拉下一页<\/b>,可一直翻到该书的全部书评。\" +\n \"面板右上角有<b>主题按钮<\/b>(显示「● 跟随 ▾」),点一下会展开一个小面板:里面能直接选\" +\n \"「跟随系统 \/ 强制深色 \/ 强制浅色」三档,还能看到<b>系统报告值、主题变化次数、最近变化时间<\/b>,\" +\n \"并带一个「重新检测」按钮——你在手机设置里切完主题回来点一下就知道有没有跟上,不必关掉面板重开。\" +\n \"选过就记住(本机),点面板外部自动收起。\" +\n \"说明:半屏面板里的网页<b>跟随的是手机系统<\/b>的日夜模式,不跟随 App 内部的夜间开关;\" +\n \"所以系统的「跟随」表现与 App 夜间模式不一致是正常的,想要和 App 一致就选「强制深色 \/ 强制浅色」。\" +\n \"若你的手机 WebView 太老、不支持跟随,面板会明确提示并引导你改用强制档。<\/li>\" +\n \"<li><b>登录<\/b>:<b>本源码不含任何账号信息<\/b>,可放心分享。首次使用请在「书源管理 → 本源的登录」中填写你自己的九州书盟邮箱与密码(只保存在本机,不会写回书源),填一次即可。<\/li>\" +\n \"<li><b>自动签到(不用手动点)<\/b>:填好账号后,<b>每天打开软件时自动签到<\/b>——你正常搜索 \/ 看发现页 \/ 点开任意一本书,都会在后台<b>随机 5~40 秒<\/b>内悄悄完成签到,<b>每天只签一次<\/b>,签到时刻不固定。\" +\n \"无需进入登录页点任何按钮;已签过则一次内存判断即返回,零网络开销。发现页底部「👤 账号」按钮可查看今日签到状态。<\/li>\" +\n \"<\/ul>\" +\n \"<h4>说明与注意<\/h4>\" +\n \"<ul>\" +\n \"<li>本源<b>不提供小说正文<\/b>——九州书盟是书评\/评分社区,本身不存放连载正文;「书评」才是它的内容。<\/li>\" +\n \"<li>长评正文按 Markdown 原样取出后转为纯文本,<b>不做任何删减<\/b>;图片会以链接形式保留。<\/li>\" +\n \"<li>站点评论功能目前几乎无人使用(评论数普遍为 0),书评面板会为有评论的书评自动附带评论。<\/li>\" +\n \"<li>账号密码只由你本人在本机填写,保存在 App 本地缓存里;源码与分享出去的 JSON <b>都不包含<\/b>任何账号。<\/li>\" +\n \"<li>若提示「未配置账号」或签到失败,先点发现页的「👤 账号」查看状态;已填过仍失败通常是密码已改,请重新填写。<\/li>\" +\n \"<li>未配置账号时,<b>搜索 \/ 详情 \/ 目录 \/ 书评 \/ 发现 \/ 面板全部照常可用<\/b>,只有登录与签到会被跳过。<\/li>\" +\n \"<\/ul>\";\n }\n\n async function callbackjs(event, book) {\n try {\n if (event === \"clickCustomButton\" || event === \"longClickCustomButton\") {\n var b = safeJson(book) || {};\n var bu = String(b.bookUrl || b.bookurl || \"\");\n var p = parseBookUrl(bu);\n var code = p ? p.code : \"\";\n if (!code) { try { await flutterBridge.showToast(\"当前书籍没有可展示的书评\"); } catch (e) {} return true; }\n await openReviewPanel(code);\n return true;\n }\n } catch (e) {\n try { await flutterBridge.log(\"[九州书盟] callbackjs 异常:\" + (e && e.message ? e.message : e)); } catch (e2) {}\n }\n return false;\n }\n\n async function filter() { return \"\"; }\n async function findfilter(url) { return \"\"; }\n async function pay(bookurl, url) {}\n async function imagedecrypt(url, image) { return []; }\n async function shouldOverrideUrlLoading(url) { return true; }\n<\/script>\n<\/html>\n",
"login": true,
"customButton": true,
"eventListener": true,
"lastUpdateTime": "1791896488953"
}