[{"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, \"&amp;\").replace(\/<\/g, \"&lt;\").replace(\/>\/g, \"&gt;\")\n      .replace(\/\"\/g, \"&quot;\");\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(\/&nbsp;\/g, \" \").replace(\/&amp;\/g, \"&\").replace(\/&lt;\/g, \"<\").replace(\/&gt;\/g, \">\").replace(\/&quot;\/g, '\"').replace(\/&#39;\/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(\/&nbsp;\/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,'&amp;').replace(\/<\/g,'&lt;').replace(\/>\/g,'&gt;').replace(\/\\\"\/g,'&quot;')}\",\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=&quot;none&quot;\\\"\/>';\",\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\\\">&#8249;<\/button><span id=\\\"pn\\\">1 \/ 1<\/span><button id=\\\"nx\\\">&#8250;<\/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"}]