// ==UserScript== // @name 番茄小说助手 // @namespace https://github.com/naiyQAQ/fanqie-assistant // @version 0.0.6 // @author naiyQAQ // @description 番茄小说助手,去广告、去推广、解锁章节、优化体验。 // @license GPLv3 // @icon data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4NCiAgICA8cGF0aA0KICAgICAgICBkPSJNMjMuMzExNSAxSDYuNjg4NTNDMy41NDY4NCAxIDEgMy41NDY4NCAxIDYuNjg4NTNWMjMuMzExNUMxIDI2LjQ1MzIgMy41NDY4NCAyOSA2LjY4ODUzIDI5SDIzLjMxMTVDMjYuNDUzMiAyOSAyOSAyNi40NTMyIDI5IDIzLjMxMTVWNi42ODg1M0MyOSAzLjU0Njg0IDI2LjQ1MzIgMSAyMy4zMTE1IDFaIg0KICAgICAgICBmaWxsPSJ3aGl0ZSI+PC9wYXRoPg0KICAgIDxwYXRoDQogICAgICAgIGQ9Ik0yMy4zMTE1IDAuNzVINi42ODg1M0MzLjQwODc3IDAuNzUgMC43NSAzLjQwODc3IDAuNzUgNi42ODg1M1YyMy4zMTE1QzAuNzUgMjYuNTkxMiAzLjQwODc3IDI5LjI1IDYuNjg4NTMgMjkuMjVIMjMuMzExNUMyNi41OTEyIDI5LjI1IDI5LjI1IDI2LjU5MTIgMjkuMjUgMjMuMzExNVY2LjY4ODUzQzI5LjI1IDMuNDA4NzcgMjYuNTkxMiAwLjc1IDIzLjMxMTUgMC43NVoiDQogICAgICAgIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjA4IiBzdHJva2Utd2lkdGg9IjAuNSI+PC9wYXRoPg0KICAgIDxtYXNrIGlkPSJtYXNrMF80NzBfNDgzNjQiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjEiIHk9IjEiIHdpZHRoPSIyOCIgaGVpZ2h0PSIyOCI+DQogICAgICAgIDxwYXRoDQogICAgICAgICAgICBkPSJNMjMuMzExNSAxSDYuNjg4NTNDMy41NDY4NCAxIDEgMy41NDY4NCAxIDYuNjg4NTNWMjMuMzExNUMxIDI2LjQ1MzIgMy41NDY4NCAyOSA2LjY4ODUzIDI5SDIzLjMxMTVDMjYuNDUzMiAyOSAyOSAyNi40NTMyIDI5IDIzLjMxMTVWNi42ODg1M0MyOSAzLjU0Njg0IDI2LjQ1MzIgMSAyMy4zMTE1IDFaIg0KICAgICAgICAgICAgZmlsbD0id2hpdGUiPjwvcGF0aD4NCiAgICA8L21hc2s+DQogICAgPGcgbWFzaz0idXJsKCNtYXNrMF80NzBfNDgzNjQpIj4NCiAgICAgICAgPHBhdGgNCiAgICAgICAgICAgIGQ9Ik0yMy4zMTE1IDFINi42ODg1M0MzLjU0Njg0IDEgMSAzLjU0Njg0IDEgNi42ODg1M1YyMy4zMTE1QzEgMjYuNDUzMiAzLjU0Njg0IDI5IDYuNjg4NTMgMjlIMjMuMzExNUMyNi40NTMyIDI5IDI5IDI2LjQ1MzIgMjkgMjMuMzExNVY2LjY4ODUzQzI5IDMuNTQ2ODQgMjYuNDUzMiAxIDIzLjMxMTUgMVoiDQogICAgICAgICAgICBmaWxsPSJ3aGl0ZSI+PC9wYXRoPg0KICAgICAgICA8cGF0aA0KICAgICAgICAgICAgZD0iTTE1LjAwMDggNDguNjY0MkMyNS40MDE3IDQ4LjY2NDIgMzMuODMzNCA0MC4yMzI2IDMzLjgzMzQgMjkuODMxNkMzMy44MzM0IDE5LjQzMDcgMjUuNDAxNyAxMC45OTkgMTUuMDAwOCAxMC45OTlDNC41OTk4NSAxMC45OTkgLTMuODMxNzkgMTkuNDMwNyAtMy44MzE3OSAyOS44MzE2Qy0zLjgzMTc5IDQwLjIzMjYgNC41OTk4NSA0OC42NjQyIDE1LjAwMDggNDguNjY0MloiDQogICAgICAgICAgICBmaWxsPSJ1cmwoI3BhaW50MF9yYWRpYWxfNDcwXzQ4MzY0KSI+PC9wYXRoPg0KICAgICAgICA8cGF0aCBkPSJNMjMuMjY4OCAxVjcuMjEyOTRMMjAuNjY2MyA1LjcxNDM3TDE4LjA2NzQgNy4yMTI5NFYxSDIzLjI2ODhaIiBmaWxsPSIjRkY1RjAwIj48L3BhdGg+DQogICAgICAgIDxwYXRoDQogICAgICAgICAgICBkPSJNMTUuMTM0MyAxOC44ODFDMTUuMTM0MyAxOC44ODEgMTYuMTAxNCAxNy41NTEzIDE2LjEwMTQgMTYuNDA2NUMxNi4xMDE0IDE1LjI2MTcgMTUuNjY3NiAxNC43MzczIDE1LjEzNDMgMTQuNzM3M0MxNC42MDEgMTQuNzM3MyAxNC4xNjczIDE1LjI2MzUgMTQuMTY3MyAxNi40MDY1QzE0LjE2NzMgMTcuNTQ5NiAxNS4xMzQzIDE4Ljg4MSAxNS4xMzQzIDE4Ljg4MVoiDQogICAgICAgICAgICBmaWxsPSJ3aGl0ZSI+PC9wYXRoPg0KICAgICAgICA8cGF0aA0KICAgICAgICAgICAgZD0iTTcuNjI3MjQgMjIuNjU4NUM4Ljc3MjA1IDIyLjY1ODUgMTAuMTAxNyAyMy42MjU2IDEwLjEwMTcgMjMuNjI1NkMxMC4xMDE3IDIzLjYyNTYgOC43NzAyNyAyNC41OTI2IDcuNjI3MjQgMjQuNTkyNkM2LjQ4NDIgMjQuNTkyNiA1Ljk1ODAxIDI0LjE1ODkgNS45NTgwMSAyMy42MjU2QzUuOTU4MDEgMjMuMDkyMyA2LjQ4MjQyIDIyLjY1ODUgNy42MjcyNCAyMi42NTg1WiINCiAgICAgICAgICAgIGZpbGw9IndoaXRlIj48L3BhdGg+DQogICAgICAgIDxwYXRoDQogICAgICAgICAgICBkPSJNMjIuNjM5NiAyNC41OTI2QzIxLjQ5NDggMjQuNTkyNiAyMC4xNjUxIDIzLjYyNTYgMjAuMTY1MSAyMy42MjU2QzIwLjE2NTEgMjMuNjI1NiAyMS40OTY2IDIyLjY1ODUgMjIuNjM5NiAyMi42NTg1QzIzLjc4MjYgMjIuNjU4NSAyNC4zMDg4IDIzLjA5MjMgMjQuMzA4OCAyMy42MjU2QzI0LjMwODggMjQuMTU4OSAyMy43ODQ0IDI0LjU5MjYgMjIuNjM5NiAyNC41OTI2WiINCiAgICAgICAgICAgIGZpbGw9IndoaXRlIj48L3BhdGg+DQogICAgICAgIDxwYXRoDQogICAgICAgICAgICBkPSJNMTAuNDU1NSAxOC4zMTM5QzExLjI2NDMgMTkuMTIyNyAxMS41MjIxIDIwLjc0NzUgMTEuNTIyMSAyMC43NDc1QzExLjUyMjEgMjAuNzQ3NSA5Ljg5NzMyIDIwLjQ4OTcgOS4wODg0OCAxOS42ODA5QzguMjc5NjQgMTguODcyMSA4LjIxMzg3IDE4LjE5NDggOC41OTI1MSAxNy44MTYxQzguOTcxMTUgMTcuNDM3NSA5LjY0NjY2IDE3LjUwMzMgMTAuNDU3MyAxOC4zMTIxTDEwLjQ1NTUgMTguMzEzOVoiDQogICAgICAgICAgICBmaWxsPSJ3aGl0ZSI+PC9wYXRoPg0KICAgICAgICA8cGF0aA0KICAgICAgICAgICAgZD0iTTE4Ljc0NjUgMjAuNzQ3NkMxOC43NDY1IDIwLjc0NzYgMTkuMDA0MyAxOS4xMjI4IDE5LjgxMzEgMTguMzE0TDE5LjgxMTMgMTguMzEyMkMyMC42MjIgMTcuNTAzMyAyMS4yOTkzIDE3LjQzOTMgMjEuNjc2MSAxNy44MTYyQzIyLjA1NDggMTguMTk0OSAyMS45ODkgMTguODcyMSAyMS4xODAyIDE5LjY4MUMyMC4zNzEzIDIwLjQ4OTggMTguNzQ2NSAyMC43NDc2IDE4Ljc0NjUgMjAuNzQ3NloiDQogICAgICAgICAgICBmaWxsPSJ3aGl0ZSI+PC9wYXRoPg0KICAgIDwvZz4NCiAgICA8ZGVmcz4NCiAgICAgICAgPHJhZGlhbEdyYWRpZW50IGlkPSJwYWludDBfcmFkaWFsXzQ3MF80ODM2NCIgY3g9IjAiIGN5PSIwIiByPSIxIg0KICAgICAgICAgICAgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiDQogICAgICAgICAgICBncmFkaWVudFRyYW5zZm9ybT0idHJhbnNsYXRlKDE1LjAwMDggMjkuODMxNikgc2NhbGUoMTguODMyNikiPg0KICAgICAgICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iI0NDMDUwMCI+PC9zdG9wPg0KICAgICAgICAgICAgPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjRkY1RjAwIj48L3N0b3A+DQogICAgICAgIDwvcmFkaWFsR3JhZGllbnQ+DQogICAgPC9kZWZzPg0KPC9zdmc+ // @match *://*.fanqienovel.com/* // @require https://registry.npmmirror.com/vue/3.5.40/files/dist/vue.global.prod.js // @require https://registry.npmmirror.com/moment/2.30.1/files/min/moment.min.js // @require https://registry.npmmirror.com/jszip/3.10.1/files/dist/jszip.min.js // @connect fanqienovel.com // @connect jxbhmy.com // @connect snssdk.com // @connect byteimg.com // @connect fqnovelpic.com // @connect bytecdn.cn // @connect fqnovelvod.com // @connect novelfmvod.com // @connect volcautovod.com // @grant GM_addStyle // @grant GM_cookie // @grant GM_deleteValue // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant unsafeWindow // @run-at document-start // ==/UserScript== (function (vue, moment, JSZip) { 'use strict'; var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); const shared_key = new Uint8Array([172, 37, 198, 125, 221, 143, 56, 193, 179, 122, 35, 72, 130, 142, 34, 46]).buffer; const fetch$1 = unsafeWindow.fetch; unsafeWindow.XMLHttpRequest; const defaultConfig = { install_id: "2187355326270644", device_id: "2187355326004404", device_type: "P30" }; const _config = { currentConfig: defaultConfig }; const scriptcss = "/* 移除章节锁定图标 */\n.muyeicon-lock {\n display: none;\n}\n/* 移除APP推广图标 */\n.muye-to-fanqie {\n display: none!important;\n}\n.reader-toolbar-item-download {\n display: none!important;\n}\n.download-btn {\n display: none!important;\n}\n.download-icon {\n display: none!important;\n}\n\n.fqa-hide {\n display: none!important;\n}\n/* 404 */\n.no-content {\n display: none!important;\n}\n\n.fqa-comic-img {\n width: 100%!important;\n height: 100%!important;\n max-width: 100%!important;\n max-height: 100%!important;\n padding-top: 0!important;\n padding-bottom: 0!important;\n margin-top: 0!important;\n margin-bottom: 0!important;\n}\n\n.fqa-comic-reader {\n line-height: 0!important;\n}\n\n.fqa-menu-item,\n.arco-menu-item {\n width: 100%!important;\n}\n\n#dynamic-el {\n display: none!important;\n}\n\n.fqa-footnote-ref {\n display: inline-block;\n margin: 0 0.15em;\n padding: 0 0.25em;\n font-size: 0.7em;\n line-height: 1.4;\n vertical-align: super;\n color: var(--web-brand_normal, #f14646);\n cursor: pointer;\n user-select: none;\n border-radius: 3px;\n text-indent: 0;\n}\n\n.fqa-footnote-ref:hover,\n.fqa-footnote-ref:focus-visible {\n background: var(--web-brand_light, rgba(241, 70, 70, 0.12));\n outline: none;\n}\n\n\n.fqa-footnote {\n margin-top: 2em;\n padding-top: 1em;\n border-top: 1px solid var(--web-gray_20, rgba(128, 128, 128, 0.25));\n font-size: var(--fqa-body-size, 1.6rem);\n}\n\n.muye-reader-content-16 .fqa-footnote { font-size: var(--fqa-body-size, 1.6rem); }\n.muye-reader-content-20 .fqa-footnote { font-size: var(--fqa-body-size, 2rem); }\n.muye-reader-content-24 .fqa-footnote { font-size: var(--fqa-body-size, 2.4rem); }\n.muye-reader-content-28 .fqa-footnote { font-size: var(--fqa-body-size, 2.8rem); }\n.muye-reader-content-32 .fqa-footnote { font-size: var(--fqa-body-size, 3.2rem); }\n\n.fqa-footnote-title {\n margin-bottom: 0.6em;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--web-gray_40, #8a8a8a);\n text-indent: 0;\n}\n\n.fqa-footnote-list {\n margin: 0;\n padding-left: 1.6em;\n font-size: 0.85em;\n line-height: 1.7;\n color: var(--web-gray_40, #8a8a8a);\n}\n\n.fqa-footnote-list li {\n margin-bottom: 0.5em;\n text-indent: 0;\n transition: background-color 0.3s ease;\n}\n\n.fqa-footnote-list li.fqa-footnote-active {\n background: var(--web-brand_light, rgba(241, 70, 70, 0.12));\n border-radius: 4px;\n}\n\n.muye-reader-content > body {\n background-color: var(--web-bg)!important;\n}\n\n.fqa-icon-dark {\n color: #B3B3B3\n}\n\n/* ----------------------------- 右键菜单 / Toast ----------------------------- */\n\n/*\n * 书架与搜索共用。两者都把菜单 Teleport 到 body,\n * 拿不到各自根节点上的变量,所以在这里声明一份全局色板。\n */\n.fqa-menu {\n --fqa-menu-bg: #fff;\n --fqa-menu-text: #1f2329;\n --fqa-menu-sub: #8f959e;\n --fqa-menu-hover: rgba(31, 35, 41, 0.06);\n --fqa-menu-danger: #f5222d;\n\n position: fixed;\n z-index: 2147483001;\n min-width: 132px;\n max-width: 240px;\n padding: 4px;\n box-sizing: border-box;\n background: var(--fqa-menu-bg);\n border: 1px solid rgba(31, 35, 41, 0.08);\n border-radius: 8px;\n box-shadow: 0 6px 24px rgba(31, 35, 41, 0.16);\n font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,\n sans-serif;\n font-size: 13px;\n color: var(--fqa-menu-text);\n user-select: none;\n}\n\n/* 二级面板:分组数量多时可滚动 */\n.fqa-menu-sub {\n max-height: 320px;\n overflow-y: auto;\n}\n\n.fqa-menu-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 7px 10px;\n border-radius: 5px;\n line-height: 1.4;\n cursor: pointer;\n white-space: nowrap;\n overflow: hidden;\n}\n\n.fqa-menu-row > span:first-child {\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.fqa-menu-row:hover,\n.fqa-menu-row.fqa-menu-open {\n background: var(--fqa-menu-hover);\n}\n\n.fqa-menu-arrow {\n color: var(--fqa-menu-sub);\n font-size: 15px;\n line-height: 1;\n}\n\n.fqa-menu-danger {\n color: var(--fqa-menu-danger);\n}\n\n.fqa-menu-disabled {\n color: var(--fqa-menu-sub);\n cursor: not-allowed;\n}\n\n.fqa-menu-disabled:hover {\n background: transparent;\n}\n\n/* 操作结果提示 */\n.fqa-toast {\n position: fixed;\n left: 50%;\n bottom: 48px;\n transform: translateX(-50%);\n z-index: 2147483002;\n max-width: 80vw;\n padding: 10px 18px;\n box-sizing: border-box;\n background: rgba(31, 35, 41, 0.88);\n color: #fff;\n border-radius: 8px;\n font-size: 13px;\n line-height: 1.4;\n box-shadow: 0 6px 24px rgba(31, 35, 41, 0.24);\n pointer-events: none;\n}\n\n/* 骨架屏微光。书架与搜索共用同一个动画名 */\n@keyframes fqa-shimmer {\n 100% {\n transform: translateX(100%);\n }\n}\n\n@media (prefers-color-scheme: dark) {\n .fqa-menu {\n --fqa-menu-bg: #23272e;\n --fqa-menu-text: #e5e6eb;\n --fqa-menu-sub: #8f959e;\n --fqa-menu-hover: rgba(255, 255, 255, 0.08);\n border-color: rgba(255, 255, 255, 0.1);\n }\n}\n\n.info {\n width: 100%!important;\n}\n\n.reader-toolbar {\n user-select: none;\n}"; async function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function nextFrame() { return new Promise((resolve) => { let settled = false; const done = () => { if (settled) return; settled = true; resolve(); }; const raf = unsafeWindow.requestAnimationFrame; if (typeof raf === "function") raf(() => done()); setTimeout(done, 32); }); } function cloneElement(element) { return element.cloneNode(true); } function waitForElement(selector, timeout = 15e3) { const existing = document.querySelector(selector); if (existing) return Promise.resolve(existing); return new Promise((resolve) => { let timer; const observer2 = new MutationObserver(() => { const el = document.querySelector(selector); if (el) { if (timer) clearTimeout(timer); observer2.disconnect(); resolve(el); } }); const start = () => { observer2.observe(document.documentElement, { childList: true, subtree: true }); timer = setTimeout(() => { observer2.disconnect(); resolve(document.querySelector(selector)); }, timeout); }; if (document.documentElement) { start(); } else { document.addEventListener("DOMContentLoaded", start, { once: true }); } }); } function chunk(list, size) { const result = []; for (let i2 = 0; i2 < list.length; i2 += size) { result.push(list.slice(i2, i2 + size)); } return result; } function concatArrayBuffers(...buffers) { const totalLength = buffers.reduce((sum, buf) => sum + buf.byteLength, 0); const result = new Uint8Array(totalLength); let offset = 0; for (const buf of buffers) { result.set(new Uint8Array(buf), offset); offset += buf.byteLength; } return result.buffer; } async function inject$1() { while (!document.body) { console.log("Waiting for body..."); await sleep(200); } GM_addStyle(scriptcss); console.log("CSS injected successfully!"); } unsafeWindow.localStorage; function write(key, value) { GM_setValue(key, JSON.stringify(value)); } function read(key) { return JSON.parse(GM_getValue(key) || "null"); } function del(key) { GM_deleteValue(key); } const STORE_KEY$1 = "settings"; const DEFAULT_SETTINGS = { decryptFont: true, blockReport: true, allowCopy: true, shelfRemoveConfirm: true, logoutConfirm: true, readerFont: "", customCssEnabled: false, customCss: "", // 原站书架点封面是继续阅读,保持一致 bookshelfClickAction: "read", enhanceSearch: true, // 默认关:携带登录态属于额外的隐私暴露,交给用户显式开启 searchPersonalized: false, enableDownload: true, downloadFormat: "epub", downloadCharset: "utf-8", // 30 是接口单请求返回正文的上限,再大也只回 30 条 downloadBatchSize: 30, // 实测 750ms 能稳定拿满,更短会被限流成每次 1 条 downloadInterval: 750, downloadRetries: 3, downloadVolumePage: false, downloadImages: true, downloadBookCss: true, audiobookChapterEnd: "next", audiobookFollow: true, apiPreference: "app", deviceId: "", installId: "", deviceType: "" }; function normalize(raw) { const s = { ...DEFAULT_SETTINGS }; if (!raw || typeof raw !== "object") return s; const o = raw; for (const key of Object.keys(DEFAULT_SETTINGS)) { const v = o[key]; if (v === void 0 || v === null) continue; if (typeof DEFAULT_SETTINGS[key] === typeof v) { s[key] = v; } } if (s.apiPreference !== "app" && s.apiPreference !== "redcandle") { s.apiPreference = DEFAULT_SETTINGS.apiPreference; } if (s.downloadFormat !== "epub" && s.downloadFormat !== "txt") { s.downloadFormat = DEFAULT_SETTINGS.downloadFormat; } if (s.downloadCharset !== "utf-8" && s.downloadCharset !== "gbk") { s.downloadCharset = DEFAULT_SETTINGS.downloadCharset; } if (s.bookshelfClickAction !== "read" && s.bookshelfClickAction !== "detail") { s.bookshelfClickAction = DEFAULT_SETTINGS.bookshelfClickAction; } if (s.audiobookChapterEnd !== "next" && s.audiobookChapterEnd !== "stop") { s.audiobookChapterEnd = DEFAULT_SETTINGS.audiobookChapterEnd; } s.downloadBatchSize = clampInt(s.downloadBatchSize, 1, 30, DEFAULT_SETTINGS.downloadBatchSize); s.downloadInterval = clampInt(s.downloadInterval, 0, 1e4, DEFAULT_SETTINGS.downloadInterval); s.downloadRetries = clampInt(s.downloadRetries, 0, 10, DEFAULT_SETTINGS.downloadRetries); return s; } function clampInt(value, min, max, fallback) { const n = Math.round(Number(value)); if (!Number.isFinite(n)) return fallback; return Math.min(max, Math.max(min, n)); } const settings$1 = vue.reactive(normalize(read(STORE_KEY$1))); let saveTimer; vue.watch( settings$1, () => { if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { saveTimer = void 0; write(STORE_KEY$1, { ...settings$1 }); }, 200); }, { deep: true } ); function flushSettings() { if (saveTimer) { clearTimeout(saveTimer); saveTimer = void 0; } write(STORE_KEY$1, { ...settings$1 }); } const code_ed = 58715; const code_st = 58344; const mapping = { "DNMrHsV173Pd4pgy": [ "D在主特家军然表场4要只v和?6别还g现儿岁??此象月3出战工相", "o男直失世F都平文什VO将真T那当?会立些u是十张学气大爱两命全", "后东性通被1它乐接而感车山公了常以何可话先pi叫轻M士w着变尔快", "l个说少色里安花远7难师放t报认面道S?克地度I好机U民写把万同", "水新没书电吃像斯5为y白几日教看但第加候作上拉住有法r事应位利你", "声身国问马女他Y比父xAHNsX边美对所金活回意到z从j知又内因", "点Q三定8Rb正或夫向德听更?得告并本q过记L让打f人就者去原满", "体做经K走如孩cG给使物?最笑部?员等受k行一条果动光门头见往自", "解成处天能于名其发总母的死手入路进心来h时力多开已许d至由很界n", "小与Z想代么分生口再妈望次西风种带J?实情才这?E我神格长觉间年", "眼无不亲关结0友信下却重己老2音字m呢明之前高PB目太e9起稜她", "也W用方子英每理便四数期中C外样a海们任" ], "fKts9tCXDjS49UhH": [ "体y十现快使话却月物水的放知爱方?表风理O老也p常克平几最主她s", "将法情o光a我呢J员太每望受教w利军已U人如变得要少斯门电m男没", "AK国时中走么何口小向问轻Td神下间车fG度D又大面远就写j给通", "起实E?它去S到道数吃们加P是无把事西多界?发新外活解孩只作前Y", "尔经?u心告父等Q民全这9果安?i母8r说任先和地C张战场g像c", "q你使?样总目x性处音头?应乐关能花I当名手4重字声力友然生代内", "里本回真入师象?0点R亲V种动英命ZhX做特边高有B为期自年马认", "出接至H正方感所明者棱F住学还分意更其n但比觉以由死家让失士L2", "I金叫身报听W再原山海白很见5直位第工个开岁好用都于可同3次四?", "日信与女笑满并部什不从或机此?了记三e些bN夫会才几眼两美被一公", "来立z长对己看k许因相色后往打结格过世气7子条在书之定v拉成进带", "着东上想天他妈1文而路那别德6Mt行候难" ], "_search": [ "?s?作口在他能并B士4U克才正们字声高全尔活者动其主报多望放h", "w次年?中3特于十入要男同G面分方K什再教本己结1等世N?说gu", "期Z外美M行给9文将两许张友0英应向像此白安少何打气常定间花见孩", "它直风数使道第水已女山解dP的通关性叫几L妈问回神来S?四里前国", "些OvIA心平自无车光代是好却c得种就意先立z子过Yj表?么所接", "了名金受J满眼没部那m每车度可R斯经现门明V如走命y6E战很上f", "月西7长夫想话变海机x到W一成生信笑但父开内东马日小而后带以三几", "为认X死员目位之学远入音呢我q乐象重对个被别F也书棱D写还因家发", "时i或住德当oI比觉然吃去公a老亲情体太b方C电理?失力更拉物着", "原她工实色感记看出相路大你候2和?与p样新只便最不进Tr做格母总", "爱身师轻知往加从?天eH?听场由快边让把任8条头事至起点真手这难", "都界用法n处下文Q告地5kt岁有会果利民" ] }; const NO_GLYPH = "?"; const flatCache = /* @__PURE__ */ new Map(); function tableOf(fontId) { const cached = flatCache.get(fontId); if (cached) return cached; const rows = mapping[fontId]; if (!rows) return null; const flat = [...rows.join("")]; const expected = code_ed - code_st + 1; if (flat.length !== expected) { console.error( `[fqa:font] 码表 ${fontId} 长度异常:${flat.length},应为 ${expected},已禁用该字体的解密` ); flatCache.set(fontId, []); return []; } flatCache.set(fontId, flat); return flat; } const enTag = Object.keys(mapping).map((id) => `.font-${id}`).join(", "); function fontIdOf(element) { for (const cls of element.classList) { if (!cls.startsWith("font-")) continue; const id = cls.slice(5); if (mapping[id]) return id; } return null; } function decryptText(text, fontId) { if (window.location.pathname.startsWith("/search")) fontId = "_search"; const table2 = tableOf(fontId); if (!table2 || table2.length === 0) return text; let result = ""; let changed = false; for (const char of text) { const codePoint = char.codePointAt(0); if (typeof codePoint !== "number") { return text; } if (codePoint < code_st || codePoint > code_ed) { result += char; continue; } const mapped = table2[codePoint - code_st]; if (mapped && mapped !== NO_GLYPH) { result += mapped; changed = true; } else { result += char; } } return changed ? result : text; } function decryptElement(element) { if (!settings$1.decryptFont) return; const fontId = fontIdOf(element); if (!fontId) return; const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT, { // 后代里可能嵌着另一套字体的节点,那部分要用它自己的码表, // 交给针对它的 decryptElement 处理,这里跳过整棵子树 acceptNode(node) { var _a; const owner = (_a = node.parentElement) == null ? void 0 : _a.closest(enTag); return owner && owner !== element ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; } } ); let textNode; while (textNode = walker.nextNode()) { const oldText = textNode.nodeValue; if (!oldText) { continue; } const newText = decryptText(oldText, fontId); if (newText !== oldText) { textNode.nodeValue = newText; } } } function decryptPage(root) { if (root instanceof Element && root.matches(enTag)) { decryptElement(root); } root.querySelectorAll(enTag).forEach(decryptElement); } function initFontDecrypt() { const observer2 = new MutationObserver((mutations) => { var _a, _b; for (const mutation of mutations) { if (mutation.type === "characterData") { const encryptedElement = (_a = mutation.target.parentElement) == null ? void 0 : _a.closest( enTag ); if (encryptedElement) { decryptElement(encryptedElement); } continue; } for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { decryptPage(node); continue; } if (node.nodeType === Node.TEXT_NODE) { const encryptedElement = (_b = node.parentElement) == null ? void 0 : _b.closest( enTag ); if (encryptedElement) { decryptElement(encryptedElement); } } } } }); observer2.observe(document, { subtree: true, childList: true, characterData: true }); decryptPage(document); vue.watch( () => settings$1.decryptFont, (on) => { if (on) decryptPage(document); } ); } const STYLE_ID$6 = "fqa-user-style"; const READER_SCOPE = "#fqa-reader-content, .muye-reader-content"; function buildCss() { const parts = []; const font = settings$1.readerFont.trim(); if (font) { const family = /^["']|,/.test(font) ? font : `"${font}"`; parts.push(`${READER_SCOPE}, ${READER_SCOPE} p { font-family: ${family}, inherit !important; }`); } if (settings$1.customCssEnabled && settings$1.customCss.trim()) { parts.push(settings$1.customCss); } return parts.join("\n"); } function apply() { const css = buildCss(); let el = document.getElementById(STYLE_ID$6); if (!css) { el == null ? void 0 : el.remove(); return; } if (!el) { el = document.createElement("style"); el.id = STYLE_ID$6; document.head.appendChild(el); } el.textContent = css; } function initUserStyle() { apply(); vue.watch( () => [settings$1.readerFont, settings$1.customCssEnabled, settings$1.customCss], apply ); } class EmptyResponseError extends Error { constructor(status) { super(`服务端返回了空响应体(HTTP ${status})`); __publicField(this, "status"); this.name = "EmptyResponseError"; this.status = status; } } function isEmptyResponse(res) { const text = res.responseText; return typeof text !== "string" || text === ""; } const supportedMethods = /* @__PURE__ */ new Set([ "GET", "HEAD", "POST", "PUT", "DELETE" ]); function apiFetch(url, options = {}) { return new Promise((resolve, reject) => { const { signal } = options; if (signal == null ? void 0 : signal.aborted) { reject(signal.reason ?? new DOMException("The operation was aborted", "AbortError")); return; } const headers = normalizeHeaders$1(options.headers); const data = normalizeBody(options.body); const method = options.method ?? (data ? "POST" : "GET"); if (!supportedMethods.has(method)) { reject(new TypeError(`Unsupported request method: ${method}`)); return; } let request; const abort = () => request == null ? void 0 : request.abort(); function cleanup() { signal == null ? void 0 : signal.removeEventListener("abort", abort); } request = GM_xmlhttpRequest({ url, method, headers, data, anonymous: options.credentials === "omit", redirect: options.redirect === "error" ? "error" : "follow", onload(response) { cleanup(); resolve(Object.assign(response, { json() { if (isEmptyResponse(this)) { throw new EmptyResponseError(this.status); } return JSON.parse(this.responseText); } })); }, onerror(response) { cleanup(); reject(createRequestError("Network request failed", response)); }, ontimeout() { cleanup(); reject(createRequestError("Network request timed out", { status: 0, statusText: "Timeout", url })); }, onabort() { cleanup(); reject( (signal == null ? void 0 : signal.reason) ?? new DOMException("The operation was aborted", "AbortError") ); } }); signal == null ? void 0 : signal.addEventListener("abort", abort, { once: true }); }); } function fetchArrayBuffer(url) { return fetch$1(url, { referrerPolicy: "no-referrer" }).then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.arrayBuffer(); }).catch((err) => { console.debug("[fqa:img] 页面 fetch 失败,改用 GM_xmlhttpRequest:", url, err); return gmArrayBuffer(url); }); } function gmArrayBuffer(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ url, method: "GET", responseType: "arraybuffer", onload(response) { const buf = response.response; if (response.status >= 200 && response.status < 300 && (buf == null ? void 0 : buf.byteLength)) { resolve(buf); } else { reject(new Error(`GM 请求失败(${response.status})`)); } }, onerror() { reject(new Error(`GM 请求出错,检查 @connect 是否覆盖该域名: ${url}`)); }, ontimeout: () => reject(new Error("GM 请求超时")) }); }); } function normalizeHeaders$1(headers) { if (!headers) { return void 0; } return Object.fromEntries(new Headers(headers).entries()); } function normalizeBody(body) { if (body == null) { return void 0; } if (body instanceof URLSearchParams) { return body.toString(); } if (typeof body === "string" || body instanceof Blob || body instanceof ArrayBuffer || body instanceof FormData) { return body; } if (ArrayBuffer.isView(body)) { return body.buffer.slice( body.byteOffset, body.byteOffset + body.byteLength ); } throw new TypeError( "GM_xmlhttpRequest does not support ReadableStream request bodies" ); } function createRequestError(message, response) { const error = new TypeError(message); Object.defineProperty(error, "response", { configurable: true, enumerable: false, value: response }); return error; } function rotateLeft(value, shiftBits) { return value << shiftBits | value >>> 32 - shiftBits; } function addUnsigned(left, right) { return left + right >>> 0; } function f(x, y, z) { return x & y | ~x & z; } function g(x, y, z) { return x & z | y & ~z; } function h(x, y, z) { return x ^ y ^ z; } function i(x, y, z) { return y ^ (x | ~z); } function ff(a, b, c, d, x, s, ac) { return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(f(b, c, d), x), ac)), s), b); } function gg(a, b, c, d, x, s, ac) { return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(g(b, c, d), x), ac)), s), b); } function hh(a, b, c, d, x, s, ac) { return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(h(b, c, d), x), ac)), s), b); } function ii(a, b, c, d, x, s, ac) { return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(i(b, c, d), x), ac)), s), b); } function convertToWordArray(bytes) { const wordCount = Math.ceil((bytes.length + 9) / 64) * 16; const words = new Array(wordCount).fill(0); for (let byteIndex = 0; byteIndex < bytes.length; byteIndex++) { const wordIndex = Math.floor(byteIndex / 4); const bytePosition = byteIndex % 4 * 8; words[wordIndex] = words[wordIndex] | bytes[byteIndex] << bytePosition; } const paddingWordIndex = Math.floor(bytes.length / 4); const paddingBytePosition = bytes.length % 4 * 8; words[paddingWordIndex] = words[paddingWordIndex] | 128 << paddingBytePosition; words[wordCount - 2] = bytes.length << 3; words[wordCount - 1] = bytes.length >>> 29; return words; } function wordToHex(value) { let hex2 = ""; for (let byteIndex = 0; byteIndex < 4; byteIndex++) { const byte = value >>> byteIndex * 8 & 255; hex2 += byte.toString(16).padStart(2, "0"); } return hex2; } function md5(input) { const bytes = typeof input === "string" ? new TextEncoder().encode(input) : new Uint8Array(input); const words = convertToWordArray(bytes); let a = 1732584193; let b = 4023233417; let c = 2562383102; let d = 271733878; for (let k = 0; k < words.length; k += 16) { const aa = a; const bb = b; const cc = c; const dd = d; a = ff(a, b, c, d, words[k], 7, 3614090360); d = ff(d, a, b, c, words[k + 1], 12, 3905402710); c = ff(c, d, a, b, words[k + 2], 17, 606105819); b = ff(b, c, d, a, words[k + 3], 22, 3250441966); a = ff(a, b, c, d, words[k + 4], 7, 4118548399); d = ff(d, a, b, c, words[k + 5], 12, 1200080426); c = ff(c, d, a, b, words[k + 6], 17, 2821735955); b = ff(b, c, d, a, words[k + 7], 22, 4249261313); a = ff(a, b, c, d, words[k + 8], 7, 1770035416); d = ff(d, a, b, c, words[k + 9], 12, 2336552879); c = ff(c, d, a, b, words[k + 10], 17, 4294925233); b = ff(b, c, d, a, words[k + 11], 22, 2304563134); a = ff(a, b, c, d, words[k + 12], 7, 1804603682); d = ff(d, a, b, c, words[k + 13], 12, 4254626195); c = ff(c, d, a, b, words[k + 14], 17, 2792965006); b = ff(b, c, d, a, words[k + 15], 22, 1236535329); a = gg(a, b, c, d, words[k + 1], 5, 4129170786); d = gg(d, a, b, c, words[k + 6], 9, 3225465664); c = gg(c, d, a, b, words[k + 11], 14, 643717713); b = gg(b, c, d, a, words[k], 20, 3921069994); a = gg(a, b, c, d, words[k + 5], 5, 3593408605); d = gg(d, a, b, c, words[k + 10], 9, 38016083); c = gg(c, d, a, b, words[k + 15], 14, 3634488961); b = gg(b, c, d, a, words[k + 4], 20, 3889429448); a = gg(a, b, c, d, words[k + 9], 5, 568446438); d = gg(d, a, b, c, words[k + 14], 9, 3275163606); c = gg(c, d, a, b, words[k + 3], 14, 4107603335); b = gg(b, c, d, a, words[k + 8], 20, 1163531501); a = gg(a, b, c, d, words[k + 13], 5, 2850285829); d = gg(d, a, b, c, words[k + 2], 9, 4243563512); c = gg(c, d, a, b, words[k + 7], 14, 1735328473); b = gg(b, c, d, a, words[k + 12], 20, 2368359562); a = hh(a, b, c, d, words[k + 5], 4, 4294588738); d = hh(d, a, b, c, words[k + 8], 11, 2272392833); c = hh(c, d, a, b, words[k + 11], 16, 1839030562); b = hh(b, c, d, a, words[k + 14], 23, 4259657740); a = hh(a, b, c, d, words[k + 1], 4, 2763975236); d = hh(d, a, b, c, words[k + 4], 11, 1272893353); c = hh(c, d, a, b, words[k + 7], 16, 4139469664); b = hh(b, c, d, a, words[k + 10], 23, 3200236656); a = hh(a, b, c, d, words[k + 13], 4, 681279174); d = hh(d, a, b, c, words[k], 11, 3936430074); c = hh(c, d, a, b, words[k + 3], 16, 3572445317); b = hh(b, c, d, a, words[k + 6], 23, 76029189); a = hh(a, b, c, d, words[k + 9], 4, 3654602809); d = hh(d, a, b, c, words[k + 12], 11, 3873151461); c = hh(c, d, a, b, words[k + 15], 16, 530742520); b = hh(b, c, d, a, words[k + 2], 23, 3299628645); a = ii(a, b, c, d, words[k], 6, 4096336452); d = ii(d, a, b, c, words[k + 7], 10, 1126891415); c = ii(c, d, a, b, words[k + 14], 15, 2878612391); b = ii(b, c, d, a, words[k + 5], 21, 4237533241); a = ii(a, b, c, d, words[k + 12], 6, 1700485571); d = ii(d, a, b, c, words[k + 3], 10, 2399980690); c = ii(c, d, a, b, words[k + 10], 15, 4293915773); b = ii(b, c, d, a, words[k + 1], 21, 2240044497); a = ii(a, b, c, d, words[k + 8], 6, 1873313359); d = ii(d, a, b, c, words[k + 15], 10, 4264355552); c = ii(c, d, a, b, words[k + 6], 15, 2734768916); b = ii(b, c, d, a, words[k + 13], 21, 1309151649); a = ii(a, b, c, d, words[k + 4], 6, 4149444226); d = ii(d, a, b, c, words[k + 11], 10, 3174756917); c = ii(c, d, a, b, words[k + 2], 15, 718787259); b = ii(b, c, d, a, words[k + 9], 21, 3951481745); a = addUnsigned(a, aa); b = addUnsigned(b, bb); c = addUnsigned(c, cc); d = addUnsigned(d, dd); } return (wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)).toLowerCase(); } function rotl(value, shift) { const normalizedShift = shift & 31; return value << normalizedShift | value >>> 32 - normalizedShift; } function p0(value) { return value ^ rotl(value, 9) ^ rotl(value, 17); } function p1(value) { return value ^ rotl(value, 15) ^ rotl(value, 23); } function sm3(input) { const inputLength = input.length; const paddedLength = Math.ceil((inputLength + 9) / 64) * 64; const message = new Uint8Array(paddedLength); message.set(input); message[inputLength] = 128; const bitLength = BigInt(inputLength) * 8n; for (let i2 = 0; i2 < 8; i2++) { message[paddedLength - 1 - i2] = Number(bitLength >> BigInt(i2 * 8) & 0xffn); } const dataView = new DataView(message.buffer); const state2 = new Uint32Array([ 1937774191, 1226093241, 388252375, 3666478592, 2842636476, 372324522, 3817729613, 2969243214 ]); const words = new Uint32Array(68); const expandedWords = new Uint32Array(64); for (let block = 0; block < message.length / 64; block++) { const start = block * 64; for (let i2 = 0; i2 < 16; i2++) { words[i2] = dataView.getUint32(start + i2 * 4, false); } for (let i2 = 16; i2 < 68; i2++) { words[i2] = p1(words[i2 - 16] ^ words[i2 - 9] ^ rotl(words[i2 - 3], 15)) ^ rotl(words[i2 - 13], 7) ^ words[i2 - 6]; } for (let i2 = 0; i2 < 64; i2++) { expandedWords[i2] = words[i2] ^ words[i2 + 4]; } let a = state2[0]; let b = state2[1]; let c = state2[2]; let d = state2[3]; let e = state2[4]; let f2 = state2[5]; let g2 = state2[6]; let h2 = state2[7]; for (let i2 = 0; i2 < 64; i2++) { const t = i2 <= 15 ? 2043430169 : 2055708042; const ss1 = rotl(rotl(a, 12) + e + rotl(t, i2), 7); const ss2 = ss1 ^ rotl(a, 12); const tt1 = (i2 <= 15 ? a ^ b ^ c : a & b | a & c | b & c) + d + ss2 + expandedWords[i2]; const tt2 = (i2 <= 15 ? e ^ f2 ^ g2 : e & f2 | ~e & g2) + h2 + ss1 + words[i2]; d = c; c = rotl(b, 9); b = a; a = tt1; h2 = g2; g2 = rotl(f2, 19); f2 = e; e = p0(tt2); } state2[0] = state2[0] ^ a; state2[1] = state2[1] ^ b; state2[2] = state2[2] ^ c; state2[3] = state2[3] ^ d; state2[4] = state2[4] ^ e; state2[5] = state2[5] ^ f2; state2[6] = state2[6] ^ g2; state2[7] = state2[7] ^ h2; } const result = new Uint8Array(32); for (let i2 = 0; i2 < state2.length; i2++) { const word = state2[i2]; result[i2 * 4] = word >>> 24; result[i2 * 4 + 1] = word >>> 16; result[i2 * 4 + 2] = word >>> 8; result[i2 * 4 + 3] = word; } return result; } function getCrypto() { const c = globalThis.crypto ?? unsafeWindow.crypto; if (!(c == null ? void 0 : c.subtle)) { throw new Error("Crypto API不可用,请检查浏览器版本是否支持该API"); } return c; } function getSubtle() { return getCrypto().subtle; } function b64decode(b64) { const binaryString = atob(b64); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i2 = 0; i2 < len; i2++) { bytes[i2] = binaryString.charCodeAt(i2); } return bytes.buffer; } function b64encode(buffer) { const bytes = new Uint8Array(buffer); const chunkSize = 32768; const chunks = []; for (let i2 = 0; i2 < bytes.length; i2 += chunkSize) { chunks.push( String.fromCharCode(...bytes.subarray(i2, i2 + chunkSize)) ); } return btoa(chunks.join("")); } function unhex(hex2) { if (hex2.length % 2 !== 0) { throw new Error("Invalid hex string"); } const bytes = new Uint8Array(hex2.length / 2); for (let i2 = 0; i2 < hex2.length; i2 += 2) { const byte = parseInt(hex2.slice(i2, i2 + 2), 16); if (Number.isNaN(byte)) { throw new Error("Invalid hex string"); } bytes[i2 / 2] = byte; } return bytes.buffer; } function hex(buffer) { const bytes = new Uint8Array(buffer); let hexString = ""; for (let i2 = 0; i2 < bytes.length; i2++) { hexString += bytes[i2].toString(16).padStart(2, "0"); } return hexString; } function pkcs7Pad(data, blockSize = 16) { const padLength = blockSize - data.length % blockSize; const padded = new Uint8Array(data.length + padLength); padded.set(data); padded.fill(padLength, data.length); return padded; } function randomString(length) { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let result = ""; const array = new Uint8Array(length); getCrypto().getRandomValues(array); for (let i2 = 0; i2 < length; i2++) { result += chars.charAt(array[i2] % chars.length); } return result; } function toBytes$1(input) { if (typeof input === "string") { return new TextEncoder().encode(input); } if (input instanceof Uint8Array) { return new Uint8Array(input); } return new Uint8Array(input); } const hash = { sha256: async (input) => { const subtle = getSubtle(); const digest = await subtle.digest("SHA-256", toBytes$1(input)); return hex(digest); }, sha256bytes: async (input) => { const subtle = getSubtle(); return subtle.digest("SHA-256", toBytes$1(input)); }, sha512: async (input) => { const subtle = getSubtle(); const digest = await subtle.digest("SHA-512", toBytes$1(input)); return hex(digest); }, sha512bytes: async (input) => { const subtle = getSubtle(); return subtle.digest("SHA-512", toBytes$1(input)); }, md5: async (input) => md5( typeof input === "string" ? input : toBytes$1(input).buffer ), md5bytes: async (input) => unhex(md5( typeof input === "string" ? input : toBytes$1(input).buffer )), sm3: async (input) => hex(sm3(toBytes$1(input)).buffer), sm3bytes: async (input) => sm3(toBytes$1(input)).buffer }; const WIRE_VARINT = 0; const WIRE_BYTES = 2; const WIRE_FIXED32 = 5; class ProtobufWriter { constructor() { __publicField(this, "buf", []); } writeVarint(value) { let v = value >>> 0; while (v >= 128) { this.buf.push(v & 127 | 128); v >>>= 7; } this.buf.push(v & 127); return this; } writeKey(fieldNumber, wireType) { return this.writeVarint(fieldNumber << 3 | wireType); } varint(fieldNumber, value) { return this.writeKey(fieldNumber, WIRE_VARINT).writeVarint(value); } fixed32(fieldNumber, value) { this.writeKey(fieldNumber, WIRE_FIXED32); const v = value >>> 0; this.buf.push(v & 255, v >>> 8 & 255, v >>> 16 & 255, v >>> 24 & 255); return this; } bytes(fieldNumber, data) { this.writeKey(fieldNumber, WIRE_BYTES).writeVarint(data.length); for (let i2 = 0; i2 < data.length; i2++) { this.buf.push(data[i2] & 255); } return this; } string(fieldNumber, value) { return this.bytes(fieldNumber, new TextEncoder().encode(value)); } message(fieldNumber, build) { const sub = new ProtobufWriter(); build(sub); return this.bytes(fieldNumber, sub.toBytes()); } toBytes() { return Uint8Array.from(this.buf); } } const ROUNDS$1 = 72; const MASK64$1 = 0xffffffffffffffffn; const Z4 = 0x3dc94c3a046d678bn; function getBit(value, position) { return value >> BigInt(position) & 1n; } function rotateLeft64(v, n) { return (v << n | v >> 64n - n) & MASK64$1; } function rotateRight64(v, n) { return (v << 64n - n | v >> n) & MASK64$1; } function keyExpansion(key) { const k = [key[0] & MASK64$1, key[1] & MASK64$1, key[2] & MASK64$1, key[3] & MASK64$1]; for (let i2 = 4; i2 < ROUNDS$1; i2++) { let tmp = rotateRight64(k[i2 - 1], 3n); tmp ^= k[i2 - 3]; tmp ^= rotateRight64(tmp, 1n); k.push((~k[i2 - 4] ^ tmp ^ getBit(Z4, (i2 - 4) % 62) ^ 3n) & MASK64$1); } return k; } function simonEncrypt(plaintext, key) { const k = keyExpansion(key); let x = plaintext[0] & MASK64$1; let y = plaintext[1] & MASK64$1; for (let i2 = 0; i2 < ROUNDS$1; i2++) { const tmp = y; const f2 = rotateLeft64(y, 1n) & rotateLeft64(y, 8n); y = (x ^ f2 ^ rotateLeft64(y, 2n) ^ k[i2]) & MASK64$1; x = tmp; } return [x, y]; } const LOW_RAND = new Uint8Array([242, 129]); const HIGH_RAND = new Uint8Array([97, 111]); const XOR_PREFIX = new Uint8Array([242, 247, 252, 255, 242, 247, 252, 255]); function sm3Prefix6(data) { return sm3(data).slice(0, 6); } function decodeStub(xssStub) { const bytes = new Uint8Array(16); if (xssStub.length >= 32) { for (let i2 = 0; i2 < 16; i2++) { bytes[i2] = parseInt(xssStub.slice(i2 * 2, i2 * 2 + 2), 16) & 255; } } return bytes; } function buildProtobuf(query, xssStub, timestamp, config) { const params = new URLSearchParams(query); const deviceId = params.get("device_id") ?? ""; const versionName = params.get("version_name") ?? ""; const bodyHash = sm3Prefix6(xssStub === "" ? new Uint8Array(16) : decodeStub(xssStub)); const queryHash = sm3Prefix6( query === "" ? new Uint8Array(16) : new TextEncoder().encode(query) ); const rand = getCrypto().getRandomValues(new Uint32Array(1))[0] % 2147483647; return new ProtobufWriter().varint(1, 538970409 * 2).varint(2, 2).varint(3, rand).string(4, config.aid).string(5, deviceId).string(6, config.licenseId).string(7, versionName).string(8, config.sdkVersion).varint(9, config.sdkVersionInt).bytes(10, new Uint8Array(8)).varint(11, 0).varint(12, timestamp * 2).bytes(13, bodyHash).bytes(14, queryHash).message(15, (sub) => { sub.varint(1, 1).varint(2, 1).varint(3, 1).varint(7, 3348294860); }).string(16, "").string(20, "none").varint(21, config.callType).message(23, (sub) => { sub.string(1, "NX551J").varint(2, 8196).varint(4, 2162219008); }).varint(25, 2).toBytes(); } async function getArgus(query, xssStub, timestamp, config) { const { signKey } = config; if (signKey.length !== 32) { throw new Error(`Sign key must be 32 bytes, got ${signKey.length}`); } const protobuf = pkcs7Pad(buildProtobuf(query, xssStub, timestamp, config), 16); const sm3Input = new Uint8Array(signKey.length * 2 + LOW_RAND.length + HIGH_RAND.length); sm3Input.set(signKey, 0); sm3Input.set(LOW_RAND, signKey.length); sm3Input.set(HIGH_RAND, signKey.length + LOW_RAND.length); sm3Input.set(signKey, signKey.length + LOW_RAND.length + HIGH_RAND.length); const sm3Output = sm3(sm3Input); const keyView = new DataView(sm3Output.buffer, sm3Output.byteOffset, sm3Output.byteLength); const simonKey = [ keyView.getBigUint64(0, true), keyView.getBigUint64(8, true), keyView.getBigUint64(16, true), keyView.getBigUint64(24, true) ]; const encrypted = new Uint8Array(protobuf.length); const pbView = new DataView(protobuf.buffer, protobuf.byteOffset, protobuf.byteLength); const encView = new DataView(encrypted.buffer); for (let offset = 0; offset < protobuf.length; offset += 16) { const [low, high] = simonEncrypt( [pbView.getBigUint64(offset, true), pbView.getBigUint64(offset + 8, true)], simonKey ); encView.setBigUint64(offset, low, true); encView.setBigUint64(offset + 8, high, true); } const data = new Uint8Array(XOR_PREFIX.length + encrypted.length); data.set(XOR_PREFIX); data.set(encrypted, XOR_PREFIX.length); for (let i2 = XOR_PREFIX.length; i2 < data.length; i2++) { data[i2] ^= data[i2 % 8]; } data.reverse(); const header = new Uint8Array([166, 110, 173, 159, 119, 1, 208, 12, 24]); const plaintext = new Uint8Array(header.length + data.length + HIGH_RAND.length); plaintext.set(header); plaintext.set(data, header.length); plaintext.set(HIGH_RAND, header.length + data.length); const subtle = getSubtle(); const aesKey = await subtle.importKey( "raw", await hash.md5bytes(signKey.slice(0, 16)), { name: "AES-CBC" }, false, ["encrypt"] ); const iv = await hash.md5bytes(signKey.slice(16)); const ciphertext = new Uint8Array( await subtle.encrypt({ name: "AES-CBC", iv }, aesKey, plaintext) ); const result = new Uint8Array(LOW_RAND.length + ciphertext.length); result.set(LOW_RAND); result.set(ciphertext, LOW_RAND.length); return b64encode(result.buffer); } const ROUNDS = 34; const MASK64 = 0xffffffffffffffffn; const WORD_SIZE = 64n; const ALPHA = 8n; const BETA = 3n; function readUint64LE(view, offset) { return view.getBigUint64(offset, true); } function keySchedule(key) { const view = new DataView(key.buffer, key.byteOffset, key.byteLength); const ks = [readUint64LE(view, 0) & MASK64]; const numWords = key.length * 8 / Number(WORD_SIZE); const ls = []; for (let i2 = 1; i2 < numWords; i2++) { ls.push(readUint64LE(view, i2 * 8) & MASK64); } for (let x = 0; x < ROUNDS - 1; x++) { const rsX = (ls[x] << WORD_SIZE - ALPHA) + (ls[x] >> ALPHA) & MASK64; const addSxy = rsX + ks[x] & MASK64; const newX = BigInt(x) ^ addSxy; const lsY = (ks[x] >> WORD_SIZE - BETA) + (ks[x] << BETA) & MASK64; ls.push(newX); ks.push(newX ^ lsY); } return ks; } function encryptBlock(ks, block, out, outOffset) { const view = new DataView(block.buffer, block.byteOffset, block.byteLength); let y = readUint64LE(view, 0); let x = readUint64LE(view, 8); for (const k of ks) { const rsX = (x << WORD_SIZE - ALPHA) + (x >> ALPHA) & MASK64; const addSxy = rsX + y & MASK64; x = k ^ addSxy; const lsY = (y >> WORD_SIZE - BETA) + (y << BETA) & MASK64; y = x ^ lsY; } const outView = new DataView(out.buffer, out.byteOffset, out.byteLength); outView.setBigUint64(outOffset, y & MASK64, true); outView.setBigUint64(outOffset + 8, x & MASK64, true); } function speckEncrypt(key, plaintext) { if (key.length !== 32) { throw new Error(`Speck key must be 32 bytes, got ${key.length}`); } const padded = pkcs7Pad(plaintext, 16); const ks = keySchedule(key); const out = new Uint8Array(padded.length); for (let i2 = 0; i2 < padded.length; i2 += 16) { encryptBlock(ks, padded.subarray(i2, i2 + 16), out, i2); } return out; } async function generateLadonKey(randomBytes, aid) { const aidBytes = new TextEncoder().encode(aid); const input = new Uint8Array(randomBytes.length + aidBytes.length); input.set(randomBytes); input.set(aidBytes, randomBytes.length); const hex2 = await hash.md5(input); return new TextEncoder().encode(hex2); } async function getLadon(timestamp, config) { const randomBytes = getCrypto().getRandomValues(new Uint8Array(4)); const plaintext = new TextEncoder().encode( `${timestamp}-${config.licenseId}-${config.aid}` ); const key = await generateLadonKey(randomBytes, config.aid); const encrypted = speckEncrypt(key, plaintext); const result = new Uint8Array(randomBytes.length + encrypted.length); result.set(randomBytes); result.set(encrypted, randomBytes.length); return b64encode(result.buffer); } const defaultUnidbgConfig = { signKey: new Uint8Array( unhex("ac1adaae95a7af94a5114ab3b3a97dd80050aa0a39314c40528caec95256c28c") ), aid: "1967", licenseId: "1611921764", sdkVersion: "v04.04.05-ov-android", sdkVersionInt: 134744640, callType: 738 }; async function generateHeaders(rawQuery, xssStub = "", timestamp = Math.floor(Date.now() / 1e3), config = defaultUnidbgConfig) { const [argus, ladon] = await Promise.all([ getArgus(rawQuery, xssStub, timestamp, config), getLadon(timestamp, config) ]); return { "x-argus": argus, "x-ladon": ladon, "x-khronos": String(timestamp) }; } async function signRequest(url, body, config = defaultUnidbgConfig) { const rawQuery = new URL(url).search.replace(/^\?/, ""); const hasBody = typeof body === "string" ? body.length > 0 : ((body == null ? void 0 : body.byteLength) ?? 0) > 0; const xssStub = hasBody ? await hash.md5(body) : ""; const now = Date.now(); const headers = await generateHeaders( rawQuery, xssStub, Math.floor(now / 1e3), config ); headers["x-ss-req-ticket"] = String(now); if (hasBody) { headers["X-SS-STUB"] = xssStub; } return headers; } async function gzip(data) { if (typeof data === "string") { data = new TextEncoder().encode(data).buffer; } const encoder = new CompressionStream("gzip"); const stream = new Blob([data]).stream().pipeThrough(encoder); const compressed = new Response(stream).arrayBuffer(); return compressed; } async function gunzip(data) { const decoder = new DecompressionStream("gzip"); const stream = new Blob([data]).stream().pipeThrough(decoder); const decompressed = new Response(stream).arrayBuffer(); return decompressed; } const FIXED_STRING = b64decode( "TdTC5rgxYgkOUrPHpnM7pByyRiuCmrWKGWs521cXdST0m69/COjWjSanLjfBqVovHwWlGJKu8pSXMrYqOKrdWA==" ); async function encrypt(data) { const crypto = getCrypto(); const subtle = getSubtle(); const randomBytes = crypto.getRandomValues(new Uint8Array(32)); const hashValue = await hash.sha512bytes( concatArrayBuffers(await hash.sha512bytes(randomBytes), FIXED_STRING) ); const k = hashValue.slice(0, 16); const iv = hashValue.slice(16, 32); const compressedData = await gzip(data); const hashedData = concatArrayBuffers( await hash.sha512bytes(compressedData), compressedData ); const key = await subtle.importKey("raw", k, { name: "AES-CBC", length: 128 }, false, ["encrypt"]); const encryptedData = await subtle.encrypt({ name: "AES-CBC", iv }, key, hashedData); return concatArrayBuffers( new Uint8Array([116, 99, 5, 16, 0, 0]).buffer, randomBytes.buffer, encryptedData ); } const ANDROID_VERSIONS = [ { version: "9", api: 29 }, { version: "10", api: 30 }, { version: "11", api: 31 }, { version: "12", api: 32 }, { version: "13", api: 33 }, { version: "14", api: 34 } ]; const DEVICE_MODELS = [ "RMX1931", "MI8", "Honor10", "P30", "V1921A", "Redmi Note 7", "Redmi K20 Pro", "MI 9", "Mi 10 Pro", "SM-G9750", "Pixel 6", "HD1910", "M2011K2C", "LIO-AN00", "VOG-TL00", "PCLM10", "GM1900", "Pixel 7 Pro", "Pixel 6a", "SM-N9760", "POCO F1" ]; const DEVICE_BRANDS = [ "realme", "Xiaomi", "Huawei", "OPPO", "vivo", "samsung", "OnePlus", "google", "Redmi", "HONOR", "motorola", "POCO" ]; const HEX_LOW = "0123456789abcdef"; const deviceValue = { aid: "1967", appName: "novelapp", channel: "0", platform: "android", osVersion: "0", versionCode: { str: "6.3.9.32", val: "63932" } }; function randomInt(max) { return getCrypto().getRandomValues(new Uint32Array(1))[0] % max; } function randomItem(list) { return list[randomInt(list.length)]; } function randomHex(k) { if (k <= 0) return ""; const bytes = getCrypto().getRandomValues(new Uint8Array(k)); let result = ""; for (let i2 = 0; i2 < k; i2++) { result += HEX_LOW[bytes[i2] % 16]; } return result; } function uuid() { const c = getCrypto(); if (typeof c.randomUUID === "function") { return c.randomUUID(); } const b = c.getRandomValues(new Uint8Array(16)); b[6] = b[6] & 15 | 64; b[8] = b[8] & 63 | 128; const h2 = [...b].map((x) => x.toString(16).padStart(2, "0")).join(""); return `${h2.slice(0, 8)}-${h2.slice(8, 12)}-${h2.slice(12, 16)}-${h2.slice(16, 20)}-${h2.slice(20)}`; } function ipv6LinkLocal() { return "::1"; } function ipv6UniqueLocal() { const x = randomInt(1 << 8); let result = `fd${HEX_LOW[x >> 4]}${HEX_LOW[x & 15]}`; for (let i2 = 0; i2 < 7; i2++) { const v = randomInt(1 << 16); result += ":" + HEX_LOW[v >> 12 & 15] + HEX_LOW[v >> 8 & 15] + HEX_LOW[v >> 4 & 15] + HEX_LOW[v & 15]; } return result; } function generateRequestBody() { const osInfo = randomItem(ANDROID_VERSIONS); const deviceBrand = randomItem(DEVICE_BRANDS); const genTime = Date.now(); const romVersion = "coloros__" + randomHex(4).toUpperCase() + "." + String(randomInt(1e6)).padStart(6, "0") + "." + String(randomInt(1e8)).padStart(8, "0") + " release-keys"; return { magic_tag: "ss_app_log", header: { display_name: "番茄免费小说", aid: 1967, channel: "43536163a", package: "com.dragon.read", sdk_version: "3.7.0-rc.25-fanqie-xiaoshuo", sdk_target_version: 29, git_hash: "711d1a7", density_dpi: 240, display_density: "hdpi", resolution: "720x1280", language: "zh", timezone: 8, access: "wifi", not_request_sender: 0, carrier: "CHINA MOBILE", mcc_mnc: "46000", region: "CN", tz_name: "Asia/Shanghai", tz_offset: 28800, sim_region: "cn", sim_serial_number: [], oaid_may_support: false, device_platform: "android", custom: { host_bit: 32, dragon_device_type: 0 }, pre_installed_channel: "", is_system_app: 0, sdk_flavor: "china", guest_mode: 0, // 设备硬件与系统信息 os: "Android", os_version: osInfo.version, os_api: osInfo.api, device_model: randomItem(DEVICE_MODELS), device_brand: deviceBrand, device_manufacturer: deviceBrand, cpu_abi: "arm64-v8a", release_build: randomHex(7), cdid: uuid(), sig_hash: "a4a27c2633195374c15651ffc3c4a497", openudid: randomHex(20), clientudid: uuid(), req_id: uuid(), // 可选字段 rom: randomHex(14).toUpperCase(), rom_version: romVersion, apk_first_install_time: genTime - randomInt(365) * 864e5, ipv6_list: [ { type: "client_anpi", value: ipv6LinkLocal() }, { type: "client_anpi", value: ipv6UniqueLocal() }, { type: "client_anpi", value: ipv6UniqueLocal() } ] }, _gen_time: genTime }; } const REGISTER_URL = "https://i.snssdk.com/service/2/device_register/?tt_data=a"; const READING_BASE = "https://reading.snssdk.com"; const USER_AGENT = "com.dragon.read"; const APP_REQUEST = { credentials: "omit" }; function buildQuery(device, versionCode, extra) { return new URLSearchParams({ device_id: device.device_id, iid: device.install_id, device_type: device.device_type, aid: deviceValue.aid, app_name: deviceValue.appName, channel: deviceValue.channel, device_platform: deviceValue.platform, os_version: deviceValue.osVersion, version_code: versionCode, ...extra }); } async function registerDevice() { const body = generateRequestBody(); const encrypted = await encrypt( new TextEncoder().encode(JSON.stringify(body)).buffer ); const res = await apiFetch(REGISTER_URL, { ...APP_REQUEST, method: "POST", headers: { "User-Agent": "okhttp/4.10.0", "Content-Type": "application/octet-stream; tt-data=a" }, body: encrypted }); if (res.status !== 200) { throw new Error(`设备注册失败: HTTP ${res.status} ${res.statusText}`); } const json = res.json(); if (!(json == null ? void 0 : json.device_id) || !json.device_id_str || !json.install_id_str) { throw new Error(`设备注册失败: device_id 无效, 响应=${res.responseText}`); } const device = { device_id: json.device_id_str, install_id: json.install_id_str, device_type: body.header.device_model }; console.log("设备注册成功!", device); return device; } async function activatePremium(device) { var _a; const url = `${READING_BASE}/reading/user/privilege/add/v?` + buildQuery(device, deviceValue.versionCode.val, { manifest_version_code: deviceValue.versionCode.val, update_version_code: deviceValue.versionCode.val }).toString(); const body = `{"add_count_daily":0,"amount":2592000,"privilege_id":7210376203117531962,"from":8,"unique_key":"${Date.now()}"}`; try { const headers = await signRequest(url, body); const res = await apiFetch(url, { ...APP_REQUEST, method: "POST", headers: { ...headers, "User-Agent": USER_AGENT, "Content-Type": "application/json; charset=utf-8" }, body }); const json = res.json(); if ((json == null ? void 0 : json.code) !== 0) { console.warn("设备会员激活失败:", res.responseText); return ""; } const expireTime = ((_a = json.data) == null ? void 0 : _a.expire_time) ?? ""; console.log("设备会员已成功激活!过期时间:", expireTime); return expireTime; } catch (e) { console.warn("设备会员激活失败:", e); return ""; } } async function registerKey(device) { var _a, _b; const url = `${READING_BASE}/reading/crypt/registerkey?` + buildQuery(device, deviceValue.versionCode.str).toString(); const idBytes = new Uint8Array(16); let id = BigInt(device.device_id); for (let i2 = 15; i2 >= 0; i2--) { idBytes[i2] = Number(id & 0xffn); id >>= 8n; } idBytes.reverse(); const subtle = getSubtle(); const iv = getCrypto().getRandomValues(new Uint8Array(16)); const key = await subtle.importKey("raw", shared_key, { name: "AES-CBC" }, false, ["encrypt"]); const encrypted = new Uint8Array( await subtle.encrypt({ name: "AES-CBC", iv }, key, idBytes) ); const content = new Uint8Array(iv.length + encrypted.length); content.set(iv); content.set(encrypted, iv.length); const plainBody = JSON.stringify({ content: b64encode(content.buffer) }); const gzipped = await gzip(plainBody); const headers = await signRequest(url, gzipped); const res = await apiFetch(url, { ...APP_REQUEST, method: "POST", headers: { ...headers, "User-Agent": USER_AGENT, "Content-Type": "application/json; charset=utf-8", "Content-Encoding": "gzip" }, body: gzipped }); if (res.status !== 200) { throw new Error(`密钥注册失败: HTTP ${res.status} ${res.statusText}`); } const json = res.json(); const encryptedKey = (_a = json == null ? void 0 : json.data) == null ? void 0 : _a.key; if (!encryptedKey) { throw new Error(`密钥注册失败: 响应缺少 key, 响应=${res.responseText}`); } const buf = b64decode(encryptedKey); const decryptKey = await subtle.importKey( "raw", shared_key, { name: "AES-CBC" }, false, ["decrypt"] ); const finalKey = await subtle.decrypt( { name: "AES-CBC", iv: buf.slice(0, 16) }, decryptKey, buf.slice(16) ); const keyInfo = { key: finalKey, keyver: (_b = json.data) == null ? void 0 : _b.keyver }; console.log("密钥获取成功,版本:", keyInfo.keyver, "key:", hex(finalKey)); return keyInfo; } const STORE_KEY = "device"; function load() { const s = read(STORE_KEY); if (!(s == null ? void 0 : s.device_id) || !(s == null ? void 0 : s.install_id)) return null; if (s.key) { if (!read("keyinfo")) { write("keyinfo", { key: s.key, keyver: s.keyver }); } delete s.key; delete s.keyver; write(STORE_KEY, s); } return { device_id: s.device_id, install_id: s.install_id, device_type: s.device_type, device_brand: s.device_brand }; } function save(c, vipExpireTime) { const s = { device_id: c.device_id, install_id: c.install_id, device_type: c.device_type, device_brand: c.device_brand, vip_expire_time: vipExpireTime }; write(STORE_KEY, s); } async function provisionDevice() { const dev = await registerDevice(); const vipExpireTime = await activatePremium(dev); const keyInfo = await registerKey(dev); const c = { device_id: dev.device_id, install_id: dev.install_id, device_type: dev.device_type, key_info: keyInfo }; _config.currentConfig = c; save(c, vipExpireTime); write("keyinfo", { key: b64encode(keyInfo.key), keyver: keyInfo.keyver }); return c; } async function ensureDevice() { const { deviceId, installId, deviceType } = settings$1; if (deviceId.trim() && installId.trim()) { const manual = { device_id: deviceId.trim(), install_id: installId.trim(), device_type: deviceType.trim() || void 0, // 手填设备没有密钥,正文接口会按需自行注册 key_info: void 0 }; _config.currentConfig = manual; console.log("使用设置里手填的设备:", manual.device_id); return manual; } const cached = load(); if (cached) { _config.currentConfig = cached; console.log("复用已缓存设备:", cached.device_id); return cached; } try { return await provisionDevice(); } catch (e) { console.warn("设备注册失败,回退到内置匿名设备:", e); _config.currentConfig = defaultConfig; return defaultConfig; } } let replaceInflight = null; let unverified = false; function markDeviceHealthy() { unverified = false; } function replaceDevice() { if (replaceInflight) return replaceInflight; replaceInflight = doReplace().finally(() => { replaceInflight = null; }); return replaceInflight; } async function doReplace() { if (unverified) { console.warn( "[fqa:device] 刚换过设备仍然收到空响应,问题多半不在设备(检查网络代理是否拦了 reading.snssdk.com)" ); return null; } if (settings$1.deviceId.trim() && settings$1.installId.trim()) { console.warn("[fqa:device] 当前是手填设备,不自动替换。若接口持续失败,请到设置里清空或改用自动注册"); return null; } unverified = true; console.warn("[fqa:device] 当前设备已被服务端作废,正在注册新设备…"); try { del("keyinfo"); _config.currentConfig.key_info = void 0; const c = await provisionDevice(); console.log("[fqa:device] 已换到新设备:", c.device_id); return c; } catch (e) { console.error("[fqa:device] 注册新设备失败:", e); return null; } } const appBaseUrl = "https://reading.snssdk.com/reading"; const redcandleBaseUrl = "https://api5-sinfonlinec.jxbhmy.com/reading"; const webBaseUrl = "https://fanqienovel.com/reading"; const appUserAgent = "com.dragon.read"; function buildAppQuery(extra) { const c = _config.currentConfig; return new URLSearchParams({ iid: c.install_id, device_id: c.device_id, ac: "wifi", channel: "43536163a", aid: "1967", app_name: "novelapp", version_code: "70132", version_name: "7.0.1.32", device_platform: "android", os: "android", ssmix: "a", os_version: "10", device_type: c.device_type || "P30", device_brand: c.device_brand || "realme", update_version_code: "70132", manifest_version_code: "70132", pv_player: "70132", ...extra }); } async function webGet(path, query, credentials2 = "omit") { const url = `${webBaseUrl}${path}?${buildAppQuery(query).toString()}`; const signed = await signRequest(url); const res = await fetch$1(url, { headers: signed, credentials: credentials2 }); if (!res.ok) { throw new Error(`请求失败(${res.status})`); } return res.json(); } function isUsable(res) { if (!res || res.status !== 200) return false; try { const j = res.json(); return !j || j.code === void 0 || j.code === 0; } catch { return false; } } async function requestApp(path, query, headers) { const url = `${appBaseUrl}${path}?${buildAppQuery(query).toString()}`; const signed = await signRequest(url); return apiFetch(url, { method: "GET", headers: { ...signed, "User-Agent": appUserAgent, ...headers } }); } async function requestRedcandle(path, query, headers) { const url = `${redcandleBaseUrl}${path}?${buildAppQuery(query).toString()}`; return apiFetch(url, { method: "GET", headers: { "User-Agent": appUserAgent, ...headers } }); } async function requestAppWithRecovery(path, query, headers) { const res = await requestApp(path, query, headers); if (!isEmptyResponse(res)) { markDeviceHealthy(); return res; } console.warn(`[fqa:api] ${path} 返回空响应体,判定当前设备已失效`); if (!await replaceDevice()) return res; const retry = await requestApp(path, query, headers); if (!isEmptyResponse(retry)) markDeviceHealthy(); return retry; } async function appGet(path, query, headers) { if (settings$1.apiPreference === "redcandle") { try { const res = await requestRedcandle(path, query, headers); if (isUsable(res)) return res; console.warn(`[fqa:api] 红烛接口数据不全,回落到番茄 APP: ${path}`); } catch (e) { console.warn(`[fqa:api] 红烛接口请求失败,回落到番茄 APP: ${path}`, e); } } return requestAppWithRecovery(path, query, headers); } async function appPost(path, body, query, headers) { return postSigned(appBaseUrl + path, body, query, headers); } async function postSigned(base, body, query, headers) { const url = `${base}?${buildAppQuery(query).toString()}`; const signed = await signRequest(url, body); console.log("---start--- APP POST ", url); const res = await apiFetch(url, { method: "POST", headers: { ...signed, "User-Agent": appUserAgent, "Content-Type": "application/json; charset=utf-8", ...headers }, body }); console.log("---complete--- APP POST ", url, res); return res; } async function decryptChapter(encrypted, rawData, config = defaultConfig) { var _a; if (!encrypted) { throw new Error("Invalid encrypted chapter"); } const buf = b64decode(encrypted); const iv = buf.slice(0, 16); const data = buf.slice(16); const key = (_a = config.key_info) == null ? void 0 : _a.key; if (!key) { throw new Error("Missing decrypt key"); } const subtle = getSubtle(); const cryptoKey = await subtle.importKey( "raw", key, { name: "AES-CBC" }, false, ["decrypt"] ); return subtle.decrypt( { name: "AES-CBC", iv }, cryptoKey, data ).then(async (decrypted) => { if (rawData && (rawData == null ? void 0 : rawData.compress_status) === 1) { decrypted = await gunzip(decrypted); } const decoder = new TextDecoder(); const plain = decoder.decode(decrypted); if (plain.trim().startsWith("<")) { return plain; } try { return JSON.parse(plain); } catch (e) { console.warn("Invalid chapter content: ", plain, e); return void 0; } }); } async function decryptComicImage(image, key) { const subtle = getSubtle(); const cryptoKey = await subtle.importKey( "raw", unhex(key), { name: "AES-GCM" }, false, ["decrypt"] ); const iv = image.slice(0, 12); const data = image.slice(12); return await subtle.decrypt( { name: "AES-GCM", iv }, cryptoKey, data ); } function reverseHex(value) { const be = BigInt(value).toString(16).padStart(32, "0"); let result = ""; for (let i2 = be.length; i2 > 0; i2 -= 2) result += be.slice(i2 - 2, i2); return result; } async function encryptKeyinfoBody(config) { const deviceId = config.device_id; const iv = new TextEncoder().encode(randomString(16)); const data = new Uint8Array(unhex(reverseHex(deviceId))).slice(0, 8); console.log(data); const subtle = getSubtle(); const k = await subtle.importKey( "raw", shared_key, { name: "AES-CBC" }, false, ["encrypt"] ); const encrypted = await subtle.encrypt( { name: "AES-CBC", iv }, k, data ); const final = new Uint8Array(iv.length + encrypted.byteLength); console.log(final); final.set(iv, 0); final.set(new Uint8Array(encrypted), iv.length); return JSON.stringify({ content: b64encode(final.buffer) }); } async function decryptKeyinfoResponse(encrypted) { const buf = b64decode(encrypted); const iv = buf.slice(0, 16); const data = buf.slice(16); const subtle = getSubtle(); const k = await subtle.importKey( "raw", shared_key, { name: "AES-CBC" }, false, ["decrypt"] ); return subtle.decrypt( { name: "AES-CBC", iv }, k, data ); } async function refreshKeyinfo() { var _a, _b, _c; const b = await encryptKeyinfoBody(_config.currentConfig); const res = await appPost("/crypt/registerkey", b); const j = res.json(); const ek = (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.key; if (!ek) { throw new Error(`Failed to get key info: ${res.responseText}`); } const key = await decryptKeyinfoResponse(ek); const keyinfo = { key, keyver: (_b = j == null ? void 0 : j.data) == null ? void 0 : _b.keyver }; console.log("Refreshed key info:", keyinfo); _config.currentConfig.key_info = keyinfo; write("keyinfo", { key: b64encode(key), keyver: (_c = j == null ? void 0 : j.data) == null ? void 0 : _c.keyver }); } let refreshInflight = null; function refreshKey() { if (!refreshInflight) { refreshInflight = refreshKeyinfo().finally(() => { refreshInflight = null; }); } return refreshInflight; } async function ensureKeyinfo(expectedKeyVersion) { const keyinfo = _config.currentConfig.key_info; const cachedKeyInfo = read("keyinfo"); console.log("cached key info: ", cachedKeyInfo); if (cachedKeyInfo) { const cki = { key: b64decode(cachedKeyInfo.key), keyver: cachedKeyInfo.keyver }; if (typeof expectedKeyVersion === "undefined" || cki.keyver === expectedKeyVersion) { _config.currentConfig.key_info = cki; return; } } if (!keyinfo) { return await refreshKey(); } if ((keyinfo == null ? void 0 : keyinfo.keyver) !== expectedKeyVersion) { return await refreshKey(); } } async function getChapter(itemId2, _retry) { var _a, _b; if (typeof _retry === "undefined") _retry = 0; if (_retry > 5) { throw new Error(`Failed to get chapter: ${itemId2}`); } if (!_config.currentConfig.key_info) { await ensureKeyinfo(); } const res = await appGet("/reader/full/v", { item_id: itemId2, req_type: "1" }); const j = (_a = res.json()) == null ? void 0 : _a.data; if (!j) { console.warn("Failed to get chapter: ", itemId2, ", response: ", res.responseText); return await getChapter(itemId2, _retry + 1); } if ((j == null ? void 0 : j.content) === "Invalid" || (j == null ? void 0 : j.key_version) !== ((_b = _config.currentConfig.key_info) == null ? void 0 : _b.keyver)) { console.warn("Key reg expired, regster again and retrying..."); if ((j == null ? void 0 : j.content) === "Invalid") { await refreshKey(); } else { await ensureKeyinfo(parseInt(j == null ? void 0 : j.key_version)); } return await getChapter(itemId2, _retry + 1); } j.content = await decryptChapter(j == null ? void 0 : j.content, j, _config.currentConfig); return j; } async function getChapters(itemIds, bookId2 = "0", _retry = 0) { var _a, _b; if (itemIds.length === 0) return {}; if (!_config.currentConfig.key_info) { await ensureKeyinfo(); } const res = await appGet("/reader/batch_full/v", { item_ids: itemIds.join(","), book_id: bookId2, novel_text_type: "1", req_type: "1" }); const raw = (_a = res.json()) == null ? void 0 : _a.data; const entries = raw && typeof raw === "object" ? Array.isArray(raw) ? raw.map((it) => { var _a2; return [String((it == null ? void 0 : it.item_id) ?? ((_a2 = it == null ? void 0 : it.novel_data) == null ? void 0 : _a2.item_id) ?? ""), it]; }) : Object.entries(raw) : []; if (entries.length === 0) { throw new Error(`Failed to batch get chapters: ${res.responseText}`); } const localKeyver = (_b = _config.currentConfig.key_info) == null ? void 0 : _b.keyver; const expired = entries.filter( ([, item]) => (item == null ? void 0 : item.code) === 0 || (item == null ? void 0 : item.code) === void 0 ? (item == null ? void 0 : item.content) === "Invalid" || (item == null ? void 0 : item.key_version) !== void 0 && Number(item.key_version) !== localKeyver : false ); if (expired.length > 0 && _retry < 2) { const [, sample] = expired[0]; console.warn( `[fqa:api] 批量正文密钥失效(${expired.length}/${entries.length} 章),重新注册后重试。本地 keyver=${localKeyver},服务端=${sample == null ? void 0 : sample.key_version}` ); await refreshKey(); await sleep(800); return await getChapters(itemIds, bookId2, _retry + 1); } const results = {}; for (const [id, item] of entries) { if (!id) continue; if ((item == null ? void 0 : item.code) !== void 0 && item.code !== 0) { results[id] = { ...item, item_id: id, error: `code ${item.code}` }; continue; } if (!(item == null ? void 0 : item.content) || item.content === "Invalid") { results[id] = { ...item, item_id: id, error: "Invalid content" }; continue; } try { results[id] = { ...item, item_id: id, novel_data: item.novel_data, content: await decryptChapter(item.content, item, _config.currentConfig) }; } catch (e) { results[id] = { ...item, item_id: id, error: String(e) }; } } return results; } async function getCatalogRaw(bookId2) { var _a; const response = await appGet("/bookapi/directory/all_items/v", { book_id: bookId2 }); const j = response.json(); const items = (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.item_data_list; if ((j == null ? void 0 : j.code) !== 0 || !Array.isArray(items) || items.length === 0) { return [null, null]; } return [items, items.map((it) => String(it.item_id))]; } async function webCatalog(bookId2) { const url = `https://fanqienovel.com/api/reader/directory/detail?bookId=${bookId2}`; const response = await apiFetch(url); const rj = response.json(); const d = rj.data; const allItems = d.allItemIds; const cs = []; const vname = d.volumeNameList; for (let i2 = 0; i2 < vname.length; i2++) { const volumeName = vname[i2]; if (volumeName !== void 0) { cs.push(...d.chapterListWithVolume[i2]); } } return [cs, allItems]; } async function getCatalog(bookId2) { const r = await getCatalogRaw(bookId2); let catalogRaw = r[0]; let allItemIds = r[1]; console.log("catalogRaw", catalogRaw, "allItemIds", allItemIds); if (!catalogRaw || !allItemIds) { const rw = await webCatalog(bookId2); catalogRaw = rw[0]; allItemIds = rw[1]; console.log("webCatalog", catalogRaw, "allItemIds", allItemIds); } const vmap = {}; const chapters = []; catalogRaw.forEach((item) => { const volumeName = item.volume_name ?? ""; const chapterItem = { item_id: String(item.item_id || item.itemId), title: item.title, // YYYY-MM-DD HH:mm:ss update_time: moment((item.first_pass_time || item.firstPassTime) * 1e3).format("YYYY-MM-DD HH:mm:ss"), char_count: item.chapter_word_number || 0, volume_title: volumeName }; chapters.push(chapterItem); if (!vmap[volumeName]) { vmap[volumeName] = { title: volumeName, book_id: bookId2, chapter_list: [] }; } vmap[volumeName].chapter_list.push(chapterItem); }); return { book_id: bookId2, volume_list: Object.values(vmap), chapter_list: chapters, all_item_ids: allItemIds }; } function mappingCreationStatus(status) { switch (status) { case "0": return "完结"; case "1": return "连载"; case "4": return "断更"; default: return "未知"; } } async function getBookInfoRaw(bookId2) { const response = await appGet("/bookapi/detail/v", { book_id: bookId2 }); const j = response.json(); console.log("Book Info:", j); return j.data; } async function getBookInfo(bookId2) { const bookInfo = await getBookInfoRaw(bookId2); if (!bookInfo) { throw new Error("Book not found"); } return { book_id: bookInfo.book_id, title: bookInfo.book_name || bookInfo.original_book_name, author: bookInfo.author, cover_url: bookInfo.thumb_url, summary: bookInfo.abstract, // volume_list: bookInfo.volume_list, update_time: moment(bookInfo.last_chapter_first_pass_time * 1e3).format("YYYY-MM-DD HH:mm:ss"), status: mappingCreationStatus(bookInfo.creation_status) // chapter_count: bookInfo.chapter_count, }; } async function getBookInfoAndCatalog(book) { if (typeof book !== "string") { book = book.book_id; } const bookInfo = await getBookInfo(book); if (!bookInfo) { throw new Error("Book not found"); } const catalog = await getCatalog(bookInfo.book_id); console.log("Catalog:", catalog); bookInfo.volume_list = catalog.volume_list; bookInfo.chapter_list = catalog.chapter_list; return bookInfo; } const CDN_PREFIX = "https://p3-novel.byteimg.com/origin/"; const cssCache = /* @__PURE__ */ new Map(); function stripComments(css) { return css.replace(/\/\*[\s\S]*?\*\//g, ""); } function scopeSelector(selector, scope) { const s = selector.trim(); if (!s) return ""; const where = `:where(${scope})`; if (/^(body|html|:root)$/i.test(s)) return where; const m = s.match(/^(body|html)\b([\s\S]*)$/i); if (m) return `${where}${m[2]}`; return `${where} ${s}`; } function stripRootDecls(body) { return body.split(";").filter((decl) => !/^\s*(color|background|background-color|font-size)\s*:/i.test(decl)).join(";"); } function scopeCss(css, scope) { const src = stripComments(css); let out = ""; let buf = ""; let i2 = 0; while (i2 < src.length) { const ch = src[i2]; if (ch === "{") { const prelude = buf.trim(); buf = ""; i2++; if (prelude.startsWith("@")) { if (/^@(media|supports|document)\b/i.test(prelude)) { const inner2 = readBlock(src, i2); out += `${prelude}{${scopeCss(inner2.text, scope)}}`; i2 = inner2.end; } else { const inner2 = readBlock(src, i2); out += `${prelude}{${inner2.text}}`; i2 = inner2.end; } continue; } const inner = readBlock(src, i2); const selectors = prelude.split(",").map((s) => scopeSelector(s, scope)).filter(Boolean); const isRoot = selectors.length === 1 && selectors[0] === `:where(${scope})`; const declarations = isRoot ? stripRootDecls(inner.text) : inner.text; if (selectors.length && declarations.trim()) { out += `${selectors.join(",")}{${declarations}}`; } i2 = inner.end; continue; } if (ch === ";" && buf.trim().startsWith("@")) { buf = ""; i2++; continue; } buf += ch; i2++; } return out; } function readBlock(src, start) { let depth = 1; let i2 = start; while (i2 < src.length && depth > 0) { const c = src[i2]; if (c === "{") depth++; else if (c === "}") depth--; if (depth === 0) break; i2++; } return { text: src.slice(start, i2), end: Math.min(i2 + 1, src.length) }; } function parseCssMap(cssMap) { if (!cssMap || typeof cssMap !== "string") return {}; try { const parsed = JSON.parse(cssMap); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; } } async function fetchCss(uri) { const cached = cssCache.get(uri); if (cached !== void 0) return cached; try { const res = await fetch(CDN_PREFIX + uri); const text = res.ok ? await res.text() : ""; cssCache.set(uri, text); return text; } catch (e) { console.warn("获取书籍样式表失败:", uri, e); cssCache.set(uri, ""); return ""; } } async function getScopedBookCss(cssMap, scope) { const map = parseCssMap(cssMap); const uris = Object.values(map).filter(Boolean); if (uris.length === 0) return ""; const sheets = await Promise.all(uris.map(fetchCss)); return sheets.filter(Boolean).map((css) => scopeCss(css, scope)).join("\n"); } async function applyBookCss(cssMap, scope, styleId = "fqa-book-style") { const css = await getScopedBookCss(cssMap, scope); let el = document.getElementById(styleId); if (!css) { el == null ? void 0 : el.remove(); return false; } if (!el) { el = document.createElement("style"); el.id = styleId; document.head.appendChild(el); } el.textContent = css; return true; } const FOOTNOTE_CLASS = "fqa-footnote"; const FOOTNOTE_REF_CLASS = "fqa-footnote-ref"; const FOOTNOTE_LIST_CLASS = "fqa-footnote-list"; function byAttr(root, tag, attr, value) { return [...root.querySelectorAll(tag)].filter((el) => { const v = el.getAttribute(attr); if (v === null) return false; return value === void 0 || v === value; }); } function processFootnotes(root) { const notes = /* @__PURE__ */ new Map(); const sections = byAttr(root, "section", "epub:type", "footnotes"); for (const section of sections) { for (const aside of section.querySelectorAll("aside")) { const id = aside.getAttribute("id"); if (id) notes.set(id, aside.innerHTML.trim()); } } if (notes.size === 0) { for (const aside of byAttr(root, "aside", "epub:type", "footnote")) { const id = aside.getAttribute("id"); if (id) notes.set(id, aside.innerHTML.trim()); } } const collectRefs = (scope) => { const set = new Set(byAttr(scope, "a", "epub:type", "noteref")); for (const img of scope.querySelectorAll("img.bdFootnote")) { const a = img.closest('a[href^="#"]'); if (a && scope.contains(a)) set.add(a); } return [...scope.querySelectorAll("a")].filter((a) => set.has(a)); }; const inSection = (el) => sections.some((s) => s.contains(el)); const refs = collectRefs(root).filter((a) => !inSection(a)); if (refs.length === 0 && notes.size === 0) return 0; const ordered = []; const numberOf = /* @__PURE__ */ new Map(); let counter = 0; const makeSup = (num2, text) => { const sup = document.createElement("sup"); sup.className = FOOTNOTE_REF_CLASS; sup.textContent = String(num2); sup.setAttribute("role", "button"); sup.setAttribute("tabindex", "0"); sup.title = stripTags(text); return sup; }; refs.forEach((ref2) => { const href = ref2.getAttribute("href") ?? ""; const id = href.startsWith("#") ? href.slice(1) : ""; const text = notes.get(id); if (text === void 0) return; counter += 1; numberOf.set(id, counter); ordered.push({ num: counter, text }); ref2.replaceWith(makeSup(counter, text)); }); for (const [id, text] of notes) { if (numberOf.has(id)) continue; counter += 1; numberOf.set(id, counter); ordered.push({ num: counter, text }); } for (const section of sections) section.remove(); if (ordered.length > 0) { const list = document.createElement("ol"); list.className = FOOTNOTE_LIST_CLASS; for (const { num: num2, text } of ordered) { const li = document.createElement("li"); li.id = `fqa-fn-${num2}`; li.innerHTML = text; for (const inner of collectRefs(li)) { const innerId = (inner.getAttribute("href") ?? "").replace(/^#/, ""); const innerNum = numberOf.get(innerId); const innerText = notes.get(innerId); if (innerNum && innerText !== void 0) { inner.replaceWith(makeSup(innerNum, innerText)); } else { inner.remove(); } } list.appendChild(li); } const wrapper = document.createElement("section"); wrapper.className = FOOTNOTE_CLASS; const heading = document.createElement("div"); heading.className = "fqa-footnote-title"; heading.textContent = "注释"; wrapper.appendChild(heading); wrapper.appendChild(list); root.appendChild(wrapper); } for (const img of root.querySelectorAll("img.bdFootnote")) img.remove(); return ordered.length; } function stripTags(html) { const el = document.createElement("div"); el.innerHTML = html; return (el.textContent ?? "").replace(/\s+/g, " ").trim(); } const KNOWN_TIERS = /muye-reader-content-(16|20|24|28|32)\b/; function syncFootnoteFontSize(container2) { const box = container2.closest('[class*="muye-reader-content-"]'); const apply2 = () => { container2.style.removeProperty("--fqa-body-size"); if (!box || KNOWN_TIERS.test(box.className)) return; const p = container2.querySelector("p"); if (!p) return; const size = getComputedStyle(p).fontSize; if (size) container2.style.setProperty("--fqa-body-size", size); }; apply2(); if (!box) return; const holder = container2; if (holder.fqaFontObserver) return; const observer2 = new MutationObserver(apply2); observer2.observe(box, { attributes: true, attributeFilter: ["class"] }); holder.fqaFontObserver = observer2; } function bindFootnoteInteraction(container2) { syncFootnoteFontSize(container2); if (container2.dataset.fqaFootnoteBound === "1") return; container2.dataset.fqaFootnoteBound = "1"; const activate = (sup) => { var _a; const num2 = (_a = sup.textContent) == null ? void 0 : _a.trim(); if (!num2) return; const target = container2.querySelector(`#fqa-fn-${num2}`); if (!target) return; target.scrollIntoView({ behavior: "smooth", block: "center" }); target.classList.add("fqa-footnote-active"); setTimeout(() => target.classList.remove("fqa-footnote-active"), 1600); }; container2.addEventListener("click", (e) => { var _a, _b; const sup = (_b = (_a = e.target) == null ? void 0 : _a.closest) == null ? void 0 : _b.call(_a, `.${FOOTNOTE_REF_CLASS}`); if (sup) { e.preventDefault(); activate(sup); } }); container2.addEventListener("keydown", (e) => { var _a, _b; const ke = e; if (ke.key !== "Enter" && ke.key !== " ") return; const sup = (_b = (_a = ke.target) == null ? void 0 : _a.closest) == null ? void 0 : _b.call(_a, `.${FOOTNOTE_REF_CLASS}`); if (sup) { ke.preventDefault(); activate(sup); } }); } const leftIcon = ''; const playingIcon = '\n\n\n\n \n \n \n \n \n \n \n image/svg+xml\n \n \n \n \n \n \n \n \n \n\n'; const pausedIcon = '\n \n \n \n \n\n'; function decodeBase36(c) { if (c >= 48 && c <= 57) return c - 48; if (c >= 97 && c <= 122) return c - 97 + 10; return 255; } function bitCount(n) { n = n - (n >> 1 & 1431655765); n = (n & 858993459) + (n >> 2 & 858993459); return (n + (n >> 4) & 252645135) * 16843009 >> 24; } function decryptSpadeInner(spadeKey) { const result = new Uint8Array(spadeKey); const buff = new Uint8Array(2 + spadeKey.length); buff.set([250, 85], 0); buff.set(spadeKey, 2); for (let i2 = 0; i2 < result.length; i2++) { let v = (spadeKey[i2] ^ buff[i2]) - bitCount(i2) - 21; while (v < 0) { v += 255; } result[i2] = v; } return result; } function decryptSpade(spadeKeyBytes) { const spadeKeyLen = spadeKeyBytes.length; if (spadeKeyLen < 3) return ""; const paddingLen = (spadeKeyBytes[0] ^ spadeKeyBytes[1] ^ spadeKeyBytes[2]) - 48; if (spadeKeyLen < paddingLen + 2) return ""; const innerInput = spadeKeyBytes.slice(1, spadeKeyLen - paddingLen); const tmpBuff = decryptSpadeInner(innerInput); if (tmpBuff.length === 0) return ""; const skipBytes = decodeBase36(tmpBuff[0]); const decodedMessageLen = spadeKeyLen - paddingLen - 2; const endIndex = 1 + decodedMessageLen - skipBytes; if (endIndex > tmpBuff.length) return ""; const finalBytes = tmpBuff.slice(1, endIndex); return new TextDecoder("utf-8").decode(finalBytes); } function decryptSpadeA(spadeAStr) { try { const bytes = new Uint8Array(b64decode(spadeAStr)); return decryptSpade(bytes); } catch (e) { console.error("Spade parsing error", e); return ""; } } async function getPlayInfo(item_id, tone_id) { const item_ids = Array.isArray(item_id) ? item_id.join(",") : item_id; const resp = await appGet("/reader/audio/playinfo/", { item_ids, tone_id: tone_id.toString() }); const j = resp.json(); const results = []; if (Array.isArray(j.data)) { for (let i2 of j.data) { results.push({ urls: [i2.main_url, i2.backup_url].filter(Boolean), vid: i2.vid, key: i2.is_encrypt ? decryptSpadeA(i2.encryption_key) : "", item_id: i2.item_id }); } } return results; } async function getBookAvailableTones(book_id) { var _a; const resp = await appGet("/bookapi/audio/toneinfo/", { book_id }); const j = resp.json(); const results = []; if (Array.isArray((_a = j == null ? void 0 : j.data) == null ? void 0 : _a.tts_tones)) { for (let i2 of j.data.tts_tones) { results.push({ id: i2.id, name: i2.title, gender: i2.tone_gender, icon: i2.icon_url, description: i2.description }); } } return results; } async function getChapterParagraphTimeTag(item_id, tone_id) { var _a; const resp = await appGet("/reader/audio/timepoint/", { item_id, tone_id: tone_id.toString(), req_type: "1" }); const j = resp.json(); const results = []; if (Array.isArray((_a = j == null ? void 0 : j.data) == null ? void 0 : _a.time_points)) { for (let i2 of j.data.time_points) { results.push({ startms: i2.start_time, endms: i2.end_time, startidx: i2.start_para, endidx: i2.end_para, is_title: i2.start_para === 1e4 }); } } return results; } function asBytes(value) { if (value instanceof Uint8Array) { return new Uint8Array(value); } return new Uint8Array(value); } function concatBytes(...arrays) { const length = arrays.reduce((sum, array) => sum + array.byteLength, 0); const result = new Uint8Array(length); let offset = 0; for (const array of arrays) { result.set(array, offset); offset += array.byteLength; } return result; } function ascii(text) { const result = new Uint8Array(text.length); for (let i2 = 0; i2 < text.length; i2++) { result[i2] = text.charCodeAt(i2) & 255; } return result; } function hexToBytes(text) { const hex2 = text.replace(/\s+/g, ""); if (!/^[0-9a-fA-F]*$/.test(hex2) || hex2.length % 2 !== 0) { throw new Error("无效的十六进制数据"); } const result = new Uint8Array(hex2.length / 2); for (let i2 = 0; i2 < result.length; i2++) { result[i2] = parseInt(hex2.slice(i2 * 2, i2 * 2 + 2), 16); } return result; } function uintBytes(value) { if (!Number.isSafeInteger(value) || value < 0) { throw new Error(`无效的无符号整数: ${value}`); } if (value === 0) return new Uint8Array([0]); const result = []; let current = value; while (current > 0) { result.unshift(current & 255); current = Math.floor(current / 256); } return new Uint8Array(result); } function be16(value) { const result = new Uint8Array(2); new DataView(result.buffer).setUint16(0, value); return result; } function be32(value) { const result = new Uint8Array(4); new DataView(result.buffer).setUint32(0, value >>> 0); return result; } function be64(value) { const result = new Uint8Array(8); new DataView(result.buffer).setBigUint64(0, BigInt(value)); return result; } function findBytes(buffer, fourcc, from = 0, to = buffer.length - 4) { if (fourcc.length !== 4) throw new Error(`fourcc 必须是 4 个字符: ${fourcc}`); const c0 = fourcc.charCodeAt(0); const c1 = fourcc.charCodeAt(1); const c2 = fourcc.charCodeAt(2); const c3 = fourcc.charCodeAt(3); for (let i2 = from; i2 <= to; i2++) { if (buffer[i2] === c0 && buffer[i2 + 1] === c1 && buffer[i2 + 2] === c2 && buffer[i2 + 3] === c3) return i2; } return -1; } function strBytes(text) { return new TextEncoder().encode(text); } function readFourcc(buffer, offset) { return String.fromCharCode( buffer[offset] ?? 0, buffer[offset + 1] ?? 0, buffer[offset + 2] ?? 0, buffer[offset + 3] ?? 0 ); } const CONTAINERS = /* @__PURE__ */ new Set([ "moov", "trak", "mdia", "minf", "stbl", "edts", "dinf", "udta", "mvex", "meta" ]); function readBox(buffer, offset, limit) { if (offset + 8 > limit) return null; const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); const size32 = view.getUint32(offset); const type = readFourcc(buffer, offset + 4); let headerSize = 8; let size; if (size32 === 1) { if (offset + 16 > limit) return null; const extended = view.getBigUint64(offset + 8); if (extended > BigInt(Number.MAX_SAFE_INTEGER)) { throw new Error(`MP4 box 过大: ${type}`); } size = Number(extended); headerSize = 16; } else if (size32 === 0) { size = limit - offset; } else { size = size32; } if (size < headerSize || offset + size > limit) return null; return { type, offset, size, headerSize, end: offset + size }; } function walkBoxes(buffer, start, end, visit, depth = 0) { if (depth > 16) return; let offset = start; while (offset + 8 <= end) { const box = readBox(buffer, offset, end); if (!box) return; visit(box); if (CONTAINERS.has(box.type)) { const childStart = box.offset + box.headerSize + (box.type === "meta" ? 4 : 0); if (childStart < box.end) walkBoxes(buffer, childStart, box.end, visit, depth + 1); } offset = box.end; } } function findBox(boxes, type) { return boxes.find((box) => box.type === type); } function findMdatStart(buffer) { let offset = 0; while (offset + 8 <= buffer.length) { const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); const size32 = view.getUint32(offset); const type = readFourcc(buffer, offset + 4); const headerSize = size32 === 1 ? 16 : 8; if (type === "mdat") return offset + headerSize; if (size32 === 0) return -1; if (size32 === 1) { if (offset + 16 > buffer.length) return -1; const size = view.getBigUint64(offset + 8); if (size > BigInt(Number.MAX_SAFE_INTEGER)) return -1; offset += Number(size); } else { offset += size32; } } return -1; } function parseSampleSizes(box, view) { if (box.offset + 20 > box.end) throw new Error("stsz box 不完整"); const sampleSize = view.getUint32(box.offset + 12); const count = view.getUint32(box.offset + 16); const sizes = new Uint32Array(count); if (sampleSize !== 0) { sizes.fill(sampleSize); return sizes; } if (box.offset + 20 + count * 4 > box.end) throw new Error("stsz 采样表不完整"); for (let i2 = 0; i2 < count; i2++) sizes[i2] = view.getUint32(box.offset + 20 + i2 * 4); return sizes; } function parseDurations(box, sampleCount, view) { if (box.offset + 16 > box.end) throw new Error("stts box 不完整"); const entryCount = view.getUint32(box.offset + 12); const durations = new Uint32Array(sampleCount); let index = 0; for (let i2 = 0; i2 < entryCount; i2++) { const offset = box.offset + 16 + i2 * 8; if (offset + 8 > box.end) throw new Error("stts 采样表不完整"); const count = view.getUint32(offset); const duration = view.getUint32(offset + 4); for (let j = 0; j < count && index < sampleCount; j++) durations[index++] = duration; } if (index !== sampleCount) throw new Error(`stts 采样数不一致: ${index}/${sampleCount}`); return durations; } function parseIvs(box, sampleCount, view) { if (box.offset + 16 > box.end) throw new Error("senc box 不完整"); const flags = view.getUint32(box.offset + 8) & 16777215; if ((flags & 2) !== 0) throw new Error("暂不支持带 subsample encryption 的音频"); const count = view.getUint32(box.offset + 12); if (count !== sampleCount) throw new Error(`senc 采样数不一致: ${count}/${sampleCount}`); const dataStart = box.offset + 16; const dataLength = box.end - dataStart; if (count === 0 || dataLength % count !== 0) throw new Error("senc IV 表不完整"); const ivLength = dataLength / count; if (ivLength !== 8 && ivLength !== 16) throw new Error(`不支持的 CENC IV 长度: ${ivLength}`); return asBytes(new Uint8Array(view.buffer, view.byteOffset + dataStart, dataLength)); } function parseTimescale(box, view) { if (box.offset + 24 > box.end) throw new Error("mdhd box 不完整"); const version2 = view.getUint8(box.offset + 8); const offset = version2 === 1 ? box.offset + 28 : box.offset + 20; if (offset + 4 > box.end) throw new Error("mdhd timescale 缺失"); const timescale = view.getUint32(offset); if (timescale === 0) throw new Error("MP4 timescale 为 0"); return timescale; } function parseCodec(buffer, mdatStart) { const end = Math.max(0, Math.min(mdatStart - 1, buffer.length - 4)); const dOps = findBytes(buffer, "dOps", 0, end); if (dOps >= 0) { const channels2 = buffer[dOps + 5] ?? 1; const preSkip = (buffer[dOps + 6] ?? 0) << 8 | (buffer[dOps + 7] ?? 0); return { codec: "opus", channels: channels2, preSkip }; } const encodedEntry = findBytes(buffer, "enca", 0, end); const plainEntry = findBytes(buffer, "mp4a", 0, end); const entryType = encodedEntry >= 0 ? encodedEntry : plainEntry; const channels = entryType >= 0 && entryType + 22 <= buffer.length ? new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint16(entryType + 20) : 1; if (findBytes(buffer, "dfLa", 0, end) >= 0) { return { codec: "flac", channels: channels || 1, preSkip: 0 }; } if (entryType >= 0) { return { codec: "aac", channels: channels || 1, preSkip: 0 }; } throw new Error("未识别的音频编码(需要 Opus、AAC 或 FLAC)"); } function parseCencMetadata(head) { const mdatStart = findMdatStart(head); if (mdatStart < 0) throw new Error("未找到 mdat,MP4 头可能尚未拉完整"); const boxes = []; walkBoxes(head, 0, Math.min(mdatStart - 8, head.length), (box) => boxes.push(box)); const stsz = findBox(boxes, "stsz"); const stts = findBox(boxes, "stts"); const senc = findBox(boxes, "senc"); const mdhd = findBox(boxes, "mdhd"); if (!stsz || !stts || !senc || !mdhd) { throw new Error("CENC MP4 头缺少 stsz/stts/senc/mdhd"); } const view = new DataView(head.buffer, head.byteOffset, head.byteLength); const sizes = parseSampleSizes(stsz, view); const durations = parseDurations(stts, sizes.length, view); const ivs = parseIvs(senc, sizes.length, view); const timescale = parseTimescale(mdhd, view); const codec = parseCodec(head, mdatStart); const offsets = new Float64Array(sizes.length + 1); for (let i2 = 0; i2 < sizes.length; i2++) { offsets[i2 + 1] = offsets[i2] + sizes[i2]; if (!Number.isSafeInteger(offsets[i2 + 1])) throw new Error("音频采样数据超过 JavaScript 安全整数范围"); } let totalTicks = 0; for (const duration of durations) { totalTicks += duration; if (!Number.isSafeInteger(totalTicks)) throw new Error("音频总时长超过 JavaScript 安全整数范围"); } return { ...codec, sampleCount: sizes.length, sizes, ivs, durations, timescale, mdatStart, offsets, totalTicks }; } function cencIvSize(meta) { return meta.ivs.length / meta.sampleCount; } function normalizeCencKey(value) { if (typeof value === "string") { const bytes2 = hexToBytes(value); if (bytes2.length !== 16) throw new Error("AES-128 key 必须是 32 位 hex"); return bytes2; } const bytes = asBytes(value); if (bytes.length !== 16) throw new Error("AES-128 key 必须是 16 字节"); return bytes.slice(); } async function importCencKey(value) { return getSubtle().importKey( "raw", normalizeCencKey(value), { name: "AES-CTR" }, false, ["decrypt"] ); } function makeCencCounter(iv, counterStart) { if (iv.length !== 8 && iv.length !== 16) throw new Error(`无效的 CENC IV 长度: ${iv.length}`); const counter = new Uint8Array(16); counter.set(iv); if (counterStart === 1) counter[15] = 1; return counter; } async function decryptCencSample(key, iv, ciphertext, counterStart) { const data = asBytes(ciphertext); const plain = await getSubtle().decrypt( { name: "AES-CTR", counter: makeCencCounter(iv, counterStart), length: 64 }, key, data ); return new Uint8Array(plain); } function ebmlVint(value, length) { if (!Number.isSafeInteger(value) || value < 0 || length < 1 || length > 8) { throw new Error(`无效的 EBML VINT: ${value}`); } const result = new Uint8Array(length); result[0] = 128 >> length - 1; let current = value; for (let i2 = length - 1; i2 > 0; i2--) { result[i2] = current & 255; current = Math.floor(current / 256); } const max = Math.pow(2, 7 * length); if (current >= max) throw new Error(`EBML VINT 溢出: ${value}`); result[0] |= current; return result; } function ebmlSize(value) { for (let length = 1; length <= 8; length++) { if (value < Math.pow(2, 7 * length) - 1) return ebmlVint(value, length); } throw new Error(`EBML size 过大: ${value}`); } function ebmlElement(id, payload) { return concatBytes(hexToBytes(id), ebmlSize(payload.length), payload); } function mp4Box(type, payload) { return concatBytes(be32(8 + payload.length), ascii(type), payload); } function matrixIdentity() { const matrix = new Uint8Array(36); const view = new DataView(matrix.buffer); view.setUint32(0, 65536); view.setUint32(16, 65536); view.setUint32(32, 1073741824); return matrix; } function signedBe16(value) { const result = new Uint8Array(2); new DataView(result.buffer).setInt16(0, value); return result; } function buildWebmInit(channels, preSkip, durationSeconds) { const ebml = ebmlElement("1A45DFA3", concatBytes( ebmlElement("4286", hexToBytes("01")), ebmlElement("42F7", hexToBytes("01")), ebmlElement("42F2", hexToBytes("04")), ebmlElement("42F3", hexToBytes("08")), ebmlElement("4282", strBytes("webm")), ebmlElement("4287", hexToBytes("04")), ebmlElement("4285", hexToBytes("02")) )); const codecDelayNs = Math.round(preSkip * 1e9 / 48e3); const duration = new Uint8Array(8); new DataView(duration.buffer).setFloat64(0, durationSeconds, false); const info = ebmlElement("1549A966", concatBytes( ebmlElement("2AD7B1", uintBytes(1e6)), ebmlElement("4489", duration), ebmlElement("4D80", strBytes("clearKeyStreamer")), ebmlElement("5741", strBytes("clearKeyStreamer")) )); const sampleRate = new Uint8Array(4); new DataView(sampleRate.buffer).setFloat32(0, 48e3, false); const audio2 = ebmlElement("E1", concatBytes( ebmlElement("B5", sampleRate), ebmlElement("9F", uintBytes(channels)) )); const track = ebmlElement("AE", concatBytes( ebmlElement("D7", hexToBytes("01")), ebmlElement("73C5", uintBytes(1)), ebmlElement("83", hexToBytes("02")), ebmlElement("86", strBytes("A_OPUS")), ebmlElement("63A2", buildOpusHead(channels, preSkip)), ebmlElement("56AA", uintBytes(codecDelayNs)), ebmlElement("56BB", uintBytes(8e7)), audio2 )); const tracks = ebmlElement("1654AE6B", track); return concatBytes(ebml, hexToBytes("18538067"), hexToBytes("01FFFFFFFFFFFFFF"), info, tracks); } function buildOpusHead(channels, preSkip) { const head = new Uint8Array(19); head.set([79, 112, 117, 115, 72, 101, 97, 100], 0); const view = new DataView(head.buffer); head[8] = 1; head[9] = channels; view.setUint16(10, preSkip, true); view.setUint32(12, 48e3, true); view.setInt16(16, 0, true); head[18] = 0; return head; } function buildWebmCluster(baseMs, relativeMs, packets) { if (relativeMs.length !== packets.length) throw new Error("WebM 时间戳和采样数不一致"); const blocks = []; for (let i2 = 0; i2 < packets.length; i2++) { const relative = relativeMs[i2]; if (relative === void 0 || relative < -32768 || relative > 32767) { throw new Error(`WebM 相对时间戳超出 Int16: ${relative}`); } const trackAndFlags = new Uint8Array([129, 128]); blocks.push(ebmlElement("A3", concatBytes( trackAndFlags.slice(0, 1), signedBe16(relative), trackAndFlags.slice(1), packets[i2] ))); } return ebmlElement("1F43B675", concatBytes( ebmlElement("E7", uintBytes(baseMs)), concatBytes(...blocks) )); } function replaceFourcc(buffer, offset, type) { buffer.set(ascii(type), offset); } function extractSampleEntry(head, mdatStart) { const encoded = findBytes(head, "enca", 0, mdatStart - 1); const plain = findBytes(head, "mp4a", 0, mdatStart - 1); const typeOffset = encoded >= 0 ? encoded : plain; if (typeOffset < 4) throw new Error("未找到 AAC 音频采样入口"); const entryOffset = typeOffset - 4; const view = new DataView(head.buffer, head.byteOffset, head.byteLength); const size = view.getUint32(entryOffset); if (size < 8 || entryOffset + size > head.length) throw new Error("AAC 采样入口不完整"); const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)); const enca = findBytes(entry, "enca", 0); if (enca >= 0) replaceFourcc(entry, enca, "mp4a"); const sinf = findBytes(entry, "sinf", 8); if (sinf >= 0) replaceFourcc(entry, sinf, "free"); return entry; } function extractFlacEntry(head, mdatStart) { const encoded = findBytes(head, "enca", 0, mdatStart - 1); if (encoded < 4) throw new Error("未找到 FLAC 加密采样入口"); const entryOffset = encoded - 4; const view = new DataView(head.buffer, head.byteOffset, head.byteLength); const size = view.getUint32(entryOffset); if (size < 8 || entryOffset + size > head.length) throw new Error("FLAC 采样入口不完整"); const entry = new Uint8Array(head.slice(entryOffset, entryOffset + size)); replaceFourcc(entry, encoded - entryOffset, "fLaC"); const sinf = findBytes(entry, "sinf", 8); if (sinf >= 0) replaceFourcc(entry, sinf, "free"); return entry; } function buildMp4Init(sampleEntry, timescale, totalTicks) { const totalSeconds = totalTicks / timescale; const matrix = matrixIdentity(); const mvhd = mp4Box("mvhd", concatBytes( be32(0), be32(0), be32(0), be32(1e3), be32(Math.round(totalSeconds * 1e3)), be32(65536), be16(256), be16(0), new Uint8Array(8), matrix, new Uint8Array(24), be32(2) )); const tkhd = mp4Box("tkhd", concatBytes( be32(7), be32(0), be32(0), be32(1), be32(0), be32(0), new Uint8Array(8), be16(0), be16(0), be16(256), be16(0), matrix, be32(0), be32(0) )); const mdhd = mp4Box("mdhd", concatBytes( be32(0), be32(0), be32(0), be32(timescale), be32(totalTicks), be16(21956), be16(0) )); const hdlr = mp4Box("hdlr", concatBytes( be32(0), be32(0), ascii("soun"), new Uint8Array(12) )); const smhd = mp4Box("smhd", concatBytes(be32(0), be16(0), be16(0))); const url = mp4Box("url ", be32(1)); const dref = mp4Box("dref", concatBytes(be32(0), be32(1), url)); const dinf = mp4Box("dinf", dref); const stsd = mp4Box("stsd", concatBytes(be32(0), be32(1), sampleEntry)); const stts = mp4Box("stts", concatBytes(be32(0), be32(0))); const stsc = mp4Box("stsc", concatBytes(be32(0), be32(0))); const stsz = mp4Box("stsz", concatBytes(be32(0), be32(0), be32(0))); const stco = mp4Box("stco", concatBytes(be32(0), be32(0))); const stbl = mp4Box("stbl", concatBytes(stsd, stts, stsc, stsz, stco)); const minf = mp4Box("minf", concatBytes(smhd, dinf, stbl)); const mdia = mp4Box("mdia", concatBytes(mdhd, hdlr, minf)); const trak = mp4Box("trak", concatBytes(tkhd, mdia)); const trex = mp4Box("trex", concatBytes(be32(0), be32(1), be32(1), be32(0), be32(0), be32(0))); const mvex = mp4Box("mvex", trex); const moov = mp4Box("moov", concatBytes(mvhd, trak, mvex)); const ftyp = mp4Box("ftyp", concatBytes(ascii("isom"), be32(512), ascii("isomiso2mp41dash"))); return concatBytes(ftyp, moov); } function buildMp4Segment(baseDecodeTick, samples, sequence) { if (samples.length === 0) throw new Error("MP4 segment 不能没有采样"); const sampleTable = samples.flatMap((sample) => [be32(sample.duration), be32(sample.data.length)]); const mfhd = mp4Box("mfhd", concatBytes(be32(0), be32(sequence))); const tfhd = mp4Box("tfhd", concatBytes(be32(131072), be32(1))); const tfdt = mp4Box("tfdt", concatBytes(be32(16777216), be64(baseDecodeTick))); const makeTrun = (offset) => mp4Box("trun", concatBytes( be32(769), be32(samples.length), be32(offset), concatBytes(...sampleTable) )); const makeMoof = (offset) => mp4Box("moof", concatBytes( mfhd, mp4Box("traf", concatBytes(tfhd, tfdt, makeTrun(offset))) )); const mediaPayload = concatBytes(...samples.map((sample) => sample.data)); const dataOffset = makeMoof(0).length + 8; return concatBytes(makeMoof(dataOffset), mp4Box("mdat", mediaPayload)); } function codecMime(codec) { if (codec === "opus") return 'audio/webm;codecs="opus"'; if (codec === "flac") return 'audio/mp4; codecs="fLaC"'; return 'audio/mp4; codecs="mp4a.40.2"'; } function initSegment(meta, sampleEntry) { if (meta.codec === "opus") { const playableTicks = Math.max(0, meta.totalTicks - meta.preSkip); return buildWebmInit(meta.channels, meta.preSkip, playableTicks / meta.timescale); } if (!sampleEntry) throw new Error("MP4 编码缺少 sample entry"); return buildMp4Init(sampleEntry, meta.timescale, meta.totalTicks); } const DEFAULT_HEAD_BYTES = 512 * 1024; const DEFAULT_MAX_HEAD_BYTES = 8 * 1024 * 1024; const DEFAULT_SEGMENT_SECONDS = 15; const MAX_WEBM_SEGMENT_SECONDS = 30; const DEFAULT_MAX_BUFFER_AHEAD = 40; const DEFAULT_KEEP_BEHIND = 10; const DEFAULT_DECRYPT_CONCURRENCY = 24; function asError(value) { return value instanceof Error ? value : new Error(String(value)); } function abortError$1() { return new DOMException("播放器会话已取消", "AbortError"); } function isAbort(value) { return value instanceof DOMException && value.name === "AbortError"; } function waitForSourceBufferIdle(sourceBuffer, signal) { if (!sourceBuffer.updating) return Promise.resolve(); return new Promise((resolve) => { const done = () => { sourceBuffer.removeEventListener("updateend", done); sourceBuffer.removeEventListener("abort", done); sourceBuffer.removeEventListener("error", done); signal.removeEventListener("abort", done); resolve(); }; sourceBuffer.addEventListener("updateend", done); sourceBuffer.addEventListener("abort", done); sourceBuffer.addEventListener("error", done); signal.addEventListener("abort", done, { once: true }); }); } function describeMediaError(audio2) { const error = audio2.error; if (!error) return ""; const names = { 1: "MEDIA_ERR_ABORTED", 2: "MEDIA_ERR_NETWORK", 3: "MEDIA_ERR_DECODE(数据不是合法音频,多半在传输中被破坏)", 4: "MEDIA_ERR_SRC_NOT_SUPPORTED" }; const name2 = names[error.code] ?? `code ${error.code}`; return `:${name2}${error.message ? ` - ${error.message}` : ""}`; } function validatePositive(value, fallback, name2) { const result = value ?? fallback; if (!Number.isFinite(result) || result <= 0) throw new Error(`${name2} 必须是正数`); return result; } async function mapLimit(items, limit, worker) { const results = new Array(items.length); let next = 0; async function consume() { while (next < items.length) { const index = next++; results[index] = await worker(items[index]); } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => consume())); return results; } class SequentialByteStream { constructor(initial = new Uint8Array(), reader = null, openReader = null) { __publicField(this, "chunks", []); __publicField(this, "chunkOffset", 0); __publicField(this, "available", 0); __publicField(this, "reader"); __publicField(this, "openReader"); __publicField(this, "eof", false); if (initial.length > 0) { const copy = new Uint8Array(initial); this.chunks.push(copy); this.available = copy.length; } this.reader = reader; this.openReader = openReader; } /** 至少缓存 length 字节;流先结束时返回 false。 */ async bufferAtLeast(length) { while (this.available < length && !this.eof) await this.pull(); return this.available >= length; } /** 复制当前全部未消费数据,不移动读取位置。 */ snapshot() { const result = new Uint8Array(this.available); let output = 0; for (let i2 = 0; i2 < this.chunks.length; i2++) { const chunk2 = this.chunks[i2]; const start = i2 === 0 ? this.chunkOffset : 0; result.set(chunk2.subarray(start), output); output += chunk2.length - start; } return result; } async skip(length) { await this.readExactly(length); } async readExactly(length) { if (!Number.isSafeInteger(length) || length < 0) { throw new Error(`无效的流读取长度: ${length}`); } if (length === 0) return new Uint8Array(); if (!await this.bufferAtLeast(length)) { throw new Error(`音频流提前结束(还需 ${length - this.available} 字节)`); } const result = new Uint8Array(length); let output = 0; while (output < length) { const chunk2 = this.chunks[0]; const take = Math.min(length - output, chunk2.length - this.chunkOffset); result.set(chunk2.subarray(this.chunkOffset, this.chunkOffset + take), output); output += take; this.chunkOffset += take; this.available -= take; if (this.chunkOffset === chunk2.length) { this.chunks.shift(); this.chunkOffset = 0; } } return result; } async cancel() { var _a; this.eof = true; try { await ((_a = this.reader) == null ? void 0 : _a.cancel()); } catch { } this.reader = null; } async pull() { if (!this.reader && this.openReader) this.reader = await this.openReader(); if (!this.reader) { this.eof = true; return; } const { value, done } = await this.reader.read(); if (done) { this.eof = true; return; } if (value == null ? void 0 : value.length) { const copy = new Uint8Array(value); this.chunks.push(copy); this.available += copy.length; } } } class CencAudioPlayer { constructor(audio2, options = {}) { __publicField(this, "audio"); __publicField(this, "options"); __publicField(this, "state", "idle"); __publicField(this, "session", 0); __publicField(this, "streamToken", 0); __publicField(this, "seekToken", 0); __publicField(this, "sessionController", null); __publicField(this, "streamController", null); __publicField(this, "sourceBuffer", null); __publicField(this, "mediaSource", null); __publicField(this, "objectUrl", null); __publicField(this, "sourceBufferChain", Promise.resolve()); __publicField(this, "context", null); __publicField(this, "info", null); __publicField(this, "autoplay", false); __publicField(this, "segmentSequence", 0); __publicField(this, "onSeeking", () => { const context = this.context; if (!context || this.state === "destroyed" || this.session === 0) return; if (this.audio.error) return; const target = this.audio.currentTime; if (!Number.isFinite(target) || this.isTimeBuffered(target)) return; const wasPlaying = !this.audio.paused; void this.restartAt(target, wasPlaying); }); __publicField(this, "onPlay", () => { if (this.context && this.state !== "destroyed") this.emitState("playing"); }); __publicField(this, "onPause", () => { if (this.context && this.state === "playing" && !this.audio.ended) this.emitState("paused"); }); __publicField(this, "onEnded", () => { if (this.context && this.state !== "destroyed") this.emitState("ended"); }); this.audio = audio2; const fetcher = (options.fetch ?? fetch$1).bind(unsafeWindow); this.options = { headBytes: validatePositive(options.headBytes, DEFAULT_HEAD_BYTES, "headBytes"), maxHeadBytes: validatePositive(options.maxHeadBytes, DEFAULT_MAX_HEAD_BYTES, "maxHeadBytes"), segmentSeconds: validatePositive(options.segmentSeconds, DEFAULT_SEGMENT_SECONDS, "segmentSeconds"), maxBufferAheadSeconds: validatePositive(options.maxBufferAheadSeconds, DEFAULT_MAX_BUFFER_AHEAD, "maxBufferAheadSeconds"), keepBehindSeconds: validatePositive(options.keepBehindSeconds, DEFAULT_KEEP_BEHIND, "keepBehindSeconds"), decryptConcurrency: Math.max(1, Math.floor(validatePositive(options.decryptConcurrency, DEFAULT_DECRYPT_CONCURRENCY, "decryptConcurrency"))), onStateChange: options.onStateChange, onProgress: options.onProgress, onMessage: options.onMessage, // config.fetch 保存的是页面 fetch 的原始引用,避免被字节 SDK 改写。 fetch: fetcher }; this.audio.addEventListener("seeking", this.onSeeking); this.audio.addEventListener("play", this.onPlay); this.audio.addEventListener("pause", this.onPause); this.audio.addEventListener("ended", this.onEnded); } get currentState() { return this.state; } get mediaInfo() { return this.info; } get element() { return this.audio; } /** 加载并开始后台缓冲;autoplay 只表示首次缓冲后尝试调用 audio.play。 */ async load(source, autoplay = false) { this.ensureAlive(); const session = this.beginSession(); this.autoplay = autoplay; this.emitState("loading"); let initialStream = null; try { const url = this.validateUrl(source.url); this.streamController = new AbortController(); initialStream = this.createNetworkStream(url, 0, this.streamController.signal); let { head, meta } = await this.parseHeadFromStream(initialStream, session); this.assertSession(session); const key = await importCencKey(source.key); this.assertSession(session); let counterStart = 0; if (meta.codec === "opus") { const probeEnd = meta.mdatStart + meta.offsets[Math.min(6, meta.sampleCount)]; if (head.length < probeEnd) { await initialStream.bufferAtLeast(probeEnd); head = initialStream.snapshot(); } counterStart = await this.detectCounterStart(meta, head, key); } this.assertSession(session); const sampleEntry = meta.codec === "aac" ? extractSampleEntry(head, meta.mdatStart) : meta.codec === "flac" ? extractFlacEntry(head, meta.mdatStart) : void 0; const mime = codecMime(meta.codec); const ctor = this.mediaSourceConstructor(); if (!ctor.isTypeSupported(mime)) throw new Error(`浏览器不支持 MSE 音频格式: ${mime}`); const mediaSource = new ctor(); this.mediaSource = mediaSource; this.objectUrl = unsafeWindow.URL.createObjectURL(mediaSource); const opened = this.waitForSourceOpen(mediaSource, session); this.audio.src = this.objectUrl; await opened; this.assertSession(session); this.sourceBuffer = mediaSource.addSourceBuffer(mime); this.segmentSequence = 0; const init2 = initSegment(meta, sampleEntry); this.emitMessage(`追加 init 段 ${init2.byteLength} 字节 (${mime})`, "info"); await this.enqueueSourceBuffer(session, (sourceBuffer) => { sourceBuffer.appendBuffer(this.toPageBuffer(init2)); }); this.assertSession(session); const ticks = new Float64Array(meta.sampleCount + 1); for (let i2 = 0; i2 < meta.sampleCount; i2++) { ticks[i2 + 1] = ticks[i2] + meta.durations[i2]; } const durationSeconds = meta.totalTicks / meta.timescale; const samplesPerSecond = meta.sampleCount / durationSeconds; const segmentSeconds = meta.codec === "opus" ? Math.min(this.options.segmentSeconds, MAX_WEBM_SEGMENT_SECONDS) : this.options.segmentSeconds; this.context = { url, head, meta, key, counterStart, ticks, samplesPerSecond, samplesPerSegment: Math.max(1, Math.round(samplesPerSecond * segmentSeconds)) }; this.info = { codec: meta.codec, sampleCount: meta.sampleCount, durationSeconds, timescale: meta.timescale, channels: meta.channels, encryptedBytes: meta.offsets[meta.sampleCount] }; await initialStream.skip(meta.mdatStart); this.assertSession(session); this.emitState("ready"); this.startStream(0, initialStream); initialStream = null; return this.info; } catch (error) { void (initialStream == null ? void 0 : initialStream.cancel()); if (this.isCurrent(session) && !isAbort(error)) { this.emitMessage(asError(error).message, "error"); void this.reportAppendFailure(); this.emitState("error"); } throw error; } } /** load(..., true) 的便捷形式。 */ start(source) { return this.load(source, true); } play() { this.ensureAlive(); return this.audio.play().then(() => void 0); } pause() { this.audio.pause(); } /** 停止当前流并释放 MSE URL,但保留播放器实例以便再次 load。 */ stop() { if (this.state === "destroyed") return; this.beginSession(); this.emitState("idle"); } destroy() { if (this.state === "destroyed") return; this.beginSession(); this.audio.removeEventListener("seeking", this.onSeeking); this.audio.removeEventListener("play", this.onPlay); this.audio.removeEventListener("pause", this.onPause); this.audio.removeEventListener("ended", this.onEnded); this.emitState("destroyed"); } ensureAlive() { if (this.state === "destroyed") throw new Error("播放器已销毁"); } beginSession() { var _a, _b; this.session++; this.streamToken++; this.seekToken++; (_a = this.sessionController) == null ? void 0 : _a.abort(); (_b = this.streamController) == null ? void 0 : _b.abort(); this.sessionController = new AbortController(); this.streamController = null; this.context = null; this.info = null; this.autoplay = false; this.sourceBuffer = null; this.mediaSource = null; this.sourceBufferChain = Promise.resolve(); this.audio.pause(); if (this.audio.src) { this.audio.removeAttribute("src"); this.audio.load(); } if (this.objectUrl) { unsafeWindow.URL.revokeObjectURL(this.objectUrl); this.objectUrl = null; } return this.session; } validateUrl(raw) { const url = raw.trim(); const parsed = new URL(url); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { throw new Error("音频 URL 必须是 HTTP(S) 地址"); } return parsed.toString(); } mediaSourceConstructor() { const win2 = unsafeWindow; const ctor = win2.MediaSource ?? globalThis.MediaSource; if (!ctor) throw new Error("当前浏览器没有 MediaSource 支持"); return ctor; } /** * 把字节搬到页面 realm,再交给 appendBuffer。 * * MediaSource 取自 unsafeWindow(页面 realm),而我们构造的 Uint8Array * 属于用户脚本沙箱 realm。跨 realm 的 ArrayBuffer 传进 appendBuffer 会被拒, * 表现是 SourceBuffer 立刻抛 error 事件、而 .error 仍是 null * —— 只看报错完全看不出是 realm 问题。 * * 页面 realm 没暴露 Uint8Array 时(少见)就原样返回,交给浏览器自己判断。 */ toPageBuffer(bytes) { const win2 = unsafeWindow; const PageUint8Array = win2.Uint8Array; if (!PageUint8Array || PageUint8Array === Uint8Array) return bytes; const copy = new PageUint8Array(bytes.byteLength); copy.set(bytes); return copy; } async parseHeadFromStream(stream, session) { let size = Math.min(this.options.headBytes, this.options.maxHeadBytes); let lastError = null; while (size <= this.options.maxHeadBytes) { const complete = await stream.bufferAtLeast(size); this.assertSession(session); const head = stream.snapshot(); try { return { head, meta: parseCencMetadata(head) }; } catch (error) { lastError = error; const message = asError(error).message; const incomplete = /未找到|不完整|缺少/.test(message); if (!incomplete || !complete || size === this.options.maxHeadBytes) throw error; size = Math.min(this.options.maxHeadBytes, size * 2); } } throw lastError instanceof Error ? lastError : new Error("无法解析音频 MP4 头"); } createNetworkStream(url, start, signal, initial = new Uint8Array()) { return new SequentialByteStream( initial, null, () => this.openAudioReader(url, start, signal) ); } /** * 打开一条从指定字节一直到文件末尾的流。 * 所有 CDN 请求都明确禁止 Referer、Cookie;顺序播放期间只会打开一次。 */ async openAudioReader(url, start, signal) { this.emitMessage(`打开音频流:bytes=${start}-`, "info"); const response = await this.options.fetch(url, { method: "GET", headers: { Range: `bytes=${start}-` }, credentials: "omit", // 两项都设:policy 禁止浏览器生成 Referer,空 referrer 防止调用方 Request 继承。 referrer: "", referrerPolicy: "no-referrer", signal }); if (!response.ok && response.status !== 206) { throw new Error(`音频 CDN 请求失败(HTTP ${response.status})`); } if (start > 0 && response.status !== 206) { throw new Error("音频 CDN 忽略了 Range,无法从拖动位置续流"); } if (response.body) return response.body.getReader(); const bytes = await response.arrayBuffer(); const fallback = new Response(bytes).body; if (!fallback) throw new Error("浏览器不支持流式读取 Response"); return fallback.getReader(); } sessionSignal(session) { this.assertSession(session); return this.sessionController.signal; } assertSession(session) { if (!this.isCurrent(session)) throw abortError$1(); } isCurrent(session) { var _a; return this.session === session && !((_a = this.sessionController) == null ? void 0 : _a.signal.aborted); } async waitForSourceOpen(mediaSource, session) { const signal = this.sessionSignal(session); await new Promise((resolve, reject) => { if (mediaSource.readyState === "open") { resolve(); return; } const onOpen = () => { cleanup(); resolve(); }; const onError = () => { cleanup(); reject(new Error("MediaSource 打开失败")); }; const onAbort = () => { cleanup(); reject(abortError$1()); }; const cleanup = () => { mediaSource.removeEventListener("sourceopen", onOpen); mediaSource.removeEventListener("error", onError); signal.removeEventListener("abort", onAbort); }; mediaSource.addEventListener("sourceopen", onOpen, { once: true }); mediaSource.addEventListener("error", onError, { once: true }); signal.addEventListener("abort", onAbort, { once: true }); }); } enqueueSourceBuffer(session, operation) { const sourceBuffer = this.sourceBuffer; if (!sourceBuffer) return Promise.reject(new Error("SourceBuffer 尚未创建")); const signal = this.sessionSignal(session); const run = async () => { if (signal.aborted) throw abortError$1(); await waitForSourceBufferIdle(sourceBuffer, signal); if (signal.aborted) throw abortError$1(); return new Promise((resolve, reject) => { let settled = false; const finish = (error) => { if (settled) return; settled = true; sourceBuffer.removeEventListener("updateend", onUpdateEnd); sourceBuffer.removeEventListener("error", onError); signal.removeEventListener("abort", onAbort); if (error) reject(error); else resolve(); }; const onUpdateEnd = () => finish(); const onError = () => finish(new Error("SourceBuffer 更新失败")); const onAbort = () => finish(abortError$1()); sourceBuffer.addEventListener("updateend", onUpdateEnd); sourceBuffer.addEventListener("error", onError); signal.addEventListener("abort", onAbort, { once: true }); try { operation(sourceBuffer); } catch (error) { finish(asError(error)); } }); }; const next = this.sourceBufferChain.catch(() => void 0).then(run); this.sourceBufferChain = next; return next; } startStream(startSample, existingStream) { var _a; const context = this.context; if (!context || this.state === "destroyed") return; if (!existingStream) { (_a = this.streamController) == null ? void 0 : _a.abort(); this.streamController = new AbortController(); } const controller = this.streamController; if (!controller) return; const token = ++this.streamToken; void this.runStream( context, this.session, token, startSample, controller.signal, existingStream ); } async runStream(context, session, token, startSample, signal, existingStream) { var _a; let sample = startSample; let first = true; let stream = existingStream ?? null; try { stream ?? (stream = this.createSampleStream(context, startSample, signal)); while (this.isStreamCurrent(session, token, signal) && sample < context.meta.sampleCount) { await this.waitForRoom(session, token, signal); if (!this.isStreamCurrent(session, token, signal)) return; const endSample = Math.min(context.meta.sampleCount, sample + context.samplesPerSegment); const byteLength = context.meta.offsets[endSample] - context.meta.offsets[sample]; const encrypted = await stream.readExactly(byteLength); if (!this.isStreamCurrent(session, token, signal)) return; const plain = await this.decryptRange(context, encrypted, sample, endSample); if (!this.isStreamCurrent(session, token, signal)) return; const segment = context.meta.codec === "opus" ? buildWebmCluster( Math.round(context.ticks[sample] * 1e3 / context.meta.timescale), plain.map((_, index) => Math.round( context.ticks[sample + index] * 1e3 / context.meta.timescale ) - Math.round(context.ticks[sample] * 1e3 / context.meta.timescale)), plain ) : buildMp4Segment( context.ticks[sample], plain.map((data, index) => ({ data, duration: context.meta.durations[sample + index] })), ++this.segmentSequence ); await this.enqueueSourceBuffer(session, (sourceBuffer) => { sourceBuffer.appendBuffer(this.toPageBuffer(segment)); }); if (first) { const toc = (_a = plain[0]) == null ? void 0 : _a[0]; this.emitMessage( `首段 samples=${sample}..${endSample} 密文${encrypted.byteLength}B 段长${segment.byteLength}B TOC=0x${(toc ?? 0).toString(16)}`, "info" ); } if (!this.isStreamCurrent(session, token, signal)) return; await this.evictBehind(session); sample = endSample; this.emitProgress({ processedSamples: sample, totalSamples: context.meta.sampleCount, percent: Math.round(sample / context.meta.sampleCount * 100), bufferedAhead: this.bufferedAhead(), durationSeconds: context.meta.totalTicks / context.meta.timescale }); if (first && this.autoplay) { first = false; void this.audio.play().catch(() => { this.emitMessage("自动播放被浏览器拦截,请点击音频控件播放", "warn"); }); } else { first = false; } } if (this.isStreamCurrent(session, token, signal)) { await this.finishStream(session, sample); this.emitMessage("音频已缓冲到结尾", "info"); } } catch (error) { if (this.isStreamCurrent(session, token, signal) && !isAbort(error)) { this.emitMessage(asError(error).message, "error"); void this.reportAppendFailure(); this.emitState("error"); } } finally { void (stream == null ? void 0 : stream.cancel()); } } /** * 补报媒体元素的错误码。 * * SourceBuffer 的 error 事件先于媒体元素错误传播,同步读 audio.error * 大概率是 null。让出一轮事件循环再读,才能拿到真正的 MediaError。 */ async reportAppendFailure() { await new Promise((resolve) => setTimeout(resolve, 0)); const detail = describeMediaError(this.audio); if (detail) this.emitMessage(`媒体元素错误${detail}`, "error"); } isStreamCurrent(session, token, signal) { return this.isCurrent(session) && this.streamToken === token && !signal.aborted; } /** * 全部样本都送进 SourceBuffer 后收口 MediaSource。 * * 只有真的跑到最后一个样本才收口。seek 之后的流也会走到循环末尾, * 但那时前面还有没缓冲的区间,提前 endOfStream 会把 duration 定在错的位置。 */ async finishStream(session, lastSample) { const context = this.context; const mediaSource = this.mediaSource; if (!context || !mediaSource) return; if (lastSample < context.meta.sampleCount) return; if (mediaSource.readyState !== "open") return; await this.sourceBufferChain.catch(() => void 0); if (!this.isCurrent(session)) return; if (this.sourceBuffer) { await waitForSourceBufferIdle(this.sourceBuffer, this.sessionSignal(session)); } if (!this.isCurrent(session) || mediaSource.readyState !== "open") return; try { mediaSource.endOfStream(); } catch (error) { this.emitMessage(`标记音频结尾失败: ${asError(error).message}`, "warn"); } } async waitForRoom(session, token, signal) { while (this.isStreamCurrent(session, token, signal) && this.bufferedAhead() >= this.options.maxBufferAheadSeconds) { await new Promise((resolve) => setTimeout(resolve, 200)); } if (!this.isStreamCurrent(session, token, signal)) throw abortError$1(); } /** * seek 后的新流。目标仍在已保存的 MP4 头内时先复用内存字节,读完再从 * head.length 开一条网络流;否则直接从目标 sample 的绝对偏移续传。 */ createSampleStream(context, startSample, signal) { const start = context.meta.mdatStart + context.meta.offsets[startSample]; if (start < context.head.length) { return this.createNetworkStream( context.url, context.head.length, signal, context.head.slice(start) ); } return this.createNetworkStream(context.url, start, signal); } async decryptRange(context, encrypted, startSample, endSample) { const ivSize = cencIvSize(context.meta); const jobs = []; for (let i2 = startSample; i2 < endSample; i2++) { const relative = context.meta.offsets[i2] - context.meta.offsets[startSample]; const size = context.meta.sizes[i2]; jobs.push({ iv: context.meta.ivs.slice(i2 * ivSize, (i2 + 1) * ivSize), ciphertext: encrypted.slice(relative, relative + size) }); } return mapLimit(jobs, this.options.decryptConcurrency, (job) => decryptCencSample( context.key, job.iv, job.ciphertext, context.counterStart )); } async detectCounterStart(meta, head, key) { const count = Math.min(6, meta.sampleCount); if (count === 0) return 0; const start = meta.mdatStart + meta.offsets[0]; const end = meta.mdatStart + meta.offsets[count] - 1; if (end >= head.length) throw new Error("Opus counter 探测数据不完整"); const encrypted = head.slice(start, end + 1); const ivSize = cencIvSize(meta); for (const candidate of [0, 1]) { const firstBytes = /* @__PURE__ */ new Set(); for (let i2 = 0; i2 < count; i2++) { const relative = meta.offsets[i2]; const ciphertext = encrypted.slice(relative, relative + meta.sizes[i2]); const plain = await decryptCencSample( key, meta.ivs.slice(i2 * ivSize, (i2 + 1) * ivSize), ciphertext, candidate ); if (plain.length > 0) firstBytes.add(plain[0]); } if (firstBytes.size === 1) { this.emitMessage(`CENC counter 低位起始=${candidate}`, "info"); return candidate; } } this.emitMessage("CENC counter 未收敛,按 0 继续", "warn"); return 0; } async evictBehind(session) { const sourceBuffer = this.sourceBuffer; if (!sourceBuffer || !sourceBuffer.buffered.length) return; const current = this.audio.currentTime; if (current <= this.options.keepBehindSeconds + 5) return; const start = sourceBuffer.buffered.start(0); const end = current - this.options.keepBehindSeconds; if (start < end) { try { await this.enqueueSourceBuffer(session, (buffer) => buffer.remove(start, end)); } catch { } } } async restartAt(target, wasPlaying) { var _a, _b; const context = this.context; if (!context) return; if (this.audio.error) { this.emitMessage("播放元素已进入错误态,无法跳转,请重新开始听书", "warn"); return; } const session = this.session; const seek = ++this.seekToken; this.streamToken++; (_a = this.streamController) == null ? void 0 : _a.abort(); let low = 0; let high = context.meta.sampleCount - 1; let index = 0; const targetTicks = target * context.meta.timescale; while (low <= high) { const middle = low + high >> 1; if (context.ticks[middle] <= targetTicks) { index = middle; low = middle + 1; } else { high = middle - 1; } } const preSamples = Math.max(1, Math.round(context.samplesPerSecond * 2)); const startSample = Math.max(0, index - preSamples); this.emitMessage(`跳转到 ${target.toFixed(1)} 秒`, "info"); await this.sourceBufferChain.catch(() => void 0); if (seek !== this.seekToken || !this.isCurrent(session)) return; const bufferedEnd = ((_b = this.sourceBuffer) == null ? void 0 : _b.buffered.length) ? this.sourceBuffer.buffered.end(this.sourceBuffer.buffered.length - 1) : 0; if (bufferedEnd > 0) { try { await this.enqueueSourceBuffer(session, (sourceBuffer) => sourceBuffer.remove(0, bufferedEnd + 0.5)); } catch { } } if (seek !== this.seekToken || !this.isCurrent(session)) return; if (this.mediaSource && this.mediaSource.readyState === "closed") { this.emitMessage("媒体源已关闭,无法跳转", "warn"); return; } this.startStream(startSample); if (wasPlaying) void this.audio.play().catch(() => void 0); } isTimeBuffered(time) { var _a; const ranges = (_a = this.sourceBuffer) == null ? void 0 : _a.buffered; if (!ranges) return false; for (let i2 = 0; i2 < ranges.length; i2++) { if (time >= ranges.start(i2) - 0.05 && time <= ranges.end(i2) + 0.05) return true; } return false; } bufferedAhead() { var _a; const ranges = (_a = this.sourceBuffer) == null ? void 0 : _a.buffered; if (!ranges || !ranges.length) return 0; const current = this.audio.currentTime; for (let i2 = 0; i2 < ranges.length; i2++) { if (current >= ranges.start(i2) - 0.05 && current <= ranges.end(i2) + 0.05) { return Math.max(0, ranges.end(i2) - current); } } return Math.max(0, ranges.end(ranges.length - 1) - current); } emitState(state2) { var _a, _b; this.state = state2; (_b = (_a = this.options).onStateChange) == null ? void 0 : _b.call(_a, state2); } emitProgress(progress) { var _a, _b; (_b = (_a = this.options).onProgress) == null ? void 0 : _b.call(_a, progress); } emitMessage(message, level) { var _a, _b; (_b = (_a = this.options).onMessage) == null ? void 0 : _b.call(_a, message, level); } } function gmAudioFetch(input, init2 = {}) { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const headers = normalizeHeaders(init2.headers); const signal = init2.signal ?? null; return new Promise((resolve, reject) => { if (signal == null ? void 0 : signal.aborted) { reject(abortError()); return; } let settled = false; let request; const onAbort = () => { try { request == null ? void 0 : request.abort(); } catch { } if (settled) return; settled = true; reject(abortError()); }; signal == null ? void 0 : signal.addEventListener("abort", onAbort, { once: true }); const cleanup = () => { signal == null ? void 0 : signal.removeEventListener("abort", onAbort); }; const succeed = (res, body) => { if (settled) return; settled = true; cleanup(); resolve(makeResponse(res, body)); }; const fail = (error) => { if (settled) return; settled = true; cleanup(); reject(error); }; request = GM_xmlhttpRequest({ method: "GET", url, headers, responseType: "stream", // GM 请求不带页面 Referer;anonymous 同时也不带 Cookie anonymous: true, onreadystatechange(res) { if (settled || res.readyState < 3) return; const stream = res.response; if (stream instanceof ReadableStream) succeed(res, stream); }, onload(res) { if (settled) return; const stream = res.response; if (stream instanceof ReadableStream) { succeed(res, stream); return; } const bytes = toBytes(res); if (!bytes) { fail(new Error( "GM_xmlhttpRequest 未返回二进制音频数据(请确认 Tampermonkey 版本支持 responseType)" )); return; } if (bytes.byteLength === 0 && res.status !== 204 && res.status !== 304) { fail(new Error("音频响应为空")); return; } succeed(res, bytes); }, onerror(res) { fail(new Error(`音频 CDN 请求失败:${describe(res)}`)); }, ontimeout() { fail(new Error("音频 CDN 请求超时")); } }); }); } function abortError() { return new DOMException("音频请求已取消", "AbortError"); } function parseHeaders(raw) { const headers = new Headers(); if (!raw) return headers; for (const line of raw.split(/\r?\n/)) { const colon = line.indexOf(":"); if (colon <= 0) continue; const name2 = line.slice(0, colon).trim(); const value = line.slice(colon + 1).trim(); if (!name2) continue; try { headers.append(name2, value); } catch { } } return headers; } function makeResponse(res, body) { return new Response(body ?? new Uint8Array(), { status: res.status || 200, statusText: res.statusText || "", headers: parseHeaders(res.responseHeaders) }); } function toBytes(res) { const raw = res.response; if (raw instanceof ArrayBuffer) return new Uint8Array(raw); if (raw instanceof Uint8Array) return raw; return null; } function normalizeHeaders(init2) { const out = {}; if (!init2) return out; if (init2 instanceof Headers) { init2.forEach((value, key) => { out[key] = value; }); return out; } if (Array.isArray(init2)) { for (const [key, value] of init2) { if (key !== void 0 && value !== void 0) out[key] = value; } return out; } return { ...init2 }; } function describe(res) { if ("error" in res && res.error) return String(res.error); const status = res.status; return status ? `HTTP ${status}` : "网络错误"; } const CONTENT_SELECTOR = "#fqa-reader-content"; const TITLE_SELECTOR = "h1.muye-reader-title"; const ACTIVE_CLASS = "fqa-audio-active"; function collectParagraphs() { const map = /* @__PURE__ */ new Map(); const container2 = document.querySelector(CONTENT_SELECTOR); if (!container2) return map; for (const node of container2.querySelectorAll("p[idx]")) { const idx = Number(node.getAttribute("idx")); if (!Number.isFinite(idx)) continue; const list = map.get(idx); if (list) list.push(node); else map.set(idx, [node]); } return map; } function titleTarget(tags) { const titles = tags.filter((t) => t.is_title); if (titles.length !== 1) return null; return document.querySelector(TITLE_SELECTOR); } function tagIndexAt(tags, timeMs) { let low = 0; let high = tags.length - 1; let found = -1; while (low <= high) { const middle = low + high >> 1; if (tags[middle].startms <= timeMs) { found = middle; low = middle + 1; } else { high = middle - 1; } } if (found < 0) return -1; return found; } class ParagraphHighlighter { constructor() { __publicField(this, "paragraphs", /* @__PURE__ */ new Map()); __publicField(this, "tags", []); __publicField(this, "active", []); __publicField(this, "activeTagIndex", -1); } /** 换章或正文重新插入后调用,重建索引 */ reset(tags) { this.clear(); this.tags = tags; this.paragraphs = collectParagraphs(); this.activeTagIndex = -1; } /** 正文 DOM 被替换过(切音色不会换 DOM,但切章会),重新抓一遍段落 */ refresh() { this.paragraphs = collectParagraphs(); this.activeTagIndex = -1; } get timeTags() { return this.tags; } /** 当前高亮对应的时间点下标,用于切音色时保持段落 */ get currentTagIndex() { return this.activeTagIndex; } clear() { for (const node of this.active) node.classList.remove(ACTIVE_CLASS); this.active = []; } /** * 按播放时间更新高亮。 * * @param scroll 是否把高亮段落滚进视口 * @returns 当前时间点下标,没有变化时返回原值 */ update(timeMs, scroll) { if (this.tags.length === 0) return -1; const index = tagIndexAt(this.tags, timeMs); if (index < 0 || index === this.activeTagIndex) return this.activeTagIndex; this.activeTagIndex = index; this.applyTag(index, scroll); return index; } /** 直接高亮第 index 个时间点,供切音色后恢复位置用 */ applyTag(index, scroll) { const tag = this.tags[index]; if (!tag) return; this.clear(); const targets = tag.is_title ? [titleTarget(this.tags)].filter((n) => n !== null) : this.rangeTargets(tag.startidx, tag.endidx); for (const node of targets) node.classList.add(ACTIVE_CLASS); this.active = targets; if (scroll && targets[0]) { targets[0].scrollIntoView({ behavior: "smooth", block: "center" }); } } rangeTargets(startidx, endidx) { const targets = []; for (let idx = startidx; idx <= endidx; idx++) { const nodes = this.paragraphs.get(idx); if (nodes) targets.push(...nodes); } return targets; } /** * 点段落跳转用:找出这个节点属于哪个时间点。 * * 从点击目标往上找最近的 (点到 blk 或行内标签上也能命中), * 再拿 idx 去时间点表里查覆盖它的那一段。 */ tagIndexOfNode(node) { var _a; const paragraph = nearestParagraph(node); if (paragraph) { const idx = Number(paragraph.getAttribute("idx")); if (Number.isFinite(idx)) return this.tagIndexOfIdx(idx); } if (node instanceof Node && ((_a = titleTarget(this.tags)) == null ? void 0 : _a.contains(node))) { return this.tags.findIndex((t) => t.is_title); } return -1; } /** idx 落在哪个时间点区间。时间点可能跨多段,所以按区间判断 */ tagIndexOfIdx(idx) { return this.tags.findIndex((t) => !t.is_title && idx >= t.startidx && idx <= t.endidx); } /** 第 index 个时间点的起始秒数,供 seek 用 */ startSecondsOf(index) { const tag = this.tags[index]; return tag ? tag.startms / 1e3 : null; } } function nearestParagraph(node) { let current = node; while (current) { if (current instanceof HTMLElement && current.matches("p[idx]")) return current; if (current instanceof HTMLElement && current.id === "fqa-reader-content") return null; current = current.parentNode; } return null; } const TONE_STORE_KEY = "audiobook_tone"; const state = vue.reactive({ open: false, collapsed: false, tonePickerOpen: false, loading: false, playing: false, spinning: false, tones: [], toneId: null, toneName: "", cover: "", title: "", error: "" }); const highlighter = new ParagraphHighlighter(); let audio = null; let player = null; let itemId = ""; let bookId = ""; let trackTimer = null; let pendingTagIndex = -1; let playToken = 0; let paragraphClickBound = false; const onParagraphClick = (event) => { if (!state.open || state.toneId === null) return; const target = event.target; if (target instanceof HTMLElement && target.closest("a, button, sup")) return; const index = highlighter.tagIndexOfNode(target); if (index < 0) return; const seconds = highlighter.startSecondsOf(index); if (seconds === null || !audio) return; audio.currentTime = seconds; highlighter.applyTag(index, false); if (!state.playing) void togglePlay(); }; function bindParagraphClick() { if (paragraphClickBound) return; document.addEventListener("click", onParagraphClick); paragraphClickBound = true; } function unbindParagraphClick() { if (!paragraphClickBound) return; document.removeEventListener("click", onParagraphClick); paragraphClickBound = false; } function ensurePlayer() { if (player) return player; audio = document.createElement("audio"); audio.preload = "none"; audio.style.display = "none"; document.body.appendChild(audio); player = new CencAudioPlayer(audio, { // 音频 CDN 不在页面 CSP 的 connect-src 里,页面 fetch 会被 report-only // 策略上报到 mon.zijieapi.com。走 GM 通道绕开上报,详见 gmFetch fetch: gmAudioFetch, onStateChange: onPlayerState, onMessage: (message, level) => { if (level === "error") { console.error("[fqa:audio]", message); state.error = message; } else { console.log("[fqa:audio]", message); } } }); return player; } function onPlayerState(playerState) { state.playing = playerState === "playing"; state.spinning = playerState === "playing" || playerState === "loading"; if (playerState === "playing") state.error = ""; if (playerState === "playing") startTracking(); else stopTracking(); if (playerState === "ended") { highlighter.clear(); onChapterEnd(); } } const NEXT_CHAPTER_SELECTOR = "div.chapter-btn.next"; function onChapterEnd() { var _a; if (settings$1.audiobookChapterEnd !== "next") { state.spinning = false; return; } const next = (_a = document.querySelector(NEXT_CHAPTER_SELECTOR)) == null ? void 0 : _a.firstChild; if (typeof (next == null ? void 0 : next.click) !== "function") { state.spinning = false; state.error = "已经是最后一章"; return; } state.spinning = true; const before = itemId; next.click(); unsafeWindow.setTimeout(() => { var _a2; if (!state.open || itemId !== before) return; const current = ((_a2 = window.location.pathname.split("/").pop()) == null ? void 0 : _a2.substring(0, 19)) || ""; if (current && current !== before) { void switchChapter(current, { cover: state.cover, title: state.title }); } else { state.spinning = false; state.error = "自动切章失败"; } }, 3e3); } function startTracking() { if (trackTimer !== null) return; trackTimer = unsafeWindow.setInterval(() => { if (!audio) return; highlighter.update(audio.currentTime * 1e3, settings$1.audiobookFollow); }, 120); } function stopTracking() { if (trackTimer === null) return; unsafeWindow.clearInterval(trackTimer); trackTimer = null; } function storedToneId() { const raw = read(TONE_STORE_KEY); return typeof raw === "number" ? raw : null; } async function openAudiobook(chapter, book, meta) { itemId = chapter; bookId = book; state.cover = meta.cover; state.title = meta.title; state.error = ""; state.open = true; state.collapsed = false; if (state.tones.length === 0) { try { state.tones = await getBookAvailableTones(bookId); } catch (e) { state.error = "取音色列表失败"; console.error("[fqa:audio] 取音色列表失败:", e); return; } } if (state.tones.length === 0) { state.error = "这本书没有可用音色"; return; } const remembered = storedToneId(); if (remembered !== null && state.tones.some((t) => t.id === remembered)) { await selectTone(remembered); return; } state.tonePickerOpen = true; } async function selectTone(toneId) { state.tonePickerOpen = false; const tone = state.tones.find((t) => t.id === toneId); state.toneId = toneId; state.toneName = (tone == null ? void 0 : tone.name) ?? ""; write(TONE_STORE_KEY, toneId); await playCurrent(); } function openTonePicker() { state.tonePickerOpen = true; } function closeTonePicker() { state.tonePickerOpen = false; if (state.toneId === null) closeAudiobook(); } async function changeTone(toneId) { if (toneId === state.toneId) { state.tonePickerOpen = false; return; } pendingTagIndex = highlighter.currentTagIndex; await selectTone(toneId); } async function playCurrent() { const toneId = state.toneId; if (toneId === null || !itemId) return; const token = ++playToken; const chapter = itemId; state.loading = true; state.spinning = true; state.error = ""; try { const [contexts, tags] = await Promise.all([ getPlayInfo(chapter, toneId), getChapterParagraphTimeTag(chapter, toneId) ]); if (token !== playToken) return; const context = contexts.find((c) => c.item_id === chapter) ?? contexts[0]; const url = context == null ? void 0 : context.urls[0]; if (!context || !url) { state.error = "这一章没有音频"; return; } highlighter.reset(tags); bindParagraphClick(); const instance = ensurePlayer(); await instance.start({ url, key: context.key }); if (token !== playToken) return; if (pendingTagIndex >= 0) { const tag = tags[pendingTagIndex]; if (tag && audio) { audio.currentTime = tag.startms / 1e3; highlighter.applyTag(pendingTagIndex, true); } pendingTagIndex = -1; } } catch (e) { const message = e instanceof Error ? e.message : String(e); state.error = message; console.error("[fqa:audio] 播放失败:", e); } finally { state.loading = false; if (!state.playing) state.spinning = false; } } async function togglePlay() { if (!player) return; if (state.playing) { player.pause(); return; } if (player.currentState === "ended" && audio) audio.currentTime = 0; try { await player.play(); } catch (e) { console.warn("[fqa:audio] play() 被拒:", e); } } function toggleCollapsed() { state.collapsed = !state.collapsed; } async function switchChapter(chapter, meta) { if (!state.open || state.toneId === null) return; itemId = chapter; state.cover = meta.cover; state.title = meta.title; pendingTagIndex = -1; await playCurrent(); } function refreshParagraphs() { if (state.open) highlighter.refresh(); } function closeAudiobook() { stopTracking(); unbindParagraphClick(); playToken++; highlighter.clear(); player == null ? void 0 : player.stop(); state.open = false; state.collapsed = false; state.tonePickerOpen = false; state.playing = false; state.spinning = false; state.loading = false; state.error = ""; } const _hoisted_1$b = { class: "fqa-tone-box", role: "dialog", "aria-modal": "true", "aria-label": "选择音色" }; const _hoisted_2$b = { class: "fqa-tone-title" }; const _hoisted_3$b = { class: "fqa-tone-list" }; const _hoisted_4$a = ["onClick"]; const _hoisted_5$a = ["src", "alt"]; const _hoisted_6$a = { key: 1, class: "fqa-tone-icon fqa-tone-icon-empty" }; const _hoisted_7$8 = { class: "fqa-tone-text" }; const _hoisted_8$8 = { class: "fqa-tone-name" }; const _hoisted_9$8 = { key: 0, class: "fqa-tone-gender" }; const _hoisted_10$8 = { key: 0, class: "fqa-tone-desc" }; const _hoisted_11$6 = { class: "fqa-tone-actions" }; const _sfc_main$b = /* @__PURE__ */ vue.defineComponent({ __name: "TonePicker", props: { tones: {}, current: {} }, emits: ["select", "close"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; function genderLabel(gender) { if (gender === 1) return "男声"; if (gender === 2) return "女声"; return ""; } return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", { class: "fqa-tone-mask", onClick: _cache[1] || (_cache[1] = vue.withModifiers(($event) => emit("close"), ["self"])) }, [ vue.createElementVNode("div", _hoisted_1$b, [ vue.createElementVNode("h3", _hoisted_2$b, vue.toDisplayString(props.current === null ? "选择音色" : "切换音色"), 1), _cache[2] || (_cache[2] = vue.createElementVNode("p", { class: "fqa-tone-sub" }, "切换后会从当前段落开头继续", -1)), vue.createElementVNode("div", _hoisted_3$b, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(props.tones, (tone) => { return vue.openBlock(), vue.createElementBlock("button", { key: tone.id, type: "button", class: vue.normalizeClass(["fqa-tone-item", { "fqa-tone-item-active": tone.id === props.current }]), onClick: ($event) => emit("select", tone.id) }, [ tone.icon ? (vue.openBlock(), vue.createElementBlock("img", { key: 0, class: "fqa-tone-icon", src: tone.icon, alt: tone.name }, null, 8, _hoisted_5$a)) : (vue.openBlock(), vue.createElementBlock("span", _hoisted_6$a, vue.toDisplayString(tone.name.slice(0, 1)), 1)), vue.createElementVNode("span", _hoisted_7$8, [ vue.createElementVNode("span", _hoisted_8$8, [ vue.createTextVNode(vue.toDisplayString(tone.name) + " ", 1), genderLabel(tone.gender) ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_9$8, vue.toDisplayString(genderLabel(tone.gender)), 1)) : vue.createCommentVNode("", true) ]), tone.description ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_10$8, vue.toDisplayString(tone.description), 1)) : vue.createCommentVNode("", true) ]) ], 10, _hoisted_4$a); }), 128)) ]), vue.createElementVNode("div", _hoisted_11$6, [ vue.createElementVNode("button", { type: "button", class: "fqa-tone-btn", onClick: _cache[0] || (_cache[0] = ($event) => emit("close")) }, "取消") ]) ]) ]); }; } }); const _hoisted_1$a = ["innerHTML"]; const _hoisted_2$a = { key: 1, class: "fqa-audio-bar" }; const _hoisted_3$a = ["title", "aria-label"]; const _hoisted_4$9 = ["src"]; const _hoisted_5$9 = { key: 1, class: "fqa-audio-cover-empty" }; const _hoisted_6$9 = ["innerHTML"]; const _hoisted_7$7 = { class: "fqa-audio-meta" }; const _hoisted_8$7 = { class: "fqa-audio-title" }; const _hoisted_9$7 = { class: "fqa-audio-sub" }; const _hoisted_10$7 = ["innerHTML"]; const _sfc_main$a = /* @__PURE__ */ vue.defineComponent({ __name: "AudioBar", setup(__props) { function onSelect(id) { if (state.toneId === null) void selectTone(id); else void changeTone(id); } return (_ctx, _cache) => { return vue.unref(state).open ? (vue.openBlock(), vue.createElementBlock("div", { key: 0, class: vue.normalizeClass(["fqa-audio-root", { "fqa-audio-collapsed": vue.unref(state).collapsed }]) }, [ vue.unref(state).collapsed ? (vue.openBlock(), vue.createElementBlock("button", { key: 0, type: "button", class: "fqa-audio-expand", title: "展开听书栏", "aria-label": "展开听书栏", onClick: _cache[0] || (_cache[0] = //@ts-ignore (...args) => vue.unref(toggleCollapsed) && vue.unref(toggleCollapsed)(...args)) }, [ vue.createElementVNode("span", { class: "fqa-audio-icon fqa-audio-icon-flip", innerHTML: vue.unref(leftIcon) }, null, 8, _hoisted_1$a) ])) : (vue.openBlock(), vue.createElementBlock("div", _hoisted_2$a, [ vue.createElementVNode("button", { type: "button", class: "fqa-audio-cover", title: vue.unref(state).playing ? "暂停" : "播放", "aria-label": vue.unref(state).playing ? "暂停" : "播放", onClick: _cache[1] || (_cache[1] = //@ts-ignore (...args) => vue.unref(togglePlay) && vue.unref(togglePlay)(...args)) }, [ vue.createElementVNode("span", { class: vue.normalizeClass(["fqa-audio-disc", { "fqa-audio-spin": vue.unref(state).spinning }]) }, [ vue.unref(state).cover ? (vue.openBlock(), vue.createElementBlock("img", { key: 0, src: vue.unref(state).cover, alt: "" }, null, 8, _hoisted_4$9)) : (vue.openBlock(), vue.createElementBlock("span", _hoisted_5$9, "听")) ], 2), vue.createElementVNode("span", { class: "fqa-audio-state", innerHTML: vue.unref(state).playing ? vue.unref(playingIcon) : vue.unref(pausedIcon) }, null, 8, _hoisted_6$9) ], 8, _hoisted_3$a), vue.createElementVNode("div", _hoisted_7$7, [ vue.createElementVNode("span", _hoisted_8$7, vue.toDisplayString(vue.unref(state).title), 1), vue.createElementVNode("span", _hoisted_9$7, vue.toDisplayString(vue.unref(state).error || (vue.unref(state).loading ? "缓冲中…" : vue.unref(state).toneName)), 1) ]), vue.createElementVNode("button", { type: "button", class: "fqa-audio-btn", title: "切换音色", "aria-label": "切换音色", onClick: _cache[2] || (_cache[2] = //@ts-ignore (...args) => vue.unref(openTonePicker) && vue.unref(openTonePicker)(...args)) }, [..._cache[5] || (_cache[5] = [ vue.createElementVNode("span", { class: "fqa-audio-dots" }, null, -1) ])]), vue.createElementVNode("button", { type: "button", class: "fqa-audio-btn", title: "收起", "aria-label": "收起听书栏", onClick: _cache[3] || (_cache[3] = //@ts-ignore (...args) => vue.unref(toggleCollapsed) && vue.unref(toggleCollapsed)(...args)) }, [ vue.createElementVNode("span", { class: "fqa-audio-icon", innerHTML: vue.unref(leftIcon) }, null, 8, _hoisted_10$7) ]), vue.createElementVNode("button", { type: "button", class: "fqa-audio-btn fqa-audio-close", title: "关闭听书", "aria-label": "关闭听书", onClick: _cache[4] || (_cache[4] = //@ts-ignore (...args) => vue.unref(closeAudiobook) && vue.unref(closeAudiobook)(...args)) }, " × ") ])), vue.unref(state).tonePickerOpen ? (vue.openBlock(), vue.createBlock(_sfc_main$b, { key: 2, tones: vue.unref(state).tones, current: vue.unref(state).toneId, onSelect, onClose: vue.unref(closeTonePicker) }, null, 8, ["tones", "current", "onClose"])) : vue.createCommentVNode("", true) ], 2)) : vue.createCommentVNode("", true); }; } }); const audiobookcss = `/* 听书悬浮栏与音色弹窗。 * 暗色跟随页面的 div.muye-reader-dark(与 userHook 的判定一致)。 */ .fqa-audio-root { --fqa-audio-bg: #fff; --fqa-audio-text: #1f2329; --fqa-audio-sub: #8f959e; --fqa-audio-hover: rgba(31, 35, 41, 0.06); --fqa-audio-border: rgba(31, 35, 41, 0.1); --fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.16); position: fixed; left: 20px; bottom: 20px; z-index: 2147483000; font-size: 14px; color: var(--fqa-audio-text); } /* * 暗色反色。悬浮栏挂在 body 下,够不到 .muye-reader-dark 的后代选择器, * 所以 audioPanel 在 #fqa-audio-root 容器上打 .fqa-audio-dark 类; * 真正的 .fqa-audio-root(Vue 根)是容器的子节点,用后代选择器作用到它。 */ .fqa-audio-dark .fqa-audio-root { --fqa-audio-bg: #2b2b2b; --fqa-audio-text: #b3b3b3; --fqa-audio-sub: #7a7a7a; --fqa-audio-hover: rgba(255, 255, 255, 0.08); --fqa-audio-border: rgba(255, 255, 255, 0.12); --fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); } .fqa-audio-bar { display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 8px; border-radius: 999px; background: var(--fqa-audio-bg); box-shadow: var(--fqa-audio-shadow); } /* ------------------------------- 旋转封面 ------------------------------- */ .fqa-audio-cover { position: relative; flex: 0 0 auto; width: 44px; height: 44px; padding: 0; border: none; border-radius: 50%; background: var(--fqa-audio-hover); cursor: pointer; } /* * 只有这一层转。状态图标是 .fqa-audio-state,放在旋转层外面, * 否则会跟着封面一起转,反而更看不清。 * 播放/缓冲时转,暂停时停在当前角度(animation-play-state 比移除动画更平滑) */ .fqa-audio-disc { position: absolute; inset: 0; border-radius: 50%; overflow: hidden; animation: fqa-audio-rotate 8s linear infinite; animation-play-state: paused; } .fqa-audio-disc.fqa-audio-spin { animation-play-state: running; } .fqa-audio-disc img { display: block; width: 100%; height: 100%; object-fit: cover; } .fqa-audio-cover-empty { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: var(--fqa-audio-sub); font-size: 18px; } /* 播放/暂停状态。压一层遮罩,浅色封面上也看得清 */ .fqa-audio-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: rgba(0, 0, 0, 0.38); color: #fff; transition: background 0.2s ease; } .fqa-audio-cover:hover .fqa-audio-state { background: rgba(0, 0, 0, 0.55); } /* * 只给尺寸,不写 fill:paused.svg 里那块透明点击区是 fill="none", * CSS 的 fill 优先级高于表现属性,会把它填成一个白方块盖住图标。 * 两个 svg 自身已经用 currentColor,跟着上面的 color 走。 */ .fqa-audio-state svg { width: 18px; height: 18px; } @keyframes fqa-audio-rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .fqa-audio-disc { animation: none; } } /* -------------------------------- 文字区 -------------------------------- */ .fqa-audio-meta { display: flex; flex-direction: column; justify-content: center; min-width: 0; max-width: 180px; line-height: 1.35; } .fqa-audio-title, .fqa-audio-sub { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .fqa-audio-sub { color: var(--fqa-audio-sub); font-size: 12px; } /* -------------------------------- 按钮 -------------------------------- */ .fqa-audio-btn, .fqa-audio-expand { display: flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 32px; height: 32px; padding: 0; border: none; border-radius: 50%; background: transparent; color: var(--fqa-audio-text); cursor: pointer; } .fqa-audio-btn:hover, .fqa-audio-expand:hover { background: var(--fqa-audio-hover); } .fqa-audio-close { font-size: 20px; line-height: 1; } /* svg 用 currentColor,跟着按钮的 color 走 */ .fqa-audio-icon { display: flex; align-items: center; justify-content: center; width: 16px; height: 16px; } .fqa-audio-icon svg { width: 100%; height: 100%; fill: currentColor; } /* 收起后箭头水平翻转朝右 */ .fqa-audio-icon-flip { transform: scaleX(-1); } .fqa-audio-expand { background: var(--fqa-audio-bg); box-shadow: var(--fqa-audio-shadow); width: 36px; height: 36px; } /* 三个点 */ .fqa-audio-dots, .fqa-audio-dots::before, .fqa-audio-dots::after { width: 4px; height: 4px; border-radius: 50%; background: currentColor; } .fqa-audio-dots { position: relative; } .fqa-audio-dots::before, .fqa-audio-dots::after { content: ''; position: absolute; top: 0; } .fqa-audio-dots::before { left: -7px; } .fqa-audio-dots::after { left: 7px; } /* ----------------------------- 段落高亮 ----------------------------- */ .fqa-audio-active { background: var(--web-brand_light, rgba(241, 70, 70, 0.12)); border-radius: 4px; transition: background 0.2s ease; } /* ----------------------------- 音色弹窗 ----------------------------- */ .fqa-tone-mask { position: fixed; inset: 0; z-index: 2147483002; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.45); } .fqa-tone-box { width: min(420px, calc(100vw - 32px)); max-height: min(560px, calc(100vh - 64px)); display: flex; flex-direction: column; padding: 20px; border-radius: 12px; background: var(--fqa-audio-bg, #fff); color: var(--fqa-audio-text, #1f2329); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.24); } .fqa-tone-title { margin: 0 0 4px; font-size: 17px; font-weight: 600; } .fqa-tone-sub { margin: 0 0 14px; color: var(--fqa-audio-sub, #8f959e); font-size: 12px; } .fqa-tone-list { flex: 1 1 auto; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; } .fqa-tone-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: inherit; text-align: left; cursor: pointer; } .fqa-tone-item:hover { background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); } .fqa-tone-item-active { border-color: var(--web-brand, #f14646); } .fqa-tone-icon { flex: 0 0 auto; width: 36px; height: 36px; border-radius: 50%; object-fit: cover; } .fqa-tone-icon-empty { display: flex; align-items: center; justify-content: center; background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); color: var(--fqa-audio-sub, #8f959e); } .fqa-tone-text { display: flex; flex-direction: column; min-width: 0; } .fqa-tone-name { display: flex; align-items: center; gap: 6px; font-size: 14px; } .fqa-tone-gender { color: var(--fqa-audio-sub, #8f959e); font-size: 11px; } .fqa-tone-desc { overflow: hidden; color: var(--fqa-audio-sub, #8f959e); font-size: 12px; white-space: nowrap; text-overflow: ellipsis; } .fqa-tone-actions { display: flex; justify-content: flex-end; margin-top: 14px; } .fqa-tone-btn { padding: 6px 16px; border: 1px solid var(--fqa-audio-border, rgba(31, 35, 41, 0.1)); border-radius: 6px; background: transparent; color: inherit; cursor: pointer; } .fqa-tone-btn:hover { background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); } `; const CONTAINER_ID$4 = "fqa-audio-root"; const STYLE_ID$5 = "fqa-audio-style"; let app$4 = null; let container$4 = null; let themeObserver = null; function injectStyle$5() { if (document.getElementById(STYLE_ID$5)) return; const style = document.createElement("style"); style.id = STYLE_ID$5; style.textContent = audiobookcss; document.head.appendChild(style); } function syncTheme() { if (!container$4) return; const dark = document.querySelector("div.muye-reader-dark") !== null; container$4.classList.toggle("fqa-audio-dark", dark); } function watchTheme() { if (themeObserver) return; themeObserver = new MutationObserver(syncTheme); themeObserver.observe(document.body, { subtree: true, attributes: true, attributeFilter: ["class"] }); } function initAudioPanel() { if (app$4) return; injectStyle$5(); container$4 = document.createElement("div"); container$4.id = CONTAINER_ID$4; document.body.appendChild(container$4); syncTheme(); watchTheme(); app$4 = vue.createApp({ render: () => vue.h(_sfc_main$a) }); app$4.config.errorHandler = (err, _instance, info) => { console.error(`[fqa:audio] Vue error (${info}):`, err); }; app$4.mount(container$4); } const SHELF_BASE = "https://fanqienovel.com/reading/bookapi/bookshelf"; async function getBookshelf() { const responses = await Promise.all([ fetch$1(SHELF_BASE + "/info/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700"), fetch$1("https://fanqienovel.com/api/reader/book/progress") ]); const response = responses[0]; const response2 = responses[1]; const data = await response.json(); const info = data.data.book_shelf_info; const data_ = await response2.json(); const data2 = data_.data; const all_items = info.map((item) => item.book_id); const response3 = await fetch$1("https://fanqienovel.com/api/book/simple/info", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ book_ids: all_items }) }); const data__ = await response3.json(); const data3 = data__.data.bookList; const results = []; for (let i2 of data.data.book_shelf_info) { const progress = data2.find((it) => it.book_id === i2.book_id); const simple = data3.find((it) => it.book_id === i2.book_id); const item = { book_id: i2.book_id, last_operate_time: progress ? progress.read_timestamp : i2.last_operate_time, add_shelf_time: i2.add_shelf_time, group_name: i2.group_name, last_read_timestamp: progress ? progress.read_timestamp : 0, last_read_chapter_id: progress ? progress.item_id : "0", is_publish: (simple == null ? void 0 : simple.genre) === "6" }; results.push(item); } return results; } async function multidetail(books) { const response = await fetch$1("https://fanqienovel.com/api/bookshelf/multidetail", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ books: books.map((book) => { return { book_id: book.book_id, item_id: book.last_read_chapter_id }; }) }) }); const data = await response.json(); const list = data.data.detail_list; const results = []; for (let i2 of list) { const item = { book_id: i2.book_id, summary: i2.abstract, title: i2.book_name, author: i2.author_name, cover_url: i2.thumb_url, current_chapter_title: i2.item_show_title, current_chapter_id: i2.item_id, current_chapter_order: i2.real_chapter_order, total_chapter_count: i2.serial_count, last_chapter_update_time: i2.last_chapter_update_time * 1e3, last_chapter_id: i2.last_chapter_item_id, current_chapter_summary: i2.item_abstract, // last_chapter_title: i.last_chapter_show_title, status: i2.update_stop === "1" ? 4 : i2.creation_status, update_status: i2.update_status }; results.push(item); } return results; } function identify(bookId2, modifyTime = 0) { return { asterisked: false, book_id: bookId2, book_type: 0, modify_time: modifyTime }; } async function shelfPost(path, body) { const res = await fetch$1(`${SHELF_BASE}${path}/v?aid=1967`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(body) }); const json = await res.json().catch(() => null); if (!res.ok || json && json.code !== 0 && json.code !== void 0) { throw new Error(`书架操作失败(${path}): ${(json == null ? void 0 : json.message) ?? res.status}`); } return json; } async function addToBookshelf(bookId2) { await shelfPost("/add", { add_book_source: 0, identify_data: [identify(bookId2)] }); } async function removeFromBookshelf(bookId2) { await shelfPost("/delete", { identify_data: [identify(bookId2, Date.now())] }); } async function moveToGroup(bookId2, groupName) { await shelfPost("/update", { book_data: [ { asterisked: false, book_id: bookId2, book_type: 0, group_name: groupName, has_shown: false, is_pin: false, modify_time: Date.now() } ] }); } async function isInBookshelf(book_id) { const response = await fetch$1(`https://fanqienovel.com/reading/bookapi/bookshelf/check/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700&book_id=${book_id}`); const data = await response.json(); return Boolean(data.data); } function addResponseModifier(modifier) { modifiers.push(modifier); return () => { const i2 = modifiers.indexOf(modifier); if (i2 >= 0) modifiers.splice(i2, 1); }; } const modifiers = []; const blackList = [ "mcs.zijieapi.com", "vcs.zijieapi.com/vc/setting", "mon.zijieapi.com", "mssdk.bytedance.com/web/common", "hm.baidu.com" ]; const BLOCKED_BODY = JSON.stringify({ e: 0, sc: 10, tc: 10 }); function checkBlack(url) { if (!settings$1.blockReport) return false; return blackList.some((black) => url.includes(black)); } function toLocalArrayBuffer(buf) { const bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); const out = new ArrayBuffer(bytes.byteLength); new Uint8Array(out).set(bytes); return out; } function textToBody(text) { return toLocalArrayBuffer(new TextEncoder().encode(text)); } function bodyToText(body) { if (typeof body === "string") return body; return new TextDecoder().decode(body); } function bodyToBuffer(body) { if (typeof body === "string") return textToBody(body); return body; } function resolveSimple(simple) { return { ...simple, responseBody: bodyToBuffer(simple.responseBody) }; } function matchingModifiers(url) { return modifiers.filter((m) => m.matcher(url)); } function applyModifiers(url, matched, simple) { let current = simple; for (const m of matched) { if (m.modify_response) current = m.modify_response(url, current); } return current; } function responseAllowsBody(status) { return status !== 204 && status !== 205 && status !== 304; } function simpleToResponse(simple) { return new Response(responseAllowsBody(simple.statusCode) ? simple.responseBody : null, { status: simple.statusCode, headers: simple.responseHeaders }); } async function responseToSimple(res) { const body = toLocalArrayBuffer(await res.arrayBuffer()); const headers = {}; res.headers.forEach((value, key) => { headers[key] = value; }); return { responseBody: body, statusCode: res.status, responseHeaders: headers }; } const originalFetch = unsafeWindow.fetch.bind(unsafeWindow); unsafeWindow.fetch = async function fetch2(input, init2) { let url; if (input instanceof Request) { url = input.url; } else if (input instanceof URL) { url = input.href; } else { url = input; } if (checkBlack(url)) { console.log("blocked request: " + url); return new Response(BLOCKED_BODY, { status: 200, headers: { "Content-Type": "application/json" } }); } const matched = matchingModifiers(url); if (matched.length === 0) return originalFetch(input, init2); const breaker = matched.find((m) => m.should_break(url)); if (breaker == null ? void 0 : breaker.make_response) { return simpleToResponse(breaker.make_response(url)); } if (!matched.some((m) => m.modify_response)) return originalFetch(input, init2); const res = await originalFetch(input, init2); try { const simple = await responseToSimple(res.clone()); return simpleToResponse(applyModifiers(url, matched, simple)); } catch (error) { console.error("[fqa:fetch] 修改响应失败,回退原始响应:", error); return res; } }; const originalXMLHttpRequest = unsafeWindow.XMLHttpRequest; const XHR_PROTO = originalXMLHttpRequest.prototype; const XHR_DESC = { status: Object.getOwnPropertyDescriptor(XHR_PROTO, "status"), responseText: Object.getOwnPropertyDescriptor(XHR_PROTO, "responseText"), response: Object.getOwnPropertyDescriptor(XHR_PROTO, "response") }; const XHR_GETALL = XHR_PROTO.getAllResponseHeaders; const XHR_GETONE = XHR_PROTO.getResponseHeader; function readXhrHeaders(self) { const headers = {}; const all = XHR_GETALL.call(self); if (!all) return headers; for (const line of all.trim().split(/[\r\n]+/)) { const idx = line.indexOf(":"); if (idx > 0) { headers[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); } } return headers; } function readXhrBody(self) { const type = self.responseType; if (type === "arraybuffer") { return toLocalArrayBuffer(XHR_DESC.response.get.call(self)); } if (type === "json") { const parsed = XHR_DESC.response.get.call(self); return textToBody(JSON.stringify(parsed)); } return textToBody(XHR_DESC.responseText.get.call(self)); } unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpRequest { constructor() { super(...arguments); __publicField(this, "_blockedUrl"); __publicField(this, "_fxaUrl", ""); __publicField(this, "_fxaMatched", null); __publicField(this, "_fxaBreaker", null); __publicField(this, "_fxaApplied", false); __publicField(this, "_fxaSimple", null); } open(method, url, async = true, user, password) { this._fxaUrl = url; this._fxaMatched = null; this._fxaBreaker = null; this._fxaApplied = false; this._fxaSimple = null; if (checkBlack(url)) { console.log("blocked request: " + url); this._blockedUrl = url; return; } this._blockedUrl = void 0; const matched = matchingModifiers(url); const breaker = matched.find((m) => m.should_break(url)); if (breaker == null ? void 0 : breaker.make_response) { this._fxaBreaker = breaker; this._fxaMatched = matched; } else if (matched.some((m) => m.modify_response)) { this._fxaMatched = matched; } super.open(method, url, async, user, password); } setRequestHeader(name2, value) { if (this._blockedUrl !== void 0) return; super.setRequestHeader(name2, value); } send(body) { var _a, _b; if (this._blockedUrl !== void 0) { this._synthesizeFromSimple({ responseBody: textToBody(BLOCKED_BODY), statusCode: 200, responseHeaders: { "content-type": "application/json" } }); return; } if ((_a = this._fxaBreaker) == null ? void 0 : _a.make_response) { this._synthesizeFromSimple(this._fxaBreaker.make_response(this._fxaUrl)); return; } if ((_b = this._fxaMatched) == null ? void 0 : _b.length) this._armTransform(); super.send(body); } abort() { if (this._blockedUrl !== void 0) return; super.abort(); } getAllResponseHeaders() { if (this._blockedUrl !== void 0) return "content-type: application/json\r\n"; return super.getAllResponseHeaders(); } getResponseHeader(name2) { if (this._blockedUrl !== void 0) { return name2.toLowerCase() === "content-type" ? "application/json" : null; } return super.getResponseHeader(name2); } /** * 中断请求后合成一个假响应并手动派发完成事件。 * 与 fetch 的 make_response 对齐,也复用了黑名单那条原本的 setTimeout 派发逻辑。 */ _synthesizeFromSimple(raw) { const url = this._blockedUrl ?? this._fxaUrl; const simple = resolveSimple(raw); const headers = simple.responseHeaders; const shadow = (prop, value) => Object.defineProperty(this, prop, { configurable: true, get: () => value }); setTimeout(() => { const text = bodyToText(simple.responseBody); shadow("readyState", 4); shadow("status", simple.statusCode); shadow("statusText", "OK"); shadow("responseURL", url); const type = this.responseType; shadow("responseText", type === "" || type === "text" ? text : ""); let value; if (type === "json") { try { value = JSON.parse(text); } catch { value = text; } } else if (type === "arraybuffer") { value = simple.responseBody; } else if (type === "blob") { value = new Blob([simple.responseBody]); } else { value = text; } shadow("response", value); shadow( "getAllResponseHeaders", () => Object.entries(headers).map(([k, v]) => `${k}: ${v}\r `).join("") ); shadow("getResponseHeader", (name2) => { const lower = name2.toLowerCase(); const key = Object.keys(headers).find((k) => k.toLowerCase() === lower); return key ? headers[key] : null; }); this.dispatchEvent(new Event("readystatechange")); this.dispatchEvent(new ProgressEvent("load")); this.dispatchEvent(new ProgressEvent("loadend")); }, 0); } /** * 在实例上挂影子属性:等真实响应就绪(readyState=4), * 页面第一次读取时才同步套用 modify_response 并缓存结果。 * 这样不用跟页面的 onload / onreadystatechange 争先后顺序。 */ _armTransform() { const type = this.responseType; if (type === "blob" || type === "document") return; const self = this; const matched = this._fxaMatched; const url = this._fxaUrl; const ensure = () => { if (self._fxaApplied || self.readyState !== 4) return; self._fxaSimple = resolveSimple(applyModifiers(url, matched, { responseBody: readXhrBody(self), statusCode: XHR_DESC.status.get.call(self), responseHeaders: readXhrHeaders(self) })); self._fxaApplied = true; }; Object.defineProperty(this, "status", { configurable: true, get() { ensure(); if (self._fxaApplied) return self._fxaSimple.statusCode; return XHR_DESC.status.get.call(self); } }); Object.defineProperty(this, "responseText", { configurable: true, get() { ensure(); const t = self.responseType; if (!self._fxaApplied || t !== "" && t !== "text") { return XHR_DESC.responseText.get.call(self); } return bodyToText(self._fxaSimple.responseBody); } }); Object.defineProperty(this, "response", { configurable: true, get() { ensure(); if (!self._fxaApplied) return XHR_DESC.response.get.call(self); const body = self._fxaSimple.responseBody; switch (self.responseType) { case "": case "text": return bodyToText(body); case "json": return JSON.parse(bodyToText(body)); case "arraybuffer": return body; default: return XHR_DESC.response.get.call(self); } } }); this.getAllResponseHeaders = function getAllResponseHeaders() { ensure(); if (!self._fxaApplied) return XHR_GETALL.call(self); return Object.entries(self._fxaSimple.responseHeaders).map(([k, v]) => `${k}: ${v}\r `).join(""); }; this.getResponseHeader = function getResponseHeader(name2) { ensure(); if (!self._fxaApplied) return XHR_GETONE.call(self, name2); const lower = name2.toLowerCase(); const key = Object.keys(self._fxaSimple.responseHeaders).find((k) => k.toLowerCase() === lower); return key ? self._fxaSimple.responseHeaders[key] : null; }; } }; const _exports$5 = []; let userState = { isLogin: false, userInfo: null }; if (read("userState")) { userState = read("userState"); } console.log("userState:", userState); async function getDetailedUserInfo() { if (!(userState == null ? void 0 : userState.isLogin) || !(userState == null ? void 0 : userState.userInfo)) { return null; } if (userState.userInfo.gender !== void 0 && userState.userInfo.recommend_gender !== void 0 && userState.userInfo.fans_num !== void 0 && userState.userInfo.following_num !== void 0 && userState.userInfo.is_author !== void 0 && userState.userInfo.author_desc !== void 0 && userState.userInfo.read_book_num !== void 0 && userState.userInfo.read_book_time !== void 0) { return userState.userInfo; } const response = await fetch$1("https://fanqienovel.com/reading/user/basic_info/get/v?aid=1967"); const j = await response.json(); if (j == null ? void 0 : j.data) { const data = j.data; userState.userInfo.gender = data.profile_gender; userState.userInfo.recommend_gender = data.gender; userState.userInfo.fans_num = data.fans_num; userState.userInfo.following_num = data.follow_user_num; userState.userInfo.is_author = data.is_author; userState.userInfo.author_desc = data.author_desc; userState.userInfo.read_book_num = data.read_book_num; userState.userInfo.read_book_time = BigInt(data.read_book_time); return userState.userInfo; } write("userState", userState); return userState.userInfo; } async function checkLogin() { var _a, _b, _c, _d, _e; const response = await fetch$1("https://fanqienovel.com/api/user/info/v2"); const j = await response.json(); const _userInfo = { id: (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.id, username: (_b = j == null ? void 0 : j.data) == null ? void 0 : _b.name, avatar: (_c = j == null ? void 0 : j.data) == null ? void 0 : _c.avatar, desc: (_d = j == null ? void 0 : j.data) == null ? void 0 : _d.desc, age: (_e = j == null ? void 0 : j.data) == null ? void 0 : _e.age }; if (j.code !== -1) { userState.isLogin = true; userState.userInfo = _userInfo; write("userState", userState); return true; } else { del("userState"); return false; } } async function init() { var _a; await checkLogin(); if (userState.isLogin) { console.log("Hello, ", (_a = userState == null ? void 0 : userState.userInfo) == null ? void 0 : _a.username); } } let currentBook = null; let latestItemId = null; let currentChapterWithContent = null; const SCRIPT_CONTAINER_ID = "fqa-reader-content"; let comicObserver = null; addResponseModifier({ matcher: (url) => url.indexOf("/reading/bookapi/bookshelf/check/") !== -1 && location.pathname.startsWith("/reader"), should_break: () => true, make_response: () => { const body = JSON.stringify({ code: 0, message: "SUCCESS", data: 0 }); return { responseBody: body, statusCode: 200, responseHeaders: { "content-type": "application/json; charset=utf-8", "content-length": String(new TextEncoder().encode(body).length) } }; } }); function ensureScriptContainer(readerContainer, comic) { let scriptContainer = document.getElementById(SCRIPT_CONTAINER_ID); if (!scriptContainer) { scriptContainer = cloneElement(readerContainer); scriptContainer.id = SCRIPT_CONTAINER_ID; scriptContainer.classList.add("fqa"); readerContainer.insertAdjacentElement("beforebegin", scriptContainer); } scriptContainer.classList.toggle("fqa-comic-reader", comic); if (settings$1.allowCopy) scriptContainer.classList.remove("noselect"); comicObserver == null ? void 0 : comicObserver.disconnect(); comicObserver = null; scriptContainer.innerHTML = ""; readerContainer.classList.add("fqa-hide"); return scriptContainer; } async function insertContent() { var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p; const itemId2 = ((_a = window.location.pathname.split("/").pop()) == null ? void 0 : _a.substring(0, 19)) || ""; if (!itemId2) { console.warn("No item_id found in URL"); return; } latestItemId = itemId2; const chapter = await getChapter(itemId2); if (!chapter) { console.warn("No chapter found for item_id:", itemId2); return; } if (latestItemId !== itemId2) { console.debug("Stale chapter response discarded:", itemId2); return; } console.log("Chapter:", chapter); currentChapterWithContent = chapter; const pageState = unsafeWindow.__INITIAL_STATE__; const chapterTitle = ((_b = chapter.novel_data) == null ? void 0 : _b.title) || ((_d = (_c = pageState == null ? void 0 : pageState.reader) == null ? void 0 : _c.chapterData) == null ? void 0 : _d.title); if (typeof chapter.content === "string") { void applyBookCss((_e = chapter.novel_data) == null ? void 0 : _e.css_map, "#fqa-reader-content"); const dp = new DOMParser(); const doc = dp.parseFromString(chapter.content, "text/html"); const body = doc.body; body.querySelectorAll('link[rel="stylesheet"]').forEach((el) => el.remove()); let article = body.querySelector("article"); let toProcess = article || body; processFootnotes(toProcess); for (let i2 = 0; i2 < toProcess.childNodes.length; i2++) { if (i2 < 2 && ((_g = (_f = toProcess.childNodes[i2]) == null ? void 0 : _f.innerHTML) == null ? void 0 : _g.includes(chapterTitle))) { toProcess.removeChild(toProcess.childNodes[i2]); break; } } if (!article) { article = document.createElement("article"); article.innerHTML = toProcess.innerHTML; toProcess = article; } const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); if (readerContainer) { const scriptContainer = ensureScriptContainer(readerContainer, false); scriptContainer.appendChild(toProcess); bindFootnoteInteraction(scriptContainer); } } else if (chapter.content.picInfos) { if (chapter.content.encrypt) { const imgs = []; for (let i2 = 0; i2 < chapter.content.picInfos.length; i2++) { const picInfo = chapter.content.picInfos[i2]; const img = document.createElement("img"); img.className = "fqa-comic-img fqa-comic-encrypted"; img.alt = `第${i2 + 1}页`; img.dataset.encryptedUrl = picInfo.picUrl; img.dataset.encryptKey = chapter.content.encrypt_key; img.dataset.pageIndex = i2.toString(); img.style.minHeight = "500px"; img.style.backgroundColor = "#f0f0f0"; imgs.push(img); } const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); if (readerContainer) { const scriptContainer = ensureScriptContainer(readerContainer, true); imgs.forEach((img) => scriptContainer.appendChild(img)); const observer2 = new IntersectionObserver( async (entries) => { for (const entry of entries) { if (entry.isIntersecting) { const img = entry.target; if (img.dataset.encryptedUrl && img.dataset.encryptKey && !img.src) { observer2.unobserve(img); try { const encryptedBuffer = await fetchArrayBuffer( img.dataset.encryptedUrl ); const decryptedBuffer = await decryptComicImage( encryptedBuffer, img.dataset.encryptKey ); const blob = new Blob([decryptedBuffer], { type: "image/jpeg" }); const blobUrl = URL.createObjectURL(blob); img.src = blobUrl; img.style.minHeight = ""; img.style.backgroundColor = ""; img.onload = () => { URL.revokeObjectURL(blobUrl); }; } catch (error) { console.error(`解密图片失败 (页 ${img.dataset.pageIndex}):`, error); img.alt = `第${Number(img.dataset.pageIndex) + 1}页 - 解密失败`; img.style.backgroundColor = "#ffebee"; } } } } }, { rootMargin: "200px" } ); comicObserver = observer2; imgs.forEach((img) => observer2.observe(img)); } } else { const imgs = []; for (let i2 = 0; i2 < chapter.content.picInfos.length; i2++) { const picInfo = chapter.content.picInfos[i2]; const img = document.createElement("img"); img.className = "fqa-comic-img"; img.alt = `第${i2 + 1}页`; img.src = picInfo.picUrl; imgs.push(img); } const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); if (readerContainer) { const scriptContainer = ensureScriptContainer(readerContainer, true); imgs.forEach((img) => scriptContainer.appendChild(img)); } } } const muyeReaderTitle = document.querySelector("h1.muye-reader-title"); let muyeReaderSubtitle = document.querySelector("div.muye-reader-subtitle"); (_h = document.querySelector("#fqa-subtitle")) == null ? void 0 : _h.remove(); if (muyeReaderSubtitle) { let _cloned = cloneElement(muyeReaderSubtitle); muyeReaderSubtitle.classList.add("fqa-hide"); _cloned.id = "fqa-subtitle"; muyeReaderSubtitle.insertAdjacentElement("afterend", _cloned); muyeReaderSubtitle = _cloned; _cloned.classList.remove("fqa-hide"); console.log("clone subtitle: ", _cloned); } if (muyeReaderTitle) { muyeReaderTitle.textContent = chapterTitle; } console.log("Current book:", currentBook); if (!currentBook || currentBook == null || currentBook.book_id !== ((_i = chapter.novel_data) == null ? void 0 : _i.book_id)) { currentBook = await getBookInfoAndCatalog((_j = chapter.novel_data) == null ? void 0 : _j.book_id); console.log("Current book:", currentBook); } if (currentBook && currentBook.chapter_list) { const currentChapterItem = currentBook.chapter_list.find((c) => c.item_id === itemId2); if (currentChapterItem) { console.log("Current chapter:", currentChapterItem); document.title = currentChapterItem.title + " - " + currentBook.title + " - 番茄小说"; if (document.getElementById("fqa-current-chapter-volume")) { const c = document.getElementById("fqa-current-chapter-volume"); if (c) { c.textContent = currentChapterItem.volume_title; } } else { const volSpan = document.createElement("span"); volSpan.className = "desc-item"; volSpan.id = "fqa-current-chapter-volume"; volSpan.textContent = currentChapterItem.volume_title; const c = muyeReaderSubtitle == null ? void 0 : muyeReaderSubtitle.firstChild; if (c) { c.insertAdjacentElement("beforebegin", volSpan); } } let updateTimeSpans = (muyeReaderSubtitle == null ? void 0 : muyeReaderSubtitle.querySelectorAll("span.desc-item")) || []; console.log("spans len", updateTimeSpans.length, "assertIsOffshelf", updateTimeSpans.length < 3); if (updateTimeSpans.length >= 3) { console.log("if"); let updateTimeSpan = updateTimeSpans[updateTimeSpans.length - 1]; let uttspan = updateTimeSpan.firstChild; uttspan == null ? void 0 : uttspan.remove(); updateTimeSpan.innerHTML = "更新时间:" + currentChapterItem.update_time; } else { console.log("else"); (_k = updateTimeSpans[updateTimeSpans.length - 1]) == null ? void 0 : _k.remove(); const updateTimeSpan = document.createElement("span"); updateTimeSpan.className = "desc-item"; updateTimeSpan.textContent = `更新时间:${currentChapterItem.update_time}`; console.log("assert equal", currentChapterItem.item_id === ((_l = currentChapterWithContent == null ? void 0 : currentChapterWithContent.novel_data) == null ? void 0 : _l.item_id)); console.log("wordcnt", (_m = currentChapterWithContent == null ? void 0 : currentChapterWithContent.novel_data) == null ? void 0 : _m.chapter_word_number); const c = document.getElementById("fqa-current-chapter-volume"); let b = null; if (currentChapterItem.item_id === ((_n = currentChapterWithContent == null ? void 0 : currentChapterWithContent.novel_data) == null ? void 0 : _n.item_id)) { const wordCntSpan = document.createElement("span"); wordCntSpan.className = "desc-item"; wordCntSpan.textContent = `本章字数:${(_o = currentChapterWithContent == null ? void 0 : currentChapterWithContent.novel_data) == null ? void 0 : _o.chapter_word_number}字`; console.log(wordCntSpan); if (c) { console.log("insert wordcnt"); c.insertAdjacentElement("afterend", wordCntSpan); } b = wordCntSpan; } console.log("insert update time"); (_p = b || c) == null ? void 0 : _p.insertAdjacentElement("afterend", updateTimeSpan); } } } if (state.open) { await switchChapter(itemId2, { cover: (currentBook == null ? void 0 : currentBook.cover_url) ?? "", title: (currentBook == null ? void 0 : currentBook.title) ?? document.title }); } else { refreshParagraphs(); } } async function startAudioPlay() { var _a; if (state.open) { closeAudiobook(); return; } const itemId2 = ((_a = window.location.pathname.split("/").pop()) == null ? void 0 : _a.substring(0, 19)) || ""; if (!itemId2) return; initAudioPanel(); await openAudiobook(itemId2, (currentBook == null ? void 0 : currentBook.book_id) ?? "", { cover: (currentBook == null ? void 0 : currentBook.cover_url) ?? "", title: (currentBook == null ? void 0 : currentBook.title) ?? document.title }); } async function onUrlChange$1(_previous) { await insertContent(); } async function onHashChange$1(_previous) { } async function onLoad$1() { var _a; async function fetchBookInfo() { var _a2, _b; const pageState = unsafeWindow.__INITIAL_STATE__; const bid = (_b = (_a2 = pageState == null ? void 0 : pageState.reader) == null ? void 0 : _a2.chapterData) == null ? void 0 : _b.bookId; currentBook = await getBookInfoAndCatalog(bid); } void fetchBookInfo(); const toolbar = document.querySelector("div.reader-toolbar > div"); const toolbarButton = document.querySelector("div.reader-toolbar > div > div:nth-child(3)"); if (toolbarButton && toolbar) { const c = cloneElement(toolbarButton); c.id = "fqa-toggle-audiobook"; const listenIcon = document.createElement("span"); listenIcon.textContent = "听"; listenIcon.style.width = "24px"; listenIcon.style.height = "24px"; listenIcon.style.fontSize = "24px"; listenIcon.style.lineHeight = "24px"; listenIcon.classList.add("muyeicon-icon"); listenIcon.classList.add("reader-toolbar-item-icon"); (_a = c.firstChild) == null ? void 0 : _a.replaceWith(listenIcon); const l = c.lastChild; if (l) { l.textContent = "听书"; } c.addEventListener("click", () => void startAudioPlay()); toolbar.appendChild(c); } const bookshelfButton = document.querySelector("div.reader-toolbar-item"); if (userState.isLogin && bookshelfButton && bookshelfButton.innerHTML.includes("书架")) { const shelf = cloneElement(bookshelfButton); bookshelfButton.replaceWith(shelf); let inShelf = false; let ready = false; let busy = false; async function ensureBookshelfState() { while (!currentBook) { await sleep(50); } inShelf = await isInBookshelf(currentBook.book_id); shelf.classList.toggle("reader-toolbar-item-disabled", inShelf); const label = shelf.lastChild; if (label) label.textContent = inShelf ? "已在书架" : "加入书架"; ready = true; } void ensureBookshelfState(); shelf.addEventListener("click", async () => { const book = currentBook; if (!ready || busy || !book) return; busy = true; try { if (inShelf) { if (settings$1.shelfRemoveConfirm) { if (!unsafeWindow.confirm(`确定要把《${book.title ?? "这本书"}》从书架移出吗?`)) return; } await removeFromBookshelf(book.book_id); } else { await addToBookshelf(book.book_id); } await ensureBookshelfState(); } catch (error) { console.error("[fqa:reader] 书架操作失败:", error); unsafeWindow.alert(error instanceof Error ? error.message : "书架操作失败"); } finally { busy = false; } }); } document.querySelector("div.muye-reader-btns"); await insertContent(); } function readerFilter(path, _query, _hash) { return path.startsWith("/reader") || path.startsWith("reader"); } const _exports$4 = [ { id: "readerHook_load", event: "load", handler: onLoad$1, filter: readerFilter }, { id: "readerHook_urlChange", event: "onUrlChange", handler: onUrlChange$1, filter: readerFilter }, { id: "readerHook_hashChange", event: "onHashChange", handler: onHashChange$1, filter: readerFilter } ]; const bookshelf = '\r\n \r\n \r\n \r\n \r\n \r\n'; const settings = '\r\n\r\n \r\n \r\n'; const name = "fanqie-assistant"; const version = "0.0.6"; const _hoisted_1$9 = { class: "fqa-set-dialog", role: "dialog", "aria-modal": "true", "aria-label": "助手设置" }; const _hoisted_2$9 = { class: "fqa-set-side" }; const _hoisted_3$9 = ["onClick", "onKeydown"]; const _hoisted_4$8 = { class: "fqa-set-main" }; const _hoisted_5$8 = { class: "fqa-set-row" }; const _hoisted_6$8 = { class: "fqa-set-row" }; const _hoisted_7$6 = { class: "fqa-set-row" }; const _hoisted_8$6 = { class: "fqa-set-row" }; const _hoisted_9$6 = { class: "fqa-set-row" }; const _hoisted_10$6 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_11$5 = { class: "fqa-set-row" }; const _hoisted_12$5 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_13$4 = ["disabled"]; const _hoisted_14$4 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_15$2 = { class: "fqa-set-radios" }; const _hoisted_16$1 = { class: "fqa-set-radio" }; const _hoisted_17$1 = { class: "fqa-set-radio" }; const _hoisted_18$1 = { class: "fqa-set-row" }; const _hoisted_19$1 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_20 = { class: "fqa-set-row", style: { "padding-top": "0", "border-bottom": "none" } }; const _hoisted_21 = { class: "fqa-set-row" }; const _hoisted_22 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_23 = { class: "fqa-set-radios" }; const _hoisted_24 = { class: "fqa-set-radio" }; const _hoisted_25 = { class: "fqa-set-radio" }; const _hoisted_26 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_27 = { class: "fqa-set-radios" }; const _hoisted_28 = { class: "fqa-set-radio" }; const _hoisted_29 = { class: "fqa-set-radio" }; const _hoisted_30 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_31 = { class: "fqa-set-row", style: { "padding-top": "0", "border-bottom": "none" } }; const _hoisted_32 = { class: "fqa-set-row", style: { "padding-top": "0", "border-bottom": "none" } }; const _hoisted_33 = { class: "fqa-set-row", style: { "padding-top": "0", "border-bottom": "none" } }; const _hoisted_34 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_35 = { class: "fqa-set-field" }; const _hoisted_36 = { class: "fqa-set-field" }; const _hoisted_37 = { class: "fqa-set-field" }; const _hoisted_38 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_39 = { class: "fqa-set-radios" }; const _hoisted_40 = { class: "fqa-set-radio" }; const _hoisted_41 = { class: "fqa-set-radio" }; const _hoisted_42 = { class: "fqa-set-row" }; const _hoisted_43 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_44 = { class: "fqa-set-radios" }; const _hoisted_45 = { class: "fqa-set-radio" }; const _hoisted_46 = { class: "fqa-set-radio" }; const _hoisted_47 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_48 = { class: "fqa-set-field" }; const _hoisted_49 = ["placeholder"]; const _hoisted_50 = { class: "fqa-set-field" }; const _hoisted_51 = ["placeholder"]; const _hoisted_52 = { class: "fqa-set-field" }; const _hoisted_53 = ["placeholder"]; const _hoisted_54 = { class: "fqa-set-actions" }; const _hoisted_55 = ["disabled"]; const _hoisted_56 = { key: 0, class: "fqa-set-note" }; const _hoisted_57 = { class: "fqa-set-note" }; const _hoisted_58 = { class: "fqa-set-links" }; const _hoisted_59 = { class: "fqa-set-link-row" }; const _hoisted_60 = ["href"]; const GREASYFORK = "https://greasyfork.org/zh-CN/scripts/589115-%E7%95%AA%E8%8C%84%E5%B0%8F%E8%AF%B4%E5%8A%A9%E6%89%8B"; const GITHUB = "https://github.com/naiyQAQ/fanqie-assistant"; const _sfc_main$9 = /* @__PURE__ */ vue.defineComponent({ __name: "SettingsView", emits: ["close"], setup(__props, { emit: __emit }) { const emit = __emit; function resetDownloadTuning() { settings$1.downloadBatchSize = DEFAULT_SETTINGS.downloadBatchSize; settings$1.downloadInterval = DEFAULT_SETTINGS.downloadInterval; settings$1.downloadRetries = DEFAULT_SETTINGS.downloadRetries; flushSettings(); } const SECTIONS = [ { key: "general", label: "常规" }, { key: "ui", label: "界面" }, { key: "search", label: "搜索" }, { key: "download", label: "下载" }, { key: "audiobook", label: "听书" }, { key: "protocol", label: "协议" }, { key: "about", label: "关于" } ]; const active = vue.ref("general"); const registering = vue.ref(false); const registerMsg = vue.ref(null); async function reRegister() { if (registering.value) return; registering.value = true; registerMsg.value = "正在注册新设备…"; try { const c = await provisionDevice(); settings$1.deviceId = ""; settings$1.installId = ""; settings$1.deviceType = ""; flushSettings(); registerMsg.value = `注册成功:${c.device_id}`; } catch (err) { console.error("[fqa:settings] 设备注册失败:", err); registerMsg.value = err instanceof Error ? `注册失败:${err.message}` : "注册失败"; } finally { registering.value = false; } } const currentDevice = vue.computed(() => ({ device_id: settings$1.deviceId || _config.currentConfig.device_id, install_id: settings$1.installId || _config.currentConfig.install_id, device_type: settings$1.deviceType || _config.currentConfig.device_type || "" })); function close2() { flushSettings(); emit("close"); } function onKey(e) { if (e.key === "Escape") close2(); } vue.onMounted(() => document.addEventListener("keydown", onKey)); vue.onBeforeUnmount(() => document.removeEventListener("keydown", onKey)); const FEEDBACK = `${GREASYFORK}/feedback`; return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", { class: "fqa-set-mask", onClick: vue.withModifiers(close2, ["self"]) }, [ vue.createElementVNode("div", _hoisted_1$9, [ vue.createElementVNode("button", { class: "fqa-set-close", "aria-label": "关闭", onClick: close2 }, "✕"), vue.createElementVNode("nav", _hoisted_2$9, [ _cache[31] || (_cache[31] = vue.createElementVNode("div", { class: "fqa-set-side-title" }, "助手设置", -1)), (vue.openBlock(), vue.createElementBlock(vue.Fragment, null, vue.renderList(SECTIONS, (s) => { return vue.createElementVNode("div", { key: s.key, class: vue.normalizeClass(["fqa-set-nav", { "fqa-set-nav-active": active.value === s.key }]), role: "button", tabindex: "0", onClick: ($event) => active.value = s.key, onKeydown: vue.withKeys(vue.withModifiers(($event) => active.value = s.key, ["prevent"]), ["enter"]) }, vue.toDisplayString(s.label), 43, _hoisted_3$9); }), 64)) ]), vue.createElementVNode("section", _hoisted_4$8, [ active.value === "general" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, [ _cache[37] || (_cache[37] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "常规", -1)), vue.createElementVNode("label", _hoisted_5$8, [ _cache[32] || (_cache[32] = vue.createElementVNode("span", { class: "fqa-set-label" }, "解密网页端混淆字体", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => vue.unref(settings$1).decryptFont = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).decryptFont] ]) ]), vue.createElementVNode("label", _hoisted_6$8, [ _cache[33] || (_cache[33] = vue.createElementVNode("span", { class: "fqa-set-label" }, "拦截网页事件上报", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[1] || (_cache[1] = ($event) => vue.unref(settings$1).blockReport = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).blockReport] ]) ]), vue.createElementVNode("label", _hoisted_7$6, [ _cache[34] || (_cache[34] = vue.createElementVNode("span", { class: "fqa-set-label" }, "允许阅读器复制文本", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[2] || (_cache[2] = ($event) => vue.unref(settings$1).allowCopy = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).allowCopy] ]) ]), vue.createElementVNode("label", _hoisted_8$6, [ _cache[35] || (_cache[35] = vue.createElementVNode("span", { class: "fqa-set-label" }, "移出书架时二次确认", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[3] || (_cache[3] = ($event) => vue.unref(settings$1).shelfRemoveConfirm = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).shelfRemoveConfirm] ]) ]), vue.createElementVNode("label", _hoisted_9$6, [ _cache[36] || (_cache[36] = vue.createElementVNode("span", { class: "fqa-set-label" }, "退出登录时二次确认", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[4] || (_cache[4] = ($event) => vue.unref(settings$1).logoutConfirm = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).logoutConfirm] ]) ]) ], 64)) : active.value === "ui" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 1 }, [ _cache[46] || (_cache[46] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "界面", -1)), vue.createElementVNode("div", _hoisted_10$6, [ _cache[38] || (_cache[38] = vue.createElementVNode("span", { class: "fqa-set-label" }, "阅读器字体", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[5] || (_cache[5] = ($event) => vue.unref(settings$1).readerFont = $event), class: "fqa-set-input", type: "text", placeholder: "留空表示使用默认字体" }, null, 512), [ [vue.vModelText, vue.unref(settings$1).readerFont] ]), _cache[39] || (_cache[39] = vue.createElementVNode("p", { class: "fqa-set-note" }, "填写字体名称,例如「思源宋体」。留空则跟随网页默认。", -1)) ]), vue.createElementVNode("div", _hoisted_11$5, [ _cache[40] || (_cache[40] = vue.createElementVNode("span", { class: "fqa-set-label" }, "自定义 CSS", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[6] || (_cache[6] = ($event) => vue.unref(settings$1).customCssEnabled = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).customCssEnabled] ]) ]), vue.createElementVNode("div", _hoisted_12$5, [ vue.withDirectives(vue.createElementVNode("textarea", { "onUpdate:modelValue": _cache[7] || (_cache[7] = ($event) => vue.unref(settings$1).customCss = $event), class: "fqa-set-textarea", disabled: !vue.unref(settings$1).customCssEnabled, spellcheck: "false", placeholder: "/* 自定义 CSS */" }, null, 8, _hoisted_13$4), [ [vue.vModelText, vue.unref(settings$1).customCss] ]), _cache[41] || (_cache[41] = vue.createElementVNode("p", { class: "fqa-set-note" }, "关闭开关后内容会保留,只是不再应用。", -1)) ]), vue.createElementVNode("div", _hoisted_14$4, [ _cache[44] || (_cache[44] = vue.createElementVNode("span", { class: "fqa-set-label" }, "书架单击书本", -1)), vue.createElementVNode("div", _hoisted_15$2, [ vue.createElementVNode("label", _hoisted_16$1, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[8] || (_cache[8] = ($event) => vue.unref(settings$1).bookshelfClickAction = $event), type: "radio", value: "read" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).bookshelfClickAction] ]), _cache[42] || (_cache[42] = vue.createElementVNode("span", null, "继续阅读", -1)) ]), vue.createElementVNode("label", _hoisted_17$1, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[9] || (_cache[9] = ($event) => vue.unref(settings$1).bookshelfClickAction = $event), type: "radio", value: "detail" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).bookshelfClickAction] ]), _cache[43] || (_cache[43] = vue.createElementVNode("span", null, "查看详情", -1)) ]) ]), _cache[45] || (_cache[45] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 按住 Ctrl 单击去往另一项;中键在新标签页打开,Ctrl+中键同样反向。 选「继续阅读」但这本书还没读过时,会退回详情页。 ", -1)) ]) ], 64)) : active.value === "search" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 2 }, [ _cache[50] || (_cache[50] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "搜索", -1)), vue.createElementVNode("label", _hoisted_18$1, [ _cache[47] || (_cache[47] = vue.createElementVNode("span", { class: "fqa-set-label" }, "接管搜索界面", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[10] || (_cache[10] = ($event) => vue.unref(settings$1).enhanceSearch = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).enhanceSearch] ]) ]), vue.createElementVNode("div", _hoisted_19$1, [ vue.createElementVNode("label", _hoisted_20, [ _cache[48] || (_cache[48] = vue.createElementVNode("span", { class: "fqa-set-label" }, "个人化推荐", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[11] || (_cache[11] = ($event) => vue.unref(settings$1).searchPersonalized = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).searchPersonalized] ]) ]), _cache[49] || (_cache[49] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 开启后搜索走同源请求,由浏览器自动带上你的登录 Cookie,番茄据此按阅读偏好排序。 凭据不经过脚本,也不会发往番茄以外的任何地方。关闭时走匿名请求。 ", -1)) ]) ], 64)) : active.value === "download" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 3 }, [ _cache[71] || (_cache[71] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "下载", -1)), vue.createElementVNode("label", _hoisted_21, [ _cache[51] || (_cache[51] = vue.createElementVNode("span", { class: "fqa-set-label" }, "显示下载入口", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[12] || (_cache[12] = ($event) => vue.unref(settings$1).enableDownload = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).enableDownload] ]) ]), vue.createElementVNode("div", _hoisted_22, [ _cache[54] || (_cache[54] = vue.createElementVNode("span", { class: "fqa-set-label" }, "默认格式", -1)), vue.createElementVNode("div", _hoisted_23, [ vue.createElementVNode("label", _hoisted_24, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[13] || (_cache[13] = ($event) => vue.unref(settings$1).downloadFormat = $event), type: "radio", value: "epub" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadFormat] ]), _cache[52] || (_cache[52] = vue.createElementVNode("span", null, "EPUB", -1)) ]), vue.createElementVNode("label", _hoisted_25, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[14] || (_cache[14] = ($event) => vue.unref(settings$1).downloadFormat = $event), type: "radio", value: "txt" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadFormat] ]), _cache[53] || (_cache[53] = vue.createElementVNode("span", null, "TXT", -1)) ]) ]), _cache[55] || (_cache[55] = vue.createElementVNode("p", { class: "fqa-set-note" }, " EPUB 保留原始排版、插图与分卷目录;TXT 是纯文本。右键菜单里可以单次指定格式。 ", -1)) ]), vue.createElementVNode("div", _hoisted_26, [ _cache[58] || (_cache[58] = vue.createElementVNode("span", { class: "fqa-set-label" }, "TXT 编码", -1)), vue.createElementVNode("div", _hoisted_27, [ vue.createElementVNode("label", _hoisted_28, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[15] || (_cache[15] = ($event) => vue.unref(settings$1).downloadCharset = $event), type: "radio", value: "utf-8" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadCharset] ]), _cache[56] || (_cache[56] = vue.createElementVNode("span", null, "UTF-8", -1)) ]), vue.createElementVNode("label", _hoisted_29, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[16] || (_cache[16] = ($event) => vue.unref(settings$1).downloadCharset = $event), type: "radio", value: "gbk" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadCharset] ]), _cache[57] || (_cache[57] = vue.createElementVNode("span", null, "GBK", -1)) ]) ]), _cache[59] || (_cache[59] = vue.createElementVNode("p", { class: "fqa-set-note" }, "EPUB 固定使用 UTF-8。老设备或部分阅读器可能需要 GBK。", -1)) ]), vue.createElementVNode("div", _hoisted_30, [ _cache[63] || (_cache[63] = vue.createElementVNode("span", { class: "fqa-set-label" }, "EPUB 选项", -1)), vue.createElementVNode("label", _hoisted_31, [ _cache[60] || (_cache[60] = vue.createElementVNode("span", { class: "fqa-set-label" }, "下载正文插图", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[17] || (_cache[17] = ($event) => vue.unref(settings$1).downloadImages = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadImages] ]) ]), vue.createElementVNode("label", _hoisted_32, [ _cache[61] || (_cache[61] = vue.createElementVNode("span", { class: "fqa-set-label" }, "保留书籍排版样式", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[18] || (_cache[18] = ($event) => vue.unref(settings$1).downloadBookCss = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadBookCss] ]) ]), vue.createElementVNode("label", _hoisted_33, [ _cache[62] || (_cache[62] = vue.createElementVNode("span", { class: "fqa-set-label" }, "为每卷生成卷页", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[19] || (_cache[19] = ($event) => vue.unref(settings$1).downloadVolumePage = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadVolumePage] ]) ]), _cache[64] || (_cache[64] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 插图逐张下载,图多的书会明显变慢、文件也更大;关闭后正文里仍保留图片地址,联网可看。 ", -1)) ]), vue.createElementVNode("div", _hoisted_34, [ _cache[68] || (_cache[68] = vue.createElementVNode("span", { class: "fqa-set-label" }, "请求节奏", -1)), _cache[69] || (_cache[69] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 接口对批量正文有限制,调得太激进会导致大量章节抓不到甚至触发风控。不清楚就别改。 ", -1)), vue.createElementVNode("label", _hoisted_35, [ _cache[65] || (_cache[65] = vue.createElementVNode("span", null, "每批章节数(1-30)", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[20] || (_cache[20] = ($event) => vue.unref(settings$1).downloadBatchSize = $event), class: "fqa-set-input", type: "number", min: "1", max: "30" }, null, 512), [ [ vue.vModelText, vue.unref(settings$1).downloadBatchSize, void 0, { number: true } ] ]) ]), vue.createElementVNode("label", _hoisted_36, [ _cache[66] || (_cache[66] = vue.createElementVNode("span", null, "批次间隔 (ms)", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[21] || (_cache[21] = ($event) => vue.unref(settings$1).downloadInterval = $event), class: "fqa-set-input", type: "number", min: "0", max: "10000", step: "50" }, null, 512), [ [ vue.vModelText, vue.unref(settings$1).downloadInterval, void 0, { number: true } ] ]) ]), vue.createElementVNode("label", _hoisted_37, [ _cache[67] || (_cache[67] = vue.createElementVNode("span", null, "重试轮数", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[22] || (_cache[22] = ($event) => vue.unref(settings$1).downloadRetries = $event), class: "fqa-set-input", type: "number", min: "0", max: "10" }, null, 512), [ [ vue.vModelText, vue.unref(settings$1).downloadRetries, void 0, { number: true } ] ]) ]), _cache[70] || (_cache[70] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 实测单次请求最多返回 30 章正文,间隔小于约 750ms 会被限流成每次 1 章。 ", -1)), vue.createElementVNode("div", { class: "fqa-set-actions" }, [ vue.createElementVNode("button", { class: "fqa-set-btn", onClick: resetDownloadTuning }, "恢复推荐值") ]) ]) ], 64)) : active.value === "audiobook" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 4 }, [ _cache[77] || (_cache[77] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "听书", -1)), vue.createElementVNode("div", _hoisted_38, [ _cache[74] || (_cache[74] = vue.createElementVNode("span", { class: "fqa-set-label" }, "读完一章后", -1)), vue.createElementVNode("div", _hoisted_39, [ vue.createElementVNode("label", _hoisted_40, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[23] || (_cache[23] = ($event) => vue.unref(settings$1).audiobookChapterEnd = $event), type: "radio", value: "next" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).audiobookChapterEnd] ]), _cache[72] || (_cache[72] = vue.createElementVNode("span", null, "自动下一章", -1)) ]), vue.createElementVNode("label", _hoisted_41, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[24] || (_cache[24] = ($event) => vue.unref(settings$1).audiobookChapterEnd = $event), type: "radio", value: "stop" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).audiobookChapterEnd] ]), _cache[73] || (_cache[73] = vue.createElementVNode("span", null, "停止播放", -1)) ]) ]), _cache[75] || (_cache[75] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 自动下一章会点击页面上的「下一章」,读到全书最后一章时自动停下。 ", -1)) ]), vue.createElementVNode("label", _hoisted_42, [ _cache[76] || (_cache[76] = vue.createElementVNode("span", { class: "fqa-set-label" }, "高亮并跟随朗读段落", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[25] || (_cache[25] = ($event) => vue.unref(settings$1).audiobookFollow = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).audiobookFollow] ]) ]), _cache[78] || (_cache[78] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 关闭后仍会高亮当前段落,但不再自动滚动页面。点击任意段落可跳到该处朗读。 ", -1)) ], 64)) : active.value === "protocol" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 5 }, [ _cache[88] || (_cache[88] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "协议", -1)), vue.createElementVNode("div", _hoisted_43, [ _cache[81] || (_cache[81] = vue.createElementVNode("span", { class: "fqa-set-label" }, "API 偏好", -1)), vue.createElementVNode("div", _hoisted_44, [ vue.createElementVNode("label", _hoisted_45, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[26] || (_cache[26] = ($event) => vue.unref(settings$1).apiPreference = $event), type: "radio", value: "app" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).apiPreference] ]), _cache[79] || (_cache[79] = vue.createElementVNode("span", null, "番茄 APP", -1)) ]), vue.createElementVNode("label", _hoisted_46, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[27] || (_cache[27] = ($event) => vue.unref(settings$1).apiPreference = $event), type: "radio", value: "redcandle" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).apiPreference] ]), _cache[80] || (_cache[80] = vue.createElementVNode("span", null, "红烛 APP", -1)) ]) ]), _cache[82] || (_cache[82] = vue.createElementVNode("p", { class: "fqa-set-note" }, "如果某协议数据不全,脚本可能会选择其他接口作为补充。", -1)) ]), vue.createElementVNode("div", _hoisted_47, [ _cache[86] || (_cache[86] = vue.createElementVNode("span", { class: "fqa-set-label" }, "设备信息", -1)), _cache[87] || (_cache[87] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 如果不知道这是什么,请保持默认。乱填可能导致脚本功能异常。 ", -1)), vue.createElementVNode("label", _hoisted_48, [ _cache[83] || (_cache[83] = vue.createElementVNode("span", null, "device_id", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[28] || (_cache[28] = ($event) => vue.unref(settings$1).deviceId = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.device_id || "自动注册" }, null, 8, _hoisted_49), [ [vue.vModelText, vue.unref(settings$1).deviceId] ]) ]), vue.createElementVNode("label", _hoisted_50, [ _cache[84] || (_cache[84] = vue.createElementVNode("span", null, "install_id (iid)", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[29] || (_cache[29] = ($event) => vue.unref(settings$1).installId = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.install_id || "自动注册" }, null, 8, _hoisted_51), [ [vue.vModelText, vue.unref(settings$1).installId] ]) ]), vue.createElementVNode("label", _hoisted_52, [ _cache[85] || (_cache[85] = vue.createElementVNode("span", null, "device_type", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[30] || (_cache[30] = ($event) => vue.unref(settings$1).deviceType = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.device_type || "自动注册" }, null, 8, _hoisted_53), [ [vue.vModelText, vue.unref(settings$1).deviceType] ]) ]), vue.createElementVNode("div", _hoisted_54, [ vue.createElementVNode("button", { class: "fqa-set-btn", disabled: registering.value, onClick: reRegister }, vue.toDisplayString(registering.value ? "注册中…" : "重新注册"), 9, _hoisted_55), registerMsg.value ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_56, vue.toDisplayString(registerMsg.value), 1)) : vue.createCommentVNode("", true) ]) ]) ], 64)) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 6 }, [ _cache[93] || (_cache[93] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "关于", -1)), vue.createElementVNode("p", _hoisted_57, "番茄小说助手 v" + vue.toDisplayString(vue.unref(version)), 1), vue.createElementVNode("div", _hoisted_58, [ vue.createElementVNode("div", { class: "fqa-set-link-row" }, [ _cache[89] || (_cache[89] = vue.createElementVNode("span", null, "GreasyFork 地址:", -1)), vue.createElementVNode("a", { href: GREASYFORK, target: "_blank", rel: "noreferrer noopener" }, "跳转") ]), vue.createElementVNode("div", { class: "fqa-set-link-row" }, [ _cache[90] || (_cache[90] = vue.createElementVNode("span", null, "GitHub 地址:", -1)), vue.createElementVNode("a", { href: GITHUB, target: "_blank", rel: "noreferrer noopener" }, "跳转") ]), vue.createElementVNode("div", _hoisted_59, [ _cache[92] || (_cache[92] = vue.createElementVNode("span", null, "问题反馈:", -1)), vue.createElementVNode("a", { href: FEEDBACK, target: "_blank", rel: "noreferrer noopener" }, "GreasyFork"), vue.createElementVNode("span", null, [ _cache[91] || (_cache[91] = vue.createTextVNode(" 或 ", -1)), vue.createElementVNode("a", { href: `${GITHUB}/issues`, target: "_blank", rel: "noreferrer noopener" }, " GitHub Issues ", 8, _hoisted_60) ]) ]) ]), _cache[94] || (_cache[94] = vue.createElementVNode("div", { class: "fqa-set-license" }, [ vue.createElementVNode("p", null, " 本脚本基于 GNU General Public License 3.0 授权,完全开源且免费,修改/二次开发请注意遵守开源协议。 "), vue.createElementVNode("p", null, "本脚本使用 TypeScript + Vue 开发,请避免直接修改编译产物。") ], -1)) ], 64)) ]) ]) ]); }; } }); const settingscss = "/* 助手设置面板 */\n\n.fqa-set-mask {\n position: fixed;\n inset: 0;\n z-index: 2147483200;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.45);\n font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,\n sans-serif;\n font-size: 14px;\n line-height: 1.6;\n color: var(--fqa-set-text, #1f2329);\n}\n\n.fqa-set-dialog {\n --fqa-set-bg: #fff;\n --fqa-set-side-bg: #f7f8fa;\n --fqa-set-text: #1f2329;\n --fqa-set-sub: #8f959e;\n --fqa-set-border: rgba(31, 35, 41, 0.1);\n --fqa-set-accent: #ff6f3d;\n --fqa-set-hover: rgba(31, 35, 41, 0.05);\n\n position: relative;\n display: flex;\n width: min(760px, 92vw);\n height: min(520px, 84vh);\n background: var(--fqa-set-bg);\n color: var(--fqa-set-text);\n border-radius: 12px;\n overflow: hidden;\n box-shadow: 0 12px 48px rgba(0, 0, 0, 0.24);\n}\n\n.fqa-set-close {\n position: absolute;\n top: 10px;\n right: 12px;\n width: 28px;\n height: 28px;\n padding: 0;\n border: none;\n border-radius: 6px;\n background: transparent;\n color: var(--fqa-set-sub);\n font-size: 15px;\n line-height: 1;\n cursor: pointer;\n}\n\n.fqa-set-close:hover {\n background: var(--fqa-set-hover);\n color: var(--fqa-set-text);\n}\n\n/* 左侧栏 */\n.fqa-set-side {\n flex: 0 0 148px;\n padding: 16px 8px;\n box-sizing: border-box;\n background: var(--fqa-set-side-bg);\n border-right: 1px solid var(--fqa-set-border);\n overflow-y: auto;\n}\n\n.fqa-set-side-title {\n padding: 0 10px 12px;\n font-size: 15px;\n font-weight: 600;\n}\n\n.fqa-set-nav {\n padding: 8px 10px;\n margin-bottom: 2px;\n border-radius: 6px;\n cursor: pointer;\n user-select: none;\n}\n\n.fqa-set-nav:hover {\n background: var(--fqa-set-hover);\n}\n\n.fqa-set-nav-active {\n background: var(--fqa-set-hover);\n color: var(--fqa-set-accent);\n font-weight: 600;\n}\n\n/* 右侧内容 */\n.fqa-set-main {\n flex: 1 1 auto;\n padding: 20px 24px;\n box-sizing: border-box;\n overflow-y: auto;\n}\n\n.fqa-set-h {\n margin: 0 0 14px;\n font-size: 16px;\n font-weight: 600;\n}\n\n.fqa-set-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 10px 0;\n border-bottom: 1px solid var(--fqa-set-border);\n}\n\n.fqa-set-row:last-child {\n border-bottom: none;\n}\n\n.fqa-set-row-col {\n display: block;\n}\n\n.fqa-set-label {\n font-size: 14px;\n}\n\n.fqa-set-note {\n margin: 6px 0 0;\n font-size: 12px;\n color: var(--fqa-set-sub);\n}\n\n.fqa-set-warn {\n margin: 6px 0 10px;\n padding: 8px 10px;\n font-size: 12px;\n color: #a8371f;\n background: rgba(255, 111, 61, 0.1);\n border-left: 3px solid var(--fqa-set-accent);\n border-radius: 0 4px 4px 0;\n}\n\n/* 开关:用原生 checkbox 改造,避免额外依赖 */\n.fqa-set-switch {\n appearance: none;\n flex: 0 0 auto;\n position: relative;\n width: 38px;\n height: 22px;\n margin: 0;\n border-radius: 11px;\n background: rgba(31, 35, 41, 0.18);\n cursor: pointer;\n transition: background 0.18s ease;\n}\n\n.fqa-set-switch::after {\n content: '';\n position: absolute;\n top: 2px;\n left: 2px;\n width: 18px;\n height: 18px;\n border-radius: 50%;\n background: #fff;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n transition: transform 0.18s ease;\n}\n\n.fqa-set-switch:checked {\n background: var(--fqa-set-accent);\n}\n\n.fqa-set-switch:checked::after {\n transform: translateX(16px);\n}\n\n.fqa-set-input {\n width: 100%;\n margin-top: 8px;\n padding: 7px 10px;\n box-sizing: border-box;\n border: 1px solid var(--fqa-set-border);\n border-radius: 6px;\n background: var(--fqa-set-bg);\n color: var(--fqa-set-text);\n font-size: 13px;\n font-family: inherit;\n}\n\n.fqa-set-input:focus {\n outline: none;\n border-color: var(--fqa-set-accent);\n}\n\n.fqa-set-textarea {\n width: 100%;\n min-height: 150px;\n margin-top: 10px;\n padding: 10px;\n box-sizing: border-box;\n border: 1px solid var(--fqa-set-border);\n border-radius: 6px;\n background: var(--fqa-set-bg);\n color: var(--fqa-set-text);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n line-height: 1.6;\n resize: vertical;\n}\n\n.fqa-set-textarea:disabled {\n background: var(--fqa-set-side-bg);\n color: var(--fqa-set-sub);\n cursor: not-allowed;\n}\n\n.fqa-set-textarea:focus {\n outline: none;\n border-color: var(--fqa-set-accent);\n}\n\n.fqa-set-radios {\n display: flex;\n gap: 20px;\n margin-top: 8px;\n}\n\n.fqa-set-radio {\n display: flex;\n align-items: center;\n gap: 6px;\n cursor: pointer;\n}\n\n.fqa-set-radio input {\n accent-color: var(--fqa-set-accent);\n}\n\n.fqa-set-field {\n display: block;\n margin-top: 10px;\n font-size: 12px;\n color: var(--fqa-set-sub);\n}\n\n.fqa-set-actions {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-top: 14px;\n}\n\n.fqa-set-btn {\n padding: 7px 16px;\n border: 1px solid var(--fqa-set-border);\n border-radius: 6px;\n background: var(--fqa-set-bg);\n color: var(--fqa-set-text);\n font-size: 13px;\n font-family: inherit;\n cursor: pointer;\n}\n\n.fqa-set-btn:hover:not(:disabled) {\n border-color: var(--fqa-set-accent);\n color: var(--fqa-set-accent);\n}\n\n.fqa-set-btn:disabled {\n color: var(--fqa-set-sub);\n cursor: not-allowed;\n}\n\n/* 关于 */\n.fqa-set-links {\n margin-top: 12px;\n}\n\n.fqa-set-link-row {\n margin-bottom: 10px;\n font-size: 13px;\n word-break: break-all;\n}\n\n.fqa-set-link-row a {\n color: var(--fqa-set-accent);\n text-decoration: none;\n}\n\n.fqa-set-link-row a:hover {\n text-decoration: underline;\n}\n\n.fqa-set-license {\n margin-top: 20px;\n padding-top: 14px;\n border-top: 1px solid var(--fqa-set-border);\n font-size: 12px;\n color: var(--fqa-set-sub);\n}\n\n.fqa-set-license p {\n margin: 0 0 6px;\n}\n\n/* 深色 */\n@media (prefers-color-scheme: dark) {\n .fqa-set-dialog {\n --fqa-set-bg: #23272e;\n --fqa-set-side-bg: #1c2026;\n --fqa-set-text: #e5e6eb;\n --fqa-set-sub: #8f959e;\n --fqa-set-border: rgba(255, 255, 255, 0.12);\n --fqa-set-hover: rgba(255, 255, 255, 0.08);\n }\n\n .fqa-set-switch {\n background: rgba(255, 255, 255, 0.2);\n }\n\n .fqa-set-warn {\n color: #ffb59b;\n }\n}\n"; const CONTAINER_ID$3 = "fqa-settings-root"; const STYLE_ID$4 = "fqa-settings-style"; let app$3 = null; let container$3 = null; function injectStyle$4() { if (document.getElementById(STYLE_ID$4)) return; const style = document.createElement("style"); style.id = STYLE_ID$4; style.textContent = settingscss; document.head.appendChild(style); } function closeSettings() { app$3 == null ? void 0 : app$3.unmount(); app$3 = null; container$3 == null ? void 0 : container$3.remove(); container$3 = null; } function openSettings() { if (app$3) return; injectStyle$4(); container$3 = document.createElement("div"); container$3.id = CONTAINER_ID$3; document.body.appendChild(container$3); app$3 = vue.createApp({ render: () => vue.h(_sfc_main$9, { onClose: closeSettings }) }); app$3.config.errorHandler = (err, _instance, info) => { console.error(`[fqa:settings] Vue error (${info}):`, err); }; app$3.mount(container$3); } function formatReadingTime(readBookTime) { let minutes = readBookTime / 60000n; const hours = minutes / 60n; minutes = minutes % 60n; return `${hours} 时 ${minutes} 分`; } function createMenuItem(text, icon, onclick) { const item = document.createElement("div"); item.role = "menuitem"; item.classList.add("fqa-menu-item", "arco-menu-item", "serial-menu-item", "slogin-user-avatar__menu-item-wrapper"); const inner1 = document.createElement("div"); inner1.classList.add("slogin-user-avatar__menu-item"); if (icon) { const iconDiv = document.createElement("div"); iconDiv.classList.add("slogin-user-avatar__menu-item__icon"); if (document.querySelector("div.muye-reader-dark")) { console.log("isdark"); iconDiv.classList.add("fqa-icon-dark"); } else { console.log("islight"); } iconDiv.innerHTML = icon; inner1.appendChild(iconDiv); } const inner2 = document.createElement("div"); inner2.classList.add("slogin-user-avatar__menu-item__content"); inner2.textContent = text; inner1.appendChild(inner2); item.appendChild(inner1); if (onclick) { item.addEventListener("click", onclick); } return item; } async function mainHook$3(_previous) { const userInfo = await getDetailedUserInfo(); if (!userInfo) { return; } const injected = /* @__PURE__ */ new WeakSet(); const inject2 = (menuInner) => { if (menuInner.querySelectorAll("div.arco-menu-inline").length > 0) { return; } if (injected.has(menuInner)) { return; } injected.add(menuInner); console.log("user action dialog created", menuInner); const firstDiv = createMenuItem(`阅读 ${userInfo.read_book_num} 本书`); menuInner.insertAdjacentElement("afterbegin", firstDiv); const secondDiv = createMenuItem(formatReadingTime(userInfo.read_book_time ?? 0n)); firstDiv.insertAdjacentElement("afterend", secondDiv); const thirdDiv = createMenuItem("我的书架", bookshelf, () => { window.open("https://fanqienovel.com/bookshelf", "_blank"); }); secondDiv.insertAdjacentElement("afterend", thirdDiv); menuInner.appendChild(createMenuItem("助手设置", settings, openSettings)); menuInner.childNodes.forEach((node) => { var _a, _b; if (((_b = (_a = node.lastChild) == null ? void 0 : _a.textContent) == null ? void 0 : _b.trim()) === "退出登录") { const original = node; const replaced = cloneElement(node); original.classList.add("fqa-hide"); original.insertAdjacentElement("afterend", replaced); replaced.addEventListener("click", (_) => { if (!settings$1.logoutConfirm || unsafeWindow.confirm("确认退出登录吗?")) { original.click(); } }); } }); }; const scan = (root) => { if (root.classList.contains("arco-menu-inner")) { inject2(root); } root.querySelectorAll(".arco-menu-inner").forEach(inject2); }; const observer2 = new MutationObserver((mutationsList) => { mutationsList.forEach((mutation) => { if (mutation.type === "childList") { mutation.addedNodes.forEach((node) => { if (node instanceof HTMLElement) { scan(node); } }); } }); }); observer2.observe(document.body, { childList: true, subtree: true }); scan(document.body); } async function guestHook(_previous) { const injected = /* @__PURE__ */ new WeakSet(); const inject2 = (registerBtn) => { const parent = registerBtn.parentElement; if (!parent || injected.has(parent)) return; if (parent.querySelector(".fqa-settings-entry")) return; injected.add(parent); const entry = cloneElement(registerBtn); entry.classList.add("fqa-settings-entry"); entry.textContent = "助手设置"; entry.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); openSettings(); }); registerBtn.insertAdjacentElement("afterend", entry); }; const scan = (root) => { root.querySelectorAll(".slogin-user-avatar__buttons__item").forEach((el) => { var _a; if (el.classList.contains("fqa-settings-entry")) return; if (((_a = el.textContent) == null ? void 0 : _a.trim()) === "注册") inject2(el); }); }; const observer2 = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node instanceof HTMLElement) scan(node); } } }); observer2.observe(document.body, { childList: true, subtree: true }); scan(document); } function filter$3(path, _query, _hash) { return userState.isLogin && !path.startsWith("/writer") && !path.startsWith("/welfare"); } function guestFilter(path, _query, _hash) { return !userState.isLogin && !path.startsWith("/writer") && !path.startsWith("/welfare"); } const _exports$3 = [ { id: "userHook", event: "load", filter: filter$3, handler: mainHook$3 }, { id: "userHook_guest", event: "load", filter: guestFilter, handler: guestHook } ]; const MIDDLE_BUTTON = 1; function modifiersOf(event) { return { flip: event.ctrlKey || event.metaKey, newTab: "button" in event && event.button === MIDDLE_BUTTON }; } function isMiddleButton(event) { return event.button === MIDDLE_BUTTON; } function readerUrl(chapterId) { return `https://fanqienovel.com/reader/${chapterId}`; } function bookPageUrl(bookId2) { return `https://fanqienovel.com/page/${bookId2}`; } function openUrl(url, newTab = false) { if (!newTab) { unsafeWindow.location.href = url; return; } if (!unsafeWindow.open(url, "_blank")) { console.warn("[fqa:nav] 新标签页被拦截,请检查浏览器的弹窗设置:", url); } } const _hoisted_1$8 = ["aria-label", "onKeydown"]; const _hoisted_2$8 = { class: "fqa-cover" }; const _hoisted_3$8 = ["src", "alt"]; const _hoisted_4$7 = { key: 1, class: "fqa-cover-progress" }; const _hoisted_5$7 = ["title"]; const _hoisted_6$7 = ["title"]; const _sfc_main$8 = /* @__PURE__ */ vue.defineComponent({ __name: "BookCard", props: { entry: {} }, emits: ["hover", "leave", "open", "visible", "contextmenu"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; const detail = vue.computed(() => props.entry.detail); const title = vue.computed(() => { var _a; return ((_a = detail.value) == null ? void 0 : _a.title) ?? ""; }); const tag = vue.computed(() => { const d = detail.value; if (!d) return null; if (d.update_status === "1") return { text: "更新", cls: "" }; if (d.status === "4") return { text: "断更", cls: "fqa-cover-tag-gray" }; if (d.status === "0") return { text: "完结", cls: "fqa-cover-tag-gray" }; if (d.status === "1") return { text: "连载", cls: "fqa-cover-tag-gray" }; return null; }); const progressText = vue.computed(() => { const d = detail.value; if (!d) return ""; const total = d.total_chapter_count || 0; const order = Math.max(0, Math.min(d.current_chapter_order || 0, total)); if (!order) return total ? `未读 · 共${total}章` : "未读"; return `${order}章/${total}章`; }); const progressPercent = vue.computed(() => { const d = detail.value; if (!d || !d.total_chapter_count) return 0; const ratio = (d.current_chapter_order || 0) / d.total_chapter_count; return Math.max(0, Math.min(1, ratio)) * 100; }); function onEnter2(event) { emit("hover", { entry: props.entry, el: event.currentTarget }); } function onContextMenu(event) { event.preventDefault(); emit("contextmenu", { entry: props.entry, x: event.clientX, y: event.clientY }); } function open2(event) { emit("open", { entry: props.entry, mods: modifiersOf(event) }); } function onAuxClick(event) { if (!isMiddleButton(event)) return; event.preventDefault(); open2(event); } function onMouseDown(event) { if (isMiddleButton(event)) event.preventDefault(); } const imgLoaded = vue.ref(false); vue.watch( () => { var _a; return (_a = detail.value) == null ? void 0 : _a.cover_url; }, () => { imgLoaded.value = false; } ); const cardRef = vue.ref(null); let io = null; vue.onMounted(() => { if (typeof IntersectionObserver === "undefined") { emit("visible", props.entry); return; } io = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting)) { emit("visible", props.entry); } }, { rootMargin: "200px" } ); if (cardRef.value) io.observe(cardRef.value); }); vue.onBeforeUnmount(() => { io == null ? void 0 : io.disconnect(); io = null; }); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", { ref_key: "cardRef", ref: cardRef, class: "fqa-card", role: "link", tabindex: "0", "aria-label": title.value, onMouseenter: onEnter2, onMouseleave: _cache[2] || (_cache[2] = ($event) => emit("leave")), onContextmenu: onContextMenu, onClick: open2, onAuxclick: onAuxClick, onMousedown: onMouseDown, onKeydown: [ vue.withKeys(vue.withModifiers(open2, ["prevent"]), ["enter"]), vue.withKeys(vue.withModifiers(open2, ["prevent"]), ["space"]) ] }, [ !detail.value ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, [ _cache[3] || (_cache[3] = vue.createElementVNode("div", { class: "fqa-sk-cover fqa-sk-anim" }, null, -1)), _cache[4] || (_cache[4] = vue.createElementVNode("div", { class: "fqa-sk-line fqa-sk-anim", style: { "width": "90%" } }, null, -1)), _cache[5] || (_cache[5] = vue.createElementVNode("div", { class: "fqa-sk-line fqa-sk-anim", style: { "width": "55%" } }, null, -1)) ], 64)) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 1 }, [ vue.createElementVNode("div", _hoisted_2$8, [ vue.createElementVNode("img", { class: vue.normalizeClass(["fqa-cover-img", { "fqa-cover-img-loading": !imgLoaded.value }]), crossorigin: "anonymous", loading: "lazy", referrerpolicy: "no-referrer", src: detail.value.cover_url, alt: title.value, onLoad: _cache[0] || (_cache[0] = ($event) => imgLoaded.value = true), onError: _cache[1] || (_cache[1] = ($event) => imgLoaded.value = true) }, null, 42, _hoisted_3$8), tag.value ? (vue.openBlock(), vue.createElementBlock("span", { key: 0, class: vue.normalizeClass(["fqa-cover-tag", tag.value.cls]) }, vue.toDisplayString(tag.value.text), 3)) : vue.createCommentVNode("", true), progressPercent.value > 0 ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_4$7, [ vue.createElementVNode("span", { class: "fqa-cover-progress-bar", style: vue.normalizeStyle({ width: progressPercent.value + "%" }) }, null, 4) ])) : vue.createCommentVNode("", true) ]), vue.createElementVNode("div", { class: "fqa-card-title", title: title.value }, vue.toDisplayString(title.value), 9, _hoisted_5$7), vue.createElementVNode("div", { class: "fqa-card-sub", title: detail.value.current_chapter_title }, vue.toDisplayString(progressText.value), 9, _hoisted_6$7) ], 64)) ], 40, _hoisted_1$8); }; } }); const _hoisted_1$7 = ["aria-label"]; const _hoisted_2$7 = { class: "fqa-group-cover" }; const _hoisted_3$7 = { class: "fqa-group-grid" }; const _hoisted_4$6 = ["src", "alt"]; const _hoisted_5$6 = ["title"]; const _hoisted_6$6 = { class: "fqa-card-sub" }; const _sfc_main$7 = /* @__PURE__ */ vue.defineComponent({ __name: "BookGroupCard", props: { group: {} }, emits: ["open", "visible"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; const previewBooks = vue.computed(() => props.group.books.slice(0, 4)); const covers = vue.computed( () => previewBooks.value.map((entry) => entry.detail).filter((detail) => !!detail) ); const cardRef = vue.ref(null); let io = null; function request() { const pending = previewBooks.value.filter((entry) => !entry.detail); if (pending.length) emit("visible", pending); } vue.onMounted(() => { if (typeof IntersectionObserver === "undefined") { request(); return; } io = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting)) request(); }, { rootMargin: "200px" } ); if (cardRef.value) io.observe(cardRef.value); }); vue.onBeforeUnmount(() => { io == null ? void 0 : io.disconnect(); io = null; }); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", { ref_key: "cardRef", ref: cardRef, class: "fqa-card", role: "button", tabindex: "0", "aria-label": `分组 ${__props.group.name}`, onClick: _cache[0] || (_cache[0] = ($event) => emit("open", __props.group)), onKeydown: [ _cache[1] || (_cache[1] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.group), ["prevent"]), ["enter"])), _cache[2] || (_cache[2] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.group), ["prevent"]), ["space"])) ] }, [ vue.createElementVNode("div", _hoisted_2$7, [ vue.createElementVNode("div", _hoisted_3$7, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(covers.value, (detail) => { return vue.openBlock(), vue.createElementBlock("div", { key: detail.book_id, class: "fqa-group-cell" }, [ vue.createElementVNode("img", { crossorigin: "anonymous", loading: "lazy", referrerpolicy: "no-referrer", src: detail.cover_url, alt: detail.title }, null, 8, _hoisted_4$6) ]); }), 128)) ]) ]), vue.createElementVNode("div", { class: "fqa-card-title", title: __props.group.name }, vue.toDisplayString(__props.group.name), 9, _hoisted_5$6), vue.createElementVNode("div", _hoisted_6$6, "共" + vue.toDisplayString(__props.group.books.length) + "本书", 1) ], 40, _hoisted_1$7); }; } }); const SECOND = 1e3; const MINUTE = 60 * SECOND; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; const MONTH = 30 * DAY; const YEAR = 365 * DAY; function normalizeTimestamp(ts) { if (!ts || ts <= 0) return 0; return ts < 1e12 ? ts * 1e3 : ts; } function fromNow(ts) { const time = normalizeTimestamp(ts); if (!time) return "从未"; const diff = Date.now() - time; if (diff < MINUTE) return "刚刚"; if (diff < HOUR) return `${Math.floor(diff / MINUTE)}分钟前`; if (diff < DAY) return `${Math.floor(diff / HOUR)}小时前`; if (diff < MONTH) return `${Math.floor(diff / DAY)}天前`; if (diff < YEAR) return `${Math.floor(diff / MONTH)}个月前`; return `${Math.floor(diff / YEAR)}年前`; } function formatDateTime(ts) { const time = normalizeTimestamp(ts); if (!time) return "未知"; const d = new Date(time); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } const _hoisted_1$6 = ["title"]; const _hoisted_2$6 = { key: 0, class: "fqa-hover-author" }; const _hoisted_3$6 = { class: "fqa-hover-stats" }; const _hoisted_4$5 = ["title"]; const _hoisted_5$5 = { class: "fqa-hover-stat-k" }; const _hoisted_6$5 = { class: "fqa-hover-stat" }; const _hoisted_7$5 = { class: "fqa-hover-stat-v" }; const _hoisted_8$5 = { class: "fqa-hover-stat" }; const _hoisted_9$5 = { class: "fqa-hover-stat-v" }; const _hoisted_10$5 = { class: "fqa-hover-seg" }; const _hoisted_11$4 = { class: "fqa-hover-abstract" }; const _hoisted_12$4 = { key: 0, class: "fqa-hover-chapter" }; const _sfc_main$6 = /* @__PURE__ */ vue.defineComponent({ __name: "BookHoverCard", props: { entry: {}, x: {}, y: {}, height: {}, visible: { type: Boolean } }, emits: ["panel-enter", "panel-leave"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; const detail = vue.computed(() => { var _a; return ((_a = props.entry) == null ? void 0 : _a.detail) ?? null; }); const item = vue.computed(() => { var _a; return ((_a = props.entry) == null ? void 0 : _a.item) ?? null; }); const readAt = vue.computed(() => { var _a; return fromNow((_a = item.value) == null ? void 0 : _a.last_read_timestamp); }); const addedAt = vue.computed(() => { var _a; return fromNow((_a = item.value) == null ? void 0 : _a.add_shelf_time); }); const updatedAt = vue.computed(() => { var _a; return fromNow((_a = detail.value) == null ? void 0 : _a.last_chapter_update_time); }); const updatedAtFull = vue.computed(() => { var _a; return formatDateTime((_a = detail.value) == null ? void 0 : _a.last_chapter_update_time); }); const showUpdateTime = vue.ref(false); const latestChapter = vue.computed(() => { const d = detail.value; if (!d) return "—"; return d.total_chapter_count ? `${d.total_chapter_count}章` : "—"; }); const pickedTab = vue.ref(null); const chapterText = vue.computed(() => { var _a, _b; return ((_b = (_a = detail.value) == null ? void 0 : _a.current_chapter_summary) == null ? void 0 : _b.trim()) ?? ""; }); const bookText = vue.computed(() => { var _a, _b; return ((_b = (_a = detail.value) == null ? void 0 : _a.summary) == null ? void 0 : _b.trim()) ?? ""; }); const activeTab = vue.computed(() => { if (pickedTab.value) return pickedTab.value; return chapterText.value ? "chapter" : "book"; }); const abstractText = vue.computed(() => { const text = activeTab.value === "chapter" ? chapterText.value : bookText.value; return text || "暂无数据"; }); vue.watch( () => { var _a; return (_a = props.entry) == null ? void 0 : _a.item.book_id; }, () => { pickedTab.value = null; showUpdateTime.value = false; } ); const root = vue.ref(null); const supportsPopover = typeof HTMLElement !== "undefined" && typeof HTMLElement.prototype.showPopover === "function"; vue.watch( () => props.visible && !!detail.value, (show) => { const el = root.value; if (!el || !supportsPopover) return; try { if (show) el.showPopover(); else el.hidePopover(); } catch { } } ); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", { id: "fqa-bookshelf-hover", ref_key: "root", ref: root, popover: "manual", class: vue.normalizeClass({ "fqa-visible": __props.visible && !!detail.value }), style: vue.normalizeStyle({ left: __props.x + "px", top: __props.y + "px" }), onMouseenter: _cache[4] || (_cache[4] = ($event) => emit("panel-enter")), onMouseleave: _cache[5] || (_cache[5] = ($event) => emit("panel-leave")) }, [ detail.value ? (vue.openBlock(), vue.createElementBlock("div", { key: 0, class: "fqa-hover-inner", style: vue.normalizeStyle(__props.height ? { height: __props.height + "px" } : void 0) }, [ vue.createElementVNode("div", { class: "fqa-hover-title", title: detail.value.title }, vue.toDisplayString(detail.value.title), 9, _hoisted_1$6), detail.value.author ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_2$6, vue.toDisplayString(detail.value.author), 1)) : vue.createCommentVNode("", true), vue.createElementVNode("div", _hoisted_3$6, [ vue.createElementVNode("div", { class: "fqa-hover-stat", onMouseenter: _cache[0] || (_cache[0] = ($event) => showUpdateTime.value = true), onMouseleave: _cache[1] || (_cache[1] = ($event) => showUpdateTime.value = false) }, [ vue.createElementVNode("div", { class: "fqa-hover-stat-v", title: updatedAtFull.value }, vue.toDisplayString(showUpdateTime.value ? updatedAt.value : latestChapter.value), 9, _hoisted_4$5), vue.createElementVNode("div", _hoisted_5$5, vue.toDisplayString(showUpdateTime.value ? "更新于" : "最新章"), 1) ], 32), vue.createElementVNode("div", _hoisted_6$5, [ vue.createElementVNode("div", _hoisted_7$5, vue.toDisplayString(readAt.value), 1), _cache[6] || (_cache[6] = vue.createElementVNode("div", { class: "fqa-hover-stat-k" }, "阅读过", -1)) ]), vue.createElementVNode("div", _hoisted_8$5, [ vue.createElementVNode("div", _hoisted_9$5, vue.toDisplayString(addedAt.value), 1), _cache[7] || (_cache[7] = vue.createElementVNode("div", { class: "fqa-hover-stat-k" }, "已加入书架", -1)) ]) ]), vue.createElementVNode("div", _hoisted_10$5, [ vue.createElementVNode("button", { class: vue.normalizeClass(["fqa-hover-seg-btn", { "fqa-hover-seg-active": activeTab.value === "chapter" }]), onClick: _cache[2] || (_cache[2] = ($event) => pickedTab.value = "chapter") }, " 本章梗概 ", 2), vue.createElementVNode("button", { class: vue.normalizeClass(["fqa-hover-seg-btn", { "fqa-hover-seg-active": activeTab.value === "book" }]), onClick: _cache[3] || (_cache[3] = ($event) => pickedTab.value = "book") }, " 全书简介 ", 2) ]), vue.createElementVNode("div", _hoisted_11$4, [ activeTab.value === "chapter" && detail.value.current_chapter_title ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_12$4, vue.toDisplayString(detail.value.current_chapter_title), 1)) : vue.createCommentVNode("", true), vue.createTextVNode(" " + vue.toDisplayString(abstractText.value), 1) ]) ], 4)) : vue.createCommentVNode("", true) ], 38); }; } }); const _hoisted_1$5 = ["aria-disabled", "onMouseenter", "onClick"]; const _hoisted_2$5 = { key: 0, class: "fqa-menu-arrow" }; const _hoisted_3$5 = ["onClick"]; const MARGIN = 8; const _sfc_main$5 = /* @__PURE__ */ vue.defineComponent({ __name: "ContextMenu", props: { visible: { type: Boolean }, x: {}, y: {}, items: {} }, emits: ["select", "close"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; const rootRef = vue.ref(null); const subRef = vue.ref(null); const openKey = vue.ref(null); const pos = vue.ref({ x: 0, y: 0 }); const subPos = vue.ref({ x: 0, y: 0 }); async function place() { pos.value = { x: props.x, y: props.y }; await vue.nextTick(); const el = rootRef.value; if (!el) return; const { width, height } = el.getBoundingClientRect(); let x = props.x; let y = props.y; if (x + width > window.innerWidth - MARGIN) x = props.x - width; if (y + height > window.innerHeight - MARGIN) y = window.innerHeight - height - MARGIN; pos.value = { x: Math.max(MARGIN, x), y: Math.max(MARGIN, y) }; } vue.watch( () => [props.visible, props.x, props.y], () => { openKey.value = null; if (props.visible) void place(); } ); async function openSub(item, event) { var _a; if (!((_a = item.children) == null ? void 0 : _a.length)) { openKey.value = null; return; } openKey.value = item.key; const row = event.currentTarget; const rect = row.getBoundingClientRect(); subPos.value = { x: rect.right - 2, y: rect.top }; await vue.nextTick(); const el = subRef.value; if (!el) return; const { width, height } = el.getBoundingClientRect(); let x = rect.right - 2; let y = rect.top; if (x + width > window.innerWidth - MARGIN) x = rect.left - width + 2; if (y + height > window.innerHeight - MARGIN) y = window.innerHeight - height - MARGIN; subPos.value = { x: Math.max(MARGIN, x), y: Math.max(MARGIN, y) }; } function choose(item) { var _a; if (item.disabled || ((_a = item.children) == null ? void 0 : _a.length)) return; emit("select", item.key); emit("close"); } function onDocPointer(e) { var _a, _b; const t = e.target; if (((_a = rootRef.value) == null ? void 0 : _a.contains(t)) || ((_b = subRef.value) == null ? void 0 : _b.contains(t))) return; emit("close"); } function onKey(e) { if (e.key === "Escape") emit("close"); } vue.onMounted(() => { document.addEventListener("pointerdown", onDocPointer, true); document.addEventListener("keydown", onKey); window.addEventListener("scroll", () => emit("close"), true); }); vue.onBeforeUnmount(() => { document.removeEventListener("pointerdown", onDocPointer, true); document.removeEventListener("keydown", onKey); }); const style = vue.computed(() => ({ left: `${pos.value.x}px`, top: `${pos.value.y}px` })); const subStyle = vue.computed(() => ({ left: `${subPos.value.x}px`, top: `${subPos.value.y}px` })); const activeChildren = vue.computed( () => { var _a; return ((_a = props.items.find((i2) => i2.key === openKey.value)) == null ? void 0 : _a.children) ?? []; } ); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock(vue.Fragment, null, [ __props.visible ? (vue.openBlock(), vue.createElementBlock("div", { key: 0, ref_key: "rootRef", ref: rootRef, class: "fqa-menu", style: vue.normalizeStyle(style.value), role: "menu" }, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(__props.items, (item) => { var _a; return vue.openBlock(), vue.createElementBlock("div", { key: item.key, class: vue.normalizeClass(["fqa-menu-row", { "fqa-menu-danger": item.danger, "fqa-menu-disabled": item.disabled, "fqa-menu-open": openKey.value === item.key }]), role: "menuitem", "aria-disabled": item.disabled, onMouseenter: ($event) => openSub(item, $event), onClick: ($event) => choose(item) }, [ vue.createElementVNode("span", null, vue.toDisplayString(item.label), 1), ((_a = item.children) == null ? void 0 : _a.length) ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_2$5, "›")) : vue.createCommentVNode("", true) ], 42, _hoisted_1$5); }), 128)) ], 4)) : vue.createCommentVNode("", true), __props.visible && activeChildren.value.length ? (vue.openBlock(), vue.createElementBlock("div", { key: 1, ref_key: "subRef", ref: subRef, class: "fqa-menu fqa-menu-sub", style: vue.normalizeStyle(subStyle.value), role: "menu" }, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(activeChildren.value, (child) => { return vue.openBlock(), vue.createElementBlock("div", { key: child.key, class: vue.normalizeClass(["fqa-menu-row", { "fqa-menu-disabled": child.disabled }]), role: "menuitem", onClick: ($event) => choose(child) }, [ vue.createElementVNode("span", null, vue.toDisplayString(child.label), 1) ], 10, _hoisted_3$5); }), 128)) ], 4)) : vue.createCommentVNode("", true) ], 64); }; } }); const DETAIL_BATCH_SIZE = 20; const PAGE_SIZE = 24; const TABS = [ { key: "all", label: "全部" }, { key: "group", label: "分组" }, { key: "publish", label: "出版" } ]; function useBookshelf() { const entries = vue.ref([]); const loading = vue.ref(false); const detailLoading = vue.ref(false); const error = vue.ref(null); let loadToken = 0; let detailCache = /* @__PURE__ */ new Map(); let inflight = /* @__PURE__ */ new Set(); function applyDetails(details) { if (!details.length) return; const byId = new Map(details.map((d) => [d.book_id, d])); entries.value = entries.value.map((entry) => { const detail = byId.get(entry.item.book_id); return detail ? { ...entry, detail } : entry; }); } async function ensureDetails(items) { const token = loadToken; const pending = items.filter( (item) => !detailCache.has(item.book_id) && !inflight.has(item.book_id) ); if (!pending.length) return; pending.forEach((item) => inflight.add(item.book_id)); detailLoading.value = true; try { for (const batch of chunk(pending, DETAIL_BATCH_SIZE)) { if (token !== loadToken) return; try { const details = await multidetail(batch); if (token !== loadToken) return; details.forEach((d) => detailCache.set(d.book_id, d)); applyDetails(details); } catch (err) { console.error("[fqa:bookshelf] 加载书籍详情失败:", err); } } } finally { pending.forEach((item) => inflight.delete(item.book_id)); if (token === loadToken && inflight.size === 0) detailLoading.value = false; } } async function load2(force = false) { const token = ++loadToken; inflight.clear(); if (force) detailCache = /* @__PURE__ */ new Map(); loading.value = true; error.value = null; try { const items = await getBookshelf(); if (token !== loadToken) return; items.sort((a, b) => b.last_operate_time - a.last_operate_time); entries.value = items.map((item) => ({ item, detail: detailCache.get(item.book_id) ?? null })); loading.value = false; } catch (err) { if (token !== loadToken) return; console.error("[fqa:bookshelf] 加载书架失败:", err); error.value = err instanceof Error ? err.message : String(err); loading.value = false; } } const groups = vue.computed(() => { const map = /* @__PURE__ */ new Map(); for (const entry of entries.value) { const name2 = entry.item.group_name; if (!name2) continue; let group = map.get(name2); if (!group) { group = { name: name2, books: [], last_operate_time: 0 }; map.set(name2, group); } group.books.push(entry); group.last_operate_time = Math.max(group.last_operate_time, entry.item.last_operate_time); } return [...map.values()].sort((a, b) => b.last_operate_time - a.last_operate_time); }); const allCells = vue.computed(() => { const sortable = []; for (const entry of entries.value) { if (entry.item.group_name) continue; sortable.push({ time: entry.item.last_operate_time, cell: { kind: "book", key: `book:${entry.item.book_id}`, entry } }); } for (const group of groups.value) { sortable.push({ time: group.last_operate_time, cell: { kind: "group", key: `group:${group.name}`, group } }); } return sortable.sort((a, b) => b.time - a.time).map((s) => s.cell); }); const groupCells = vue.computed( () => groups.value.map((group) => ({ kind: "group", key: `group:${group.name}`, group })) ); const publishCells = vue.computed( () => entries.value.filter((entry) => entry.item.is_publish).map((entry) => ({ kind: "book", key: `book:${entry.item.book_id}`, entry })) ); const counts = vue.computed(() => ({ all: entries.value.length, group: groups.value.length, publish: entries.value.filter((entry) => entry.item.is_publish).length })); function cellsOf(tab) { if (tab === "group") return groupCells.value; if (tab === "publish") return publishCells.value; return allCells.value; } function findGroup(name2) { return groups.value.find((group) => group.name === name2) ?? null; } return { entries, loading, detailLoading, error, groups, counts, load: load2, ensureDetails, cellsOf, findGroup, PAGE_SIZE }; } class CancelledError extends Error { constructor() { super("已取消"); this.name = "CancelledError"; } } class DownloadTask { constructor() { __publicField(this, "state", { title: "准备中…", current: 0, total: 0, subtitle: "", percentOnly: false, done: false, error: null, cancelled: false }); __publicField(this, "listeners", /* @__PURE__ */ new Set()); } get snapshot() { return this.state; } get isCancelled() { return this.state.cancelled; } subscribe(listener) { this.listeners.add(listener); listener(this.state); return () => this.listeners.delete(listener); } emit(patch) { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) { try { listener(this.state); } catch (err) { console.error("[fqa:download] 进度回调异常:", err); } } } /** 进入新阶段,重置计数 */ stage(title, total = 0) { this.emit({ title, total, current: 0, subtitle: "", percentOnly: false }); } /** 进入一个按百分比汇报的阶段(如 zip 压缩) */ stagePercent(title) { this.emit({ title, total: 100, current: 0, subtitle: "", percentOnly: true }); } update(current, subtitle) { this.emit(subtitle === void 0 ? { current } : { current, subtitle }); } /** 在当前计数上累加,供并发/分批场景使用 */ advance(delta = 1, subtitle) { this.update(this.state.current + delta, subtitle); } setTotal(total) { this.emit({ total }); } setSubtitle(subtitle) { this.emit({ subtitle }); } cancel() { if (this.state.done) return; this.emit({ cancelled: true, title: "已取消", subtitle: "" }); } finish(title = "完成") { this.emit({ done: true, title, subtitle: "" }); } fail(error) { const message = error instanceof Error ? error.message : String(error); this.emit({ done: true, error: message, title: "下载失败" }); } /** 取消时抛出,让调用栈直接退出 */ throwIfCancelled() { if (this.state.cancelled) throw new CancelledError(); } } const MAX_BATCH_SIZE = 30; function hasContent(chapter) { return Boolean(chapter == null ? void 0 : chapter.content) && chapter.content !== "Invalid"; } async function fetchChapters(itemIds, bookId2, task, options = {}) { var _a; const batchSize = Math.min( Math.max(1, options.batchSize ?? settings$1.downloadBatchSize), MAX_BATCH_SIZE ); const interval = Math.max(0, options.interval ?? settings$1.downloadInterval); const retries = Math.max(0, options.retries ?? settings$1.downloadRetries); const chapters = {}; let pending = [...itemIds]; task.stage("缓存章节…", itemIds.length); for (let round = 0; round <= retries && pending.length > 0; round++) { if (round > 0) { console.warn(`[fqa:download] 第 ${round} 轮重试,剩余 ${pending.length} 章`); task.setSubtitle(`重试 ${pending.length} 章(第 ${round} 轮)`); await sleep(Math.max(interval, 1e3)); } const missed = []; const batches = chunk(pending, batchSize); for (let i2 = 0; i2 < batches.length; i2++) { task.throwIfCancelled(); const batch = batches[i2]; try { const result = await getChapters(batch, bookId2); for (const itemId2 of batch) { const chapter = result[itemId2]; if (hasContent(chapter)) { chapters[itemId2] = chapter; } else { missed.push(itemId2); } } } catch (err) { if (err instanceof CancelledError) throw err; console.error("[fqa:download] 批量获取失败:", err); missed.push(...batch); } const got = Object.keys(chapters).length; const lastTitle = ((_a = options.titleOf) == null ? void 0 : _a.call(options, batch[batch.length - 1])) ?? ""; task.update(got, lastTitle); if (i2 < batches.length - 1 && interval > 0) await sleep(interval); } pending = missed; } if (pending.length > 0) { console.warn(`[fqa:download] ${pending.length} 章最终失败:`, pending.slice(0, 20)); } return { chapters, failed: pending }; } const FLAG = "__fqaSetImmediate"; function installSetImmediate(target) { if (target[FLAG]) return false; const tasks = /* @__PURE__ */ new Map(); let nextId = 1; const run = (id) => { const task = tasks.get(id); if (!task) return; tasks.delete(id); try { task.fn(...task.args); } catch (err) { console.error("[fqa:zipfix] setImmediate 任务异常:", err); } }; let schedule; if (typeof MessageChannel === "function") { const channel = new MessageChannel(); channel.port1.onmessage = (event) => run(event.data); schedule = (id) => channel.port2.postMessage(id); } else { schedule = (id) => void setTimeout(run, 0, id); } target["setImmediate"] = (fn, ...args) => { const id = nextId++; tasks.set(id, { fn, args }); schedule(id); return id; }; target["clearImmediate"] = (id) => void tasks.delete(id); target[FLAG] = true; return true; } function fixZipScheduler() { const patched = installSetImmediate(globalThis); try { const real = unsafeWindow; if (real && real !== globalThis) { installSetImmediate(real); } } catch { } if (patched) console.debug("[fqa:zipfix] 已替换 setImmediate,修复 JSZip 异步调度"); } /*! * llepub-saver - v1.0.3 (TypeScript port) * An EPUB ebook saving library for browser. * License: GPLv3 (https://www.gnu.org/licenses/gpl-3.0.html) * * 依赖: * - Moment.js (https://momentjs.com/) - MIT License * - JSZip (https://stuk.github.io/jszip/) - Dual-licensed under MIT and GPLv3. * * 相对原 JS 版的改动: * - 补齐类型,改成 ES module import(JSZip / moment 不再依赖全局变量); * - 网络请求可注入(见 EpubSaverOptions),默认用页面 fetch。番茄的部分图床 * 不给 CORS 头,调用方可以传入走 GM_xmlhttpRequest 的实现; * - 图片扩展名改为嗅探文件头,注入式 fetch 拿不到响应头也能判断; * - 写入 XML 的文本(标题、作者、简介等)统一转义,否则含 & < 的书名会 * 生成非法 XML,阅读器直接打不开。 */ fixZipScheduler(); function yieldFrame() { return new Promise((resolve) => { let settled = false; const done = () => { if (settled) return; settled = true; resolve(); }; const raf = globalThis.requestAnimationFrame; if (typeof raf === "function") raf(done); setTimeout(done, 32); }); } const DEFAULT_I18N = { en: { cover: "Cover", tableOfContents: "Table of Contents", chapters: "Chapters" }, "zh-CN": { cover: "封面", tableOfContents: "目录", chapters: "章节" }, "zh-TW": { cover: "封面", tableOfContents: "目錄", chapters: "章節" }, es: { cover: "Portada", tableOfContents: "Índice", chapters: "Capítulos" }, fr: { cover: "Couverture", tableOfContents: "Table des matières", chapters: "Chapitres" }, de: { cover: "Cover", tableOfContents: "Inhaltsverzeichnis", chapters: "Kapitel" }, ja: { cover: "表紙", tableOfContents: "目次", chapters: "章" }, ko: { cover: "표지", tableOfContents: "목차", chapters: "장" }, ru: { cover: "Обложка", tableOfContents: "Содержание", chapters: "Главы" }, pt: { cover: "Capa", tableOfContents: "Índice", chapters: "Capítulos" }, it: { cover: "Copertina", tableOfContents: "Indice", chapters: "Capitoli" } }; const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp", "svg"]; const DC_ELEMENTS = /* @__PURE__ */ new Set([ "title", "creator", "subject", "description", "publisher", "contributor", "date", "type", "format", "identifier", "source", "language", "relation", "coverage", "rights" ]); function escapeXml(value) { return String(value ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } function sniffImageExtension(buffer, url) { var _a, _b; const b = new Uint8Array(buffer.slice(0, 16)); if (b[0] === 255 && b[1] === 216) return "jpg"; if (b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71) return "png"; if (b[0] === 71 && b[1] === 73 && b[2] === 70) return "gif"; if (b[0] === 82 && b[1] === 73 && b[2] === 70 && b[3] === 70 && b[8] === 87 && b[9] === 69 && b[10] === 66 && b[11] === 80) return "webp"; const head = new TextDecoder().decode(b).trimStart(); if (head.startsWith(" 内容。idx 0 是全局样式 */ __publicField(this, "cssFiles", /* @__PURE__ */ new Map()); /** addCSSMap 的样式:EPUB 内文件名 -> 内容 */ __publicField(this, "cssMap", /* @__PURE__ */ new Map()); /** 正文里的原始 CSS 路径 -> EPUB 内相对路径 */ __publicField(this, "cssPathMapping", /* @__PURE__ */ new Map()); /** 正文插图:文件名 -> 数据 */ __publicField(this, "images", /* @__PURE__ */ new Map()); __publicField(this, "coverBuffer", null); __publicField(this, "coverExtension", null); __publicField(this, "i18n", { ...DEFAULT_I18N }); __publicField(this, "fetchBinary"); __publicField(this, "fetchText"); __publicField(this, "imageMode"); this.fetchBinary = options.fetchBinary ?? defaultFetchBinary; this.fetchText = options.fetchText ?? defaultFetchText; this.imageMode = options.images ?? "download"; this.setInfo("identifier", getCrypto().randomUUID(), { scheme: "uuid" }); this.setInfo("date", moment().format("YYYY-MM-DDTHH:mm:ss[Z]"), { "opf:event": "modification" }); this.setInfo("language", "en"); this.setInfo("title", "Untitled Book"); this.setInfo("creator", "Unknown Author"); } setI18n(language, translations) { this.i18n[language] = { ...this.i18n[language] ?? this.i18n["en"], ...translations }; } t(key) { var _a, _b; const language = ((_a = this.metadata.get("language")) == null ? void 0 : _a.value) ?? "en"; return ((_b = this.i18n[language]) == null ? void 0 : _b[key]) ?? this.i18n["en"][key] ?? key; } meta(key, fallback) { var _a; return ((_a = this.metadata.get(key)) == null ? void 0 : _a.value) || fallback; } setInfo(key, value, options = {}) { this.metadata.set(key, { value, options }); } /** 设置封面。传 URL 会去下载,也可以直接给字节 */ async cover(input) { if (typeof input !== "string") { this.coverBuffer = input; this.coverExtension = sniffImageExtension(input, ""); return; } try { const buffer = await this.fetchBinary(input); this.coverBuffer = buffer; this.coverExtension = sniffImageExtension(buffer, input); } catch (error) { throw new Error(`Failed to fetch cover image: ${error.message}`); } } async addVolume(idx, title, options = {}) { const volume = new EpubVolume(idx, title, this, options); this.volumes.set(idx, volume); return volume; } /** 注册一份样式表。idx 0 会作为全局样式被章节引用 */ async addCSS(idx, content, mappath) { const existing = this.cssFiles.get(idx); if (existing && mappath && existing.mappath !== mappath) { throw new Error(`CSS index ${idx} already exists with different mappath`); } this.cssFiles.set(idx, { content, mappath }); } /** * 注册正文 引用的样式表。 * 传 { 'Styles/main.css': 'https://…' | 'css 文本' }, * 值以 http 开头时会去下载。 */ async addCSSMap(pathMap) { for (const [originalPath, urlOrContent] of Object.entries(pathMap)) { const normalizedOriginal = originalPath.startsWith("Styles/") ? originalPath.substring(7) : originalPath; let finalPath = normalizedOriginal; if (/^style\d+\.css$/.test(finalPath)) { let counter = 1e3; while (this.cssFiles.has(counter) || this.cssMap.has(`style${counter}.css`)) { counter++; } finalPath = `style${counter}.css`; console.warn(`CSS conflict detected, renamed ${normalizedOriginal} to ${finalPath}`); } this.cssPathMapping.set(originalPath, `../Styles/${finalPath}`); if (urlOrContent.startsWith("http")) { try { this.cssMap.set(finalPath, await this.fetchText(urlOrContent)); } catch (error) { throw new Error(`Failed to fetch CSS: ${error.message}`); } } else { this.cssMap.set(finalPath, urlOrContent); } } } /** 内容里的 Unicode 转义序列还原。失败时原样返回 */ static decodeEscapes(content) { try { return JSON.parse('"' + content.replace(/"/g, '\\"') + '"'); } catch { return content; } } static parseFragment(content) { const parser = new DOMParser(); const whole = /]/i.test(content) || content.includes("${content}`; return { doc: parser.parseFromString(source, "text/html"), whole }; } static serializeFragment(doc, whole) { if (whole) return new XMLSerializer().serializeToString(doc.documentElement); return doc.body.innerHTML; } /** 把正文里的 CSS 引用重写到 EPUB 内的路径,map 里没有的直接删掉 */ async processCSSLinksInContent(content) { const decoded = _EpubSaver.decodeEscapes(content); try { const { doc, whole } = _EpubSaver.parseFragment(decoded); const cssLinks = doc.querySelectorAll('link[rel="stylesheet"], link[type="text/css"]'); for (const link of cssLinks) { const href = link.getAttribute("href"); if (!href) continue; const mapped = this.cssPathMapping.get(href); if (mapped) { link.setAttribute("href", mapped); console.debug(`Updated CSS reference: ${href} -> ${mapped}`); } else { console.warn(`CSS reference not found in CSSMap, removing: ${href}`); link.remove(); } } return _EpubSaver.serializeFragment(doc, whole); } catch (error) { console.warn("Error processing CSS links in content:", error); return decoded; } } /** 下载正文里的外链图片并改写成 EPUB 内相对路径,下载失败的删掉 img */ async downloadImagesFromContent(content) { if (this.imageMode === "keep") return content; const decoded = content.includes("\\u") ? _EpubSaver.decodeEscapes(content) : content; try { const { doc, whole } = _EpubSaver.parseFragment(decoded); const images = doc.querySelectorAll("img[src]"); let imageCounter = this.images.size; for (const img of images) { const src = img.getAttribute("src"); if (!src || !/^https?:\/\//.test(src)) continue; if (this.imageMode === "remove") { img.remove(); continue; } let finalUrl = src; if (src.startsWith("http://")) finalUrl = src.replace("http://", "https://"); let stored = null; for (const url of finalUrl === src ? [src] : [finalUrl, src]) { try { const buffer = await this.fetchBinary(url); const extension = sniffImageExtension(buffer, url); const filename = `image_${imageCounter++}.${extension}`; this.images.set(filename, { buffer, extension, originalUrl: src }); stored = filename; break; } catch (error) { console.warn(`Failed to download image ${url}:`, error.message); } } if (stored) { img.setAttribute("src", `../Images/${stored}`); } else { console.warn(`Removing failed image tag: ${src}`); img.remove(); } } return _EpubSaver.serializeFragment(doc, whole); } catch (error) { console.warn("Error processing images in content:", error); return decoded; } } generateContainer() { return ` `; } generateContentOpf() { let metadata = ""; for (const [key, data] of this.metadata) { const optionsStr = Object.entries(data.options).map(([k, v]) => `${k}="${escapeXml(v)}"`).join(" "); const attrs = optionsStr ? " " + optionsStr : ""; if (DC_ELEMENTS.has(key)) { metadata += ` ${escapeXml(data.value)} `; } else { metadata += ` ${escapeXml(data.value)} `; } } metadata += ` ${escapeXml( this.meta("date", moment().format("YYYY-MM-DDTHH:mm:ss[Z]")) )} `; if (this.coverBuffer) { metadata += ` `; } let manifest = ""; const spineItems = []; manifest += ` `; manifest += ` `; if (this.coverBuffer) { manifest += ` `; manifest += ` `; spineItems.push("cover"); } for (const [filename, imageData] of this.images) { const imageId = `img-${filename.replace(/[^a-zA-Z0-9]/g, "-")}`; manifest += ` `; } for (const idx of this.cssFiles.keys()) { manifest += ` `; } for (const path of this.cssMap.keys()) { const id = `css-map-${path.replace(/[^a-zA-Z0-9]/g, "-")}`; manifest += ` `; } for (const [volIdx, volume] of this.sortedVolumes()) { if (volume.options.createVolumePage) { const volumeId = `volume-page-${volIdx}`; manifest += ` `; spineItems.push(volumeId); } for (const [chapIdx] of volume.sortedChapters()) { const id = `chapter-${volIdx}-${chapIdx}`; manifest += ` `; spineItems.push(id); } } const spine = spineItems.map((id) => ` `).join("\n"); return ` ${metadata} ${manifest} ${spine} `; } sortedVolumes() { return Array.from(this.volumes.entries()).sort(([a], [b]) => a - b); } /** 卷在目录里的链接:有卷页指向卷页,否则指向首章 */ volumeLink(volIdx, volume) { var _a; if (volume.options.createVolumePage) return `Text/volume${volIdx}_index.xhtml`; const first = ((_a = volume.sortedChapters()[0]) == null ? void 0 : _a[0]) ?? 0; return `Text/volume${volIdx}_chapter${first}.xhtml`; } generateTocNcx() { const title = this.meta("title", "Untitled Book"); const uuid2 = this.meta("identifier", getCrypto().randomUUID()); let navPoints = ""; let playOrder = 1; if (this.coverBuffer) { navPoints += ` ${escapeXml(this.t("cover"))} `; } for (const [volIdx, volume] of this.sortedVolumes()) { const link = this.volumeLink(volIdx, volume); const chapters = volume.sortedChapters(); const expand = chapters.length > 1 || volume.options.alwaysShowVolumeTitle || volume.options.createVolumePage; navPoints += ` ${escapeXml(volume.title)} `; if (expand) { for (const [chapIdx, chapter] of chapters) { navPoints += ` ${escapeXml(chapter.title)} `; } } navPoints += ` `; } return ` ${escapeXml(title)} ${navPoints} `; } generateNavXhtml() { const title = this.meta("title", "Untitled Book"); let navItems = ""; if (this.coverBuffer) { navItems += ` ${escapeXml(this.t("cover"))} `; } for (const [volIdx, volume] of this.sortedVolumes()) { const link = this.volumeLink(volIdx, volume); const chapters = volume.sortedChapters(); const expand = chapters.length > 1 || volume.options.alwaysShowVolumeTitle || volume.options.createVolumePage; if (!expand) { navItems += ` ${escapeXml(volume.title)} `; continue; } navItems += ` ${escapeXml(volume.title)} `; for (const [chapIdx, chapter] of chapters) { navItems += ` ${escapeXml(chapter.title)} `; } navItems += ` `; } return ` ${escapeXml(title)} - ${escapeXml(this.t("tableOfContents"))} ${escapeXml(this.t("tableOfContents"))} ${navItems} `; } generateCoverXhtml() { if (!this.coverBuffer) return ""; const title = this.meta("title", "Untitled Book"); return ` ${escapeXml(title)} - ${escapeXml(this.t("cover"))} `; } generateVolumePageXhtml(volume, volIdx) { let cssLinks = ""; if (this.cssFiles.has(0)) { cssLinks += ` `; } let bodyContent = ` ${escapeXml(volume.title)} `; if (volume.options.volumePageType === "navigator") { const chapters = volume.sortedChapters(); if (chapters.length > 0) { bodyContent += ` ${escapeXml(this.t("chapters"))} `; for (const [chapIdx, chapter] of chapters) { bodyContent += ` ${escapeXml(chapter.title)} `; } bodyContent += ` `; } } return ` ${escapeXml(volume.title)} ${cssLinks} ${bodyContent} `; } /** 给 head 补 title / viewport / 样式引用,并按需插入正文标题 */ decorateChapterDoc(doc, chapter) { let head = doc.querySelector("head"); if (!head) { head = doc.createElement("head"); doc.documentElement.insertBefore(head, doc.body); } if (!head.querySelector("title")) { const titleElement = doc.createElement("title"); titleElement.textContent = chapter.title; head.insertBefore(titleElement, head.firstChild); } if (!head.querySelector('meta[name="viewport"]')) { const viewportMeta = doc.createElement("meta"); viewportMeta.setAttribute("name", "viewport"); viewportMeta.setAttribute("content", "width=device-width, height=device-height, initial-scale=1.0"); head.appendChild(viewportMeta); } const linkCss = (idx) => { const href = `../Styles/style${idx}.css`; if (!this.cssFiles.has(idx) || head.querySelector(`link[href="${href}"]`)) return; const cssLink = doc.createElement("link"); cssLink.setAttribute("rel", "stylesheet"); cssLink.setAttribute("type", "text/css"); cssLink.setAttribute("href", href); head.appendChild(cssLink); }; if (chapter.useGlobalCSS) linkCss(0); for (const cssIdx of chapter.cssIdxs) linkCss(cssIdx); const body = doc.querySelector("body"); if (body && chapter.insertTitle !== false) { const hasHeading = Boolean(body.querySelector("h1, h2, h3, h4, h5, h6")); if (chapter.insertTitle === true || !hasHeading) { const chapterHeading = doc.createElement("h2"); chapterHeading.textContent = chapter.title; body.insertBefore(chapterHeading, body.firstChild); } } } static finalizeXhtml(serialized) { let result = serialized; if (!result.startsWith("\n' + result; } result = result.replace(/]*>/i, (tag) => { let seen = false; const deduped = tag.replace(/\s+xmlns="[^"]*"/g, (match) => { if (seen) return ""; seen = true; return match; }); return seen ? deduped : deduped.replace(/^ 或 * 开头,症状就是所有段落跑进标题里)。 */ generateHtmlChapter(chapter) { const isDocument = /]/i.test(chapter.content); const source = isDocument ? chapter.content : `${chapter.content}`; try { const parser = new DOMParser(); const xdoc = parser.parseFromString(source, "application/xhtml+xml"); const doc = xdoc.querySelector("parsererror") ? parser.parseFromString(source, "text/html") : xdoc; this.decorateChapterDoc(doc, chapter); const serialized = new XMLSerializer().serializeToString(doc.documentElement); return _EpubSaver.finalizeXhtml(serialized); } catch (error) { console.warn("Failed to parse XHTML content:", error); return chapter.content; } } generateChapterXhtml(chapter) { if (chapter.type === "text") { let cssLinks = ""; if (chapter.useGlobalCSS && this.cssFiles.has(0)) { cssLinks += ` `; } for (const cssIdx of chapter.cssIdxs) { if (cssIdx !== 0 && this.cssFiles.has(cssIdx)) { cssLinks += ` `; } } const bodyContent = ` ${escapeXml(chapter.content).replace(/\n\n/g, "\n ").replace(/\n/g, "")}`; return ` ${escapeXml(chapter.title)} ${cssLinks} ${escapeXml(chapter.title)} ${bodyContent} `; } return this.generateHtmlChapter(chapter); } /** * 打包,返回 EPUB 字节。 * * 章节 XHTML 是在这里生成的(每章一次 DOMParser + XMLSerializer), * 长篇小说上千章就是几十秒的同步计算。全部一口气跑完的话主线程被占死: * 界面停在 0% 不动,取消按钮的点击事件也排不进事件循环。 * 所以按批生成、批间让出主线程,并在批之间检查取消。 */ async save(options = {}) { const { onWrite, onCompress, checkCancel } = options; const WRITE_BATCH = 20; this.zip.file("mimetype", "application/epub+zip", { compression: "STORE" }); this.zip.folder("META-INF"); this.zip.folder("OEBPS"); this.zip.folder("OEBPS/Text"); this.zip.folder("OEBPS/Styles"); this.zip.folder("OEBPS/Images"); const opts = { compression: "DEFLATE", compressionOptions: { level: 6 } }; this.zip.file("META-INF/container.xml", this.generateContainer(), opts); const tMeta = Date.now(); this.zip.file("OEBPS/content.opf", this.generateContentOpf(), opts); this.zip.file("OEBPS/toc.ncx", this.generateTocNcx(), opts); this.zip.file("OEBPS/nav.xhtml", this.generateNavXhtml(), opts); console.log(`[epub] 目录/清单生成完毕,用时 ${Date.now() - tMeta}ms`); if (this.coverBuffer) { this.zip.file(`OEBPS/Images/cover.${this.coverExtension}`, this.coverBuffer, opts); this.zip.file("OEBPS/Text/cover.xhtml", this.generateCoverXhtml(), opts); } for (const [filename, imageData] of this.images) { this.zip.file(`OEBPS/Images/${filename}`, imageData.buffer, opts); } for (const [idx, cssData] of this.cssFiles) { this.zip.file(`OEBPS/Styles/style${idx}.css`, cssData.content, opts); } for (const [path, content] of this.cssMap) { this.zip.file(`OEBPS/Styles/${path}`, content, opts); } const total = Array.from(this.volumes.values()).reduce((sum, v) => sum + v.size + (v.options.createVolumePage ? 1 : 0), 0); let written = 0; onWrite == null ? void 0 : onWrite(0, total); console.log(`[epub] 开始写入 ${total} 个章节文件`); for (const [volIdx, volume] of this.sortedVolumes()) { if (volume.options.createVolumePage) { this.zip.file( `OEBPS/Text/volume${volIdx}_index.xhtml`, this.generateVolumePageXhtml(volume, volIdx), opts ); written++; } for (const [chapIdx, chapter] of volume.sortedChapters()) { try { this.zip.file( `OEBPS/Text/volume${volIdx}_chapter${chapIdx}.xhtml`, this.generateChapterXhtml(chapter), opts ); } catch (err) { console.error( `[epub] 第 ${chapIdx} 章「${chapter.title}」生成失败,用占位内容替代:`, err ); this.zip.file( `OEBPS/Text/volume${volIdx}_chapter${chapIdx}.xhtml`, ` ${escapeXml(chapter.title)}${escapeXml(chapter.title)}本章生成失败`, opts ); } written++; if (written % WRITE_BATCH === 0) { onWrite == null ? void 0 : onWrite(written, total); await yieldFrame(); checkCancel == null ? void 0 : checkCancel(); } } } onWrite == null ? void 0 : onWrite(written, total); checkCancel == null ? void 0 : checkCancel(); console.log(`[epub] 章节写入完成 ${written}/${total},条目总数 ${Object.keys(this.zip.files).length},开始压缩`); const COMPRESS_TIMEOUT = 12e4; const t0 = Date.now(); let lastPercent = -1; let cancelled = null; const compressing = this.zip.generateAsync({ type: "uint8array" }, (metadata) => { if (lastPercent < 0) { console.log(`[epub] 压缩开始,首次回调用时 ${Date.now() - t0}ms`); } lastPercent = metadata.percent; try { checkCancel == null ? void 0 : checkCancel(); } catch (err) { cancelled = err; } try { onCompress == null ? void 0 : onCompress(metadata.percent, metadata.currentFile); } catch (err) { console.warn("[epub] 压缩进度回调异常:", err); } }); let timer; const guard = new Promise((_, reject) => { timer = setTimeout(() => { reject(new Error( `压缩超时(${COMPRESS_TIMEOUT / 1e3}s 无进展,最后进度 ${lastPercent < 0 ? "未开始" : lastPercent.toFixed(1) + "%"})。JSZip 的异步调度可能被页面环境干扰,请把控制台日志反馈给作者。` )); }, COMPRESS_TIMEOUT); }); try { const out = await Promise.race([compressing, guard]); if (cancelled) throw cancelled; console.log(`[epub] 压缩完成:${out.length} 字节,用时 ${Date.now() - t0}ms`); return out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength); } finally { if (timer) clearTimeout(timer); } } }; __publicField(_EpubSaver, "version", "1.0.3"); let EpubSaver = _EpubSaver; class EpubVolume { constructor(idx, title, saver, options = {}) { __publicField(this, "idx"); __publicField(this, "title"); __publicField(this, "options"); __publicField(this, "saver"); __publicField(this, "chapters", /* @__PURE__ */ new Map()); this.idx = idx; this.title = title; this.saver = saver; this.options = { alwaysShowVolumeTitle: false, createVolumePage: false, volumePageType: "navigator", ...options }; } get size() { return this.chapters.size; } sortedChapters() { return Array.from(this.chapters.entries()).sort(([a], [b]) => a - b); } async addChapter(idx, title, content, type = "text", useGlobalCSS = false, cssIdxs = [], insertTitle) { let processedContent = content; if (type === "html" || type === "xhtml") { processedContent = await this.saver.processCSSLinksInContent(content); processedContent = await this.saver.downloadImagesFromContent(processedContent); } this.chapters.set(idx, { title, content: processedContent, type, useGlobalCSS, cssIdxs, insertTitle }); } } const appcss = "/* \n * 番茄小说APP内置CSS\n * 版权归属为番茄小说APP官方\n * 修改以兼容网页环境\n *\n * line-space / theme-color 是 APP 排版引擎的私有属性,浏览器和 EPUB 阅读器都不认,\n * 已注释保留以便和原始样式对照。theme-color 的值 color1#0.7 还会让 CSS 解析器跑偏,\n * 把后面几条声明一起吃掉,必须注释而不能留着。\n */\n\nhtml{\n display:block;\n}\n\nbody{\n display:block;\n}\n\np{\n font-size:1em;\n text-align:justify;\n display:block;\n text-indent:2em;\n margin:0.6em 0em 0.6em 0em;\n}\n\ndiv{\n display:block;\n}\n\nh1{\n display:block;\n font-size:1.42em;\n font-weight:bold;\n margin:22px 0em 3em 0em;\n text-align:left;\n /* line-space:0.5em; */\n}\n\nh2{\n display:block;\n font-size:1.2em;\n font-weight:bold;\n margin:1em 0em 0.6em 0em;\n text-align:left;\n}\n\nh3{\n display:block;\n font-size:1em;\n font-weight:bold;\n margin:0em 0em 1em 0em;\n text-align:left;\n}\n\nsup{\n font-size:smaller;\n}\n\n.picture{\n text-indent:0em;\n text-align:center;\n margin:0em 0em 0em 0em;\n /* line-space:0em; */\n}\n\n.pictureDesc{\n font-size:0.73em;\n text-indent:0em;\n margin:0.4em 0em 1em 0em;\n /* theme-color:color1#0.7; */\n /* line-space:0.3em; */\n text-align:left;\n}\n\n.pictureTitle{\n font-size:0.73em;\n text-indent:0em;\n margin:1em 0em 0.3em 0em;\n /* theme-color:color1#0.7; */\n text-align:left;\n /* line-space:0em; */\n}\n\n.collectTitle{\n margin:80px 68px 0em 0px;\n font-size:1.42em;\n text-indent:0em;\n text-align:left;\n}\n\n.collectAuthor{\n margin:16px 0em 0em 0em;\n font-size:1em;\n text-indent:0em;\n text-align:left;\n}\n\n.collectPicture{\n margin:24px 0em 0em 0em;\n text-indent:0em;\n text-align:right;\n}\n\n.collectDetail{\n margin:3em 44px 0em 10px;\n text-indent:0em;\n}\n\n.quoteDefault{\n font-size:1em;\n margin:1.5em 0em 1.5em 2em;\n font-family:'FZShengShiKaiShuS-M-GB';\n /* line-space:0.5em; */\n}\n\n.quoteStyle1{\n text-indent:0em;\n text-align:left;\n}\n\n.alignRight{\n text-align:right;\n}\n\n.quoteDefaultAlignRight{\n font-size:1em;\n margin:0.6em 0em 1.5em 32px;\n font-family:'FZShengShiKaiShuS-M-GB';\n text-align:right;\n /* line-space:0.5em; */\n}\n.chapterTitle1{\n display:block;\n font-size:1.42em;\n font-weight:bold;\n margin:22px 0em 3em 0em;\n text-align:left;\n /* line-space:0.5em; */\n}\n\n.chapterTitle2{\n display:block;\n font-size:1.2em;\n font-weight:bold;\n margin:1em 0em 0.6em 0em;\n text-align:left;\n}\n\n.chapterTitle3{\n display:block;\n font-size:1em;\n font-weight:bold;\n margin:0em 0em 1em 0em;\n text-align:left;\n}\n\n.bdFootnote{\n width:0.69em;\n height:0.84em;\n margin-top:-0.23em;\n vertical-align:top;\n}\n\n.bdPicturebg{\n break-before:always;\n}"; const TITLE_CLASS = /(^|\s)chapterTitle\d?(\s|$)/; function normalizeTitle(text) { return text.replace(/[\s]+/g, ""); } function parseChapterDoc(xhtml) { const parser = new DOMParser(); const xdoc = parser.parseFromString(xhtml, "application/xhtml+xml"); if (!xdoc.querySelector("parsererror") && xdoc.body) return xdoc; const hdoc = parser.parseFromString(xhtml, "text/html"); return hdoc.body ? hdoc : null; } function contentRoot(doc) { return doc.querySelector("article") ?? doc.body; } function removeLeadingTitle(root, title) { const headings = root.querySelectorAll("h1, h2, h3, h4, h5, h6"); const wanted = normalizeTitle(title); let index = 0; for (const heading of headings) { if (index++ >= 2) break; const cls = heading.getAttribute("class") ?? ""; if (TITLE_CLASS.test(cls) || normalizeTitle(heading.textContent ?? "") === wanted) { heading.remove(); return; } } } function takeStyles(doc) { const styles = []; doc.querySelectorAll("style").forEach((el) => { var _a; const text = (_a = el.textContent) == null ? void 0 : _a.trim(); if (text) styles.push(text); el.remove(); }); return styles; } function chapterBody(raw) { var _a; if (typeof raw !== "string" || !raw.trim()) { return { html: "正文内容为空", styles: [] }; } const doc = parseChapterDoc(raw); if (!doc) return { html: raw, styles: [] }; const styles = takeStyles(doc); doc.querySelectorAll("link").forEach((el) => el.remove()); const root = contentRoot(doc); const html = (_a = root == null ? void 0 : root.innerHTML) == null ? void 0 : _a.trim(); return { html: html || "正文内容为空", styles }; } function collectText(node, out) { var _a; for (const child of node.childNodes) { if (child.nodeType === Node.TEXT_NODE) { const text = (_a = child.textContent) == null ? void 0 : _a.trim(); if (text) out.push(text); } else if (child.nodeType === Node.ELEMENT_NODE) { collectText(child, out); } } } function chapterParagraphs(raw, title = "") { if (typeof raw !== "string" || !raw.trim()) return []; const doc = parseChapterDoc(raw); if (!doc) return []; takeStyles(doc); doc.querySelectorAll("script, link").forEach((el) => el.remove()); const root = contentRoot(doc); if (!root) return []; if (title) removeLeadingTitle(root, title); const out = []; collectText(root, out); return out; } const FEEDBACK_HINT = /,如有任何疑问,请通过[“"]?我的-意见反馈[”"]?告知我们/; function parseJsonField(raw, fallback) { if (raw == null) return fallback; if (typeof raw !== "string") return raw ?? fallback; try { const parsed = JSON.parse(raw); return parsed == null ? fallback : parsed; } catch { return fallback; } } function hdCover(url) { if (!url) return ""; if (url.includes("novel-pic-r")) return url; let u = url; if (u.startsWith("https://")) u = u.substring(8); else if (u.startsWith("http://")) u = u.substring(7); const parts = u.split("/"); parts[0] = "https://p6-novel.byteimg.com/origin"; return parts.map((part) => part.includes("?") || part.includes("~") ? part.split("~")[0] : part).join("/"); } function toBookMeta(raw) { const categories = parseJsonField(raw == null ? void 0 : raw.category_v2, []).map((item) => String((item == null ? void 0 : item.Name) ?? "")).filter(Boolean); return { book_id: String((raw == null ? void 0 : raw.book_id) ?? ""), title: String((raw == null ? void 0 : raw.book_name) || (raw == null ? void 0 : raw.original_book_name) || "未命名"), author: String((raw == null ? void 0 : raw.author) ?? "未知作者"), categories: categories.length ? categories : [String((raw == null ? void 0 : raw.category) ?? "")].filter(Boolean), roles: parseJsonField(raw == null ? void 0 : raw.roles, []).map(String).filter(Boolean), abstract: String((raw == null ? void 0 : raw.abstract) ?? ""), copyright: String((raw == null ? void 0 : raw.copyright_info) ?? "").replace(FEEDBACK_HINT, ""), cover_url: String((raw == null ? void 0 : raw.thumb_url) ?? ""), word_number: Number(raw == null ? void 0 : raw.word_number) || 0, creation_status: String((raw == null ? void 0 : raw.creation_status) ?? "") }; } function describeBook(meta) { const lines = []; if (meta.categories.length) lines.push(`分类:${meta.categories.join("、")}`); if (meta.roles.length) lines.push(`主角:${meta.roles.join("、")}`); if (meta.abstract) lines.push(`简介:${meta.abstract}`); if (meta.copyright) lines.push(`${meta.copyright}。`); return lines.join("\n"); } const CSS_CDN_PREFIX = "https://p3-novel.byteimg.com/origin/"; const INLINE_CSS_BASE = 1; async function buildEpub({ meta, catalog, chapters, cssMap, task }) { var _a; const saver = new EpubSaver({ // 部分图床不给 CORS 头,走项目的双通道实现 fetchBinary: fetchArrayBuffer, fetchText: async (url) => { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.text(); }, // 关掉插图下载时保留原地址,联网仍能看,比直接删掉损失小 images: settings$1.downloadImages ? "download" : "keep" }); saver.setInfo("title", meta.title); saver.setInfo("language", "zh-CN"); saver.setInfo("creator", meta.author); saver.setInfo("description", describeBook(meta)); saver.setInfo("publisher", "番茄小说"); task.stage("准备 EPUB…", 1); if (meta.cover_url) { try { await saver.cover(hdCover(meta.cover_url)); } catch (err) { console.warn("[fqa:download] 封面下载失败,跳过:", err); } } task.update(1); await saver.addCSS(0, appcss, "Styles/dragon-common.css"); if (settings$1.downloadBookCss) { const map = parseCssMap(cssMap); const absolute = {}; for (const [path, uri] of Object.entries(map)) { if (uri) absolute[path] = CSS_CDN_PREFIX + uri; } if (Object.keys(absolute).length) { try { await saver.addCSSMap(absolute); } catch (err) { console.warn("[fqa:download] 书籍样式获取失败,跳过:", err); } } } task.stage("组装章节…", catalog.length); const styleIndex = /* @__PURE__ */ new Map(); let nextCssIdx = INLINE_CSS_BASE; let volumeName = ""; let volumeIdx = 0; let currentVolume = null; let cursor = 0; for (const item of catalog) { task.throwIfCancelled(); const chapter = chapters[item.item_id]; const name2 = ((_a = chapter == null ? void 0 : chapter.novel_data) == null ? void 0 : _a.volume_name) || item.volume_title || "默认卷"; if (!currentVolume || name2 !== volumeName) { volumeName = name2; currentVolume = await saver.addVolume(volumeIdx++, name2, { createVolumePage: settings$1.downloadVolumePage, volumePageType: "blank" }); } const { html, styles } = chapterBody(chapter == null ? void 0 : chapter.content); const cssIdxs = []; for (const style of styles) { let idx = styleIndex.get(style); if (idx === void 0) { idx = nextCssIdx++; styleIndex.set(style, idx); await saver.addCSS(idx, style); } cssIdxs.push(idx); } await currentVolume.addChapter( cursor++, item.title, html, "html", true, cssIdxs, // 正文自带标题元素,不要再插一个 false ); task.update(cursor, item.title); if (cursor % 20 === 0) await nextFrame(); } console.log(`[fqa:download] 章节内联样式去重后 ${styleIndex.size} 份`); task.stage("写入章节…", catalog.length); let lastPercent = -1; const buffer = await saver.save({ onWrite: (done, total) => { if (total !== task.snapshot.total) task.setTotal(total); task.update(done); }, onCompress: (percent, currentFile) => { const rounded = Math.floor(percent); if (rounded === lastPercent) return; if (lastPercent < 0) task.stagePercent("压缩 EPUB…"); lastPercent = rounded; task.update(rounded, currentFile ?? "正在压缩…"); }, checkCancel: () => task.throwIfCancelled() }); task.update(100, ""); return new Blob([buffer], { type: "application/epub+zip" }); } const ILLEGAL = /[\\/:*?"<>|\r\n\t]/g; function sanitizeFilename(name2, fallback = "download") { const cleaned = name2.replace(ILLEGAL, "_").replace(/[. ]+$/, "").trim(); if (!cleaned) return fallback; return cleaned.length > 120 ? cleaned.slice(0, 120) : cleaned; } function saveBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.rel = "noopener"; a.style.display = "none"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 3e4); } const GBK_RANGES = [ [161, 169, 161, 254], [176, 247, 161, 254], [129, 160, 64, 254], [170, 254, 64, 160], [168, 169, 64, 160], [170, 175, 161, 254], [248, 254, 161, 254], [161, 167, 64, 160] ]; let table = null; function initTable() { const pairs = new Uint16Array(23940); let count = 0; for (const [hiStart, hiEnd, loStart, loEnd] of GBK_RANGES) { for (let lo = loStart; lo <= loEnd; lo++) { if (lo === 127) continue; for (let hi = hiStart; hi <= hiEnd; hi++) { pairs[count++] = lo << 8 | hi; } } } const next = new Uint16Array(65536).fill(65535); const decoded = new TextDecoder("gbk").decode(pairs); for (let i2 = 0; i2 < decoded.length; i2++) { next[decoded.charCodeAt(i2)] = pairs[i2]; } table = next; return next; } function encodeGBK(text, options = {}) { const map = table ?? initTable(); const onError = options.onError ?? (() => 63); const out = new Uint8Array(text.length * 2); let n = 0; for (let i2 = 0; i2 < text.length; i2++) { const code = text.charCodeAt(i2); if (code < 128) { out[n++] = code; continue; } const gbk = map[code]; if (gbk !== 65535) { out[n++] = gbk & 255; out[n++] = gbk >> 8; continue; } if (code === 8364) { out[n++] = 128; continue; } const replacement = onError(i2, text); if (replacement === -1) break; if (replacement > 255) { out[n++] = replacement & 255; out[n++] = replacement >> 8; } else { out[n++] = replacement; } } return out.subarray(0, n); } const INDENT = " "; async function buildTxtContent({ meta, catalog, chapters, task }) { var _a; task.stage("处理章节…", catalog.length); const parts = [meta.title, `作者:${meta.author}`, describeBook(meta), "", ""]; let volumeName = ""; let cursor = 0; for (const item of catalog) { task.throwIfCancelled(); const chapter = chapters[item.item_id]; const name2 = ((_a = chapter == null ? void 0 : chapter.novel_data) == null ? void 0 : _a.volume_name) || item.volume_title || ""; if (name2 && name2 !== volumeName) { volumeName = name2; parts.push(` ${name2.replace(":默认", "")} `); } const paragraphs = chapterParagraphs(chapter == null ? void 0 : chapter.content, item.title); const body = paragraphs.length ? INDENT + paragraphs.join(` ${INDENT}`) : `${INDENT}(本章内容缺失)`; parts.push(`${item.title} ${body} `); task.update(++cursor, item.title); if (cursor % 50 === 0) await nextFrame(); } return parts.join("\n"); } function txtToBlob(content) { const charset = settings$1.downloadCharset; const encoded = charset === "gbk" ? encodeGBK(content) : new TextEncoder().encode(content); return new Blob([encoded], { type: `text/plain;charset=${charset}` }); } let activeTask = null; let running = false; const taskListeners = /* @__PURE__ */ new Set(); function onTaskChange(listener) { taskListeners.add(listener); listener(activeTask); return () => taskListeners.delete(listener); } function setActiveTask(task) { activeTask = task; for (const listener of taskListeners) { try { listener(task); } catch (err) { console.error("[fqa:download] 任务监听异常:", err); } } } function clearFinishedTask() { if (!activeTask) return; if (!activeTask.snapshot.done) activeTask.cancel(); setActiveTask(null); } function findCssMap(chapters) { var _a; for (const chapter of Object.values(chapters)) { const cssMap = (_a = chapter.novel_data) == null ? void 0 : _a.css_map; if (cssMap) return cssMap; } return void 0; } async function resolveMeta(bookId2, chapters) { var _a; try { const raw = await getBookInfoRaw(bookId2); if (raw == null ? void 0 : raw.book_id) return toBookMeta(raw); } catch (err) { console.warn("[fqa:download] 获取书籍详情失败,改用章节里的信息:", err); } const fallback = (_a = Object.values(chapters).find((c) => { var _a2; return (_a2 = c.novel_data) == null ? void 0 : _a2.book_id; })) == null ? void 0 : _a.novel_data; return toBookMeta(fallback ?? { book_id: bookId2 }); } async function startDownload(bookId2, options = {}) { if (running) { console.warn("[fqa:download] 已有下载任务在进行"); return; } const format = options.format ?? settings$1.downloadFormat; const task = new DownloadTask(); running = true; setActiveTask(task); try { task.stage("获取目录…"); const catalogResult = await getCatalog(bookId2); const catalog = catalogResult.chapter_list; if (catalog.length === 0) throw new Error("目录为空"); task.throwIfCancelled(); const titleMap = new Map(catalog.map((item) => [item.item_id, item.title])); const { chapters, failed } = await fetchChapters( catalog.map((item) => item.item_id), bookId2, task, { titleOf: (itemId2) => titleMap.get(itemId2) } ); task.throwIfCancelled(); if (Object.keys(chapters).length === 0) { throw new Error("没有获取到任何章节正文"); } if (failed.length > 0) { console.warn(`[fqa:download] ${failed.length} 章缺失,仍然继续导出`); } const meta = await resolveMeta(bookId2, chapters); task.throwIfCancelled(); const base = sanitizeFilename(`${meta.title}_${meta.author}`, bookId2); if (format === "epub") { const blob = await buildEpub({ meta, catalog, chapters, cssMap: findCssMap(chapters), task }); task.stage("保存中…", 1); saveBlob(blob, `${base}.epub`); } else { const content = await buildTxtContent({ meta, catalog, chapters, task }); task.stage("保存中…", 1); saveBlob(txtToBlob(content), `${base}.txt`); } task.update(1); task.finish(failed.length > 0 ? `完成(${failed.length} 章缺失)` : "下载完成"); } catch (err) { if (err instanceof CancelledError) { console.log("[fqa:download] 任务已取消"); task.finish("已取消"); return; } console.error("[fqa:download] 下载失败:", err); task.fail(err); } finally { running = false; } } const _hoisted_1$4 = { id: "fqa-bookshelf" }; const _hoisted_2$4 = { class: "fqa-bs-header" }; const _hoisted_3$4 = { class: "fqa-bs-actions" }; const _hoisted_4$4 = { key: 0 }; const _hoisted_5$4 = ["disabled"]; const _hoisted_6$4 = ["aria-selected", "onClick", "onKeydown"]; const _hoisted_7$4 = { class: "fqa-tab-count" }; const _hoisted_8$4 = { key: 0, class: "fqa-groupbar" }; const _hoisted_9$4 = { class: "fqa-groupbar-name" }; const _hoisted_10$4 = { class: "fqa-groupbar-count" }; const _hoisted_11$3 = { key: 1, class: "fqa-status" }; const _hoisted_12$3 = { key: 2, class: "fqa-status" }; const _hoisted_13$3 = { class: "fqa-status-title" }; const _hoisted_14$3 = { class: "fqa-grid" }; const _hoisted_15$1 = { key: 0, class: "fqa-toast" }; const HOVER_DELAY = 300; const HIDE_DELAY = 160; const HOVER_WIDTH = 280; const HOVER_GAP = 12; const VIEWPORT_MARGIN = 8; const MOVE_PREFIX = "move:"; const DOWNLOAD_PREFIX$1 = "download:"; const _sfc_main$4 = /* @__PURE__ */ vue.defineComponent({ __name: "BookshelfView", setup(__props) { const { loading, detailLoading, error, counts, groups, load: load2, ensureDetails, cellsOf, findGroup, PAGE_SIZE: PAGE_SIZE2 } = useBookshelf(); const activeTab = vue.ref("all"); const openedGroupName = vue.ref(null); const tabsRef = vue.ref(null); const inkStyle = vue.ref({ left: "0px", width: "0px" }); const hoverEntry = vue.ref(null); const hoverVisible = vue.ref(false); const hoverPos = vue.ref({ x: 0, y: 0 }); const hoverHeight = vue.ref(0); let hoverTimer; let hideTimer; let hoverAnchor = null; const pointerInPanel = vue.ref(false); const openedGroup = vue.computed( () => openedGroupName.value ? findGroup(openedGroupName.value) : null ); const allCells = vue.computed(() => { const group = openedGroup.value; if (group) { return group.books.map((entry) => ({ kind: "book", key: `book:${entry.item.book_id}`, entry })); } return cellsOf(activeTab.value); }); const visibleCount = vue.ref(PAGE_SIZE2); const cells = vue.computed(() => allCells.value.slice(0, visibleCount.value)); const hasMore = vue.computed(() => visibleCount.value < allCells.value.length); const sentinel = vue.ref(null); let pageObserver = null; function resetPaging() { visibleCount.value = PAGE_SIZE2; } async function refresh() { resetPaging(); await load2(true); } vue.watch([activeTab, openedGroupName], resetPaging); const isEmpty = vue.computed(() => !loading.value && !error.value && allCells.value.length === 0); const emptyText = vue.computed(() => { if (openedGroup.value) return "这个分组还没有书"; if (activeTab.value === "group") return "还没有创建任何分组"; if (activeTab.value === "publish") return "书架里还没有出版物"; return "书架空空如也,去首页找几本书看看吧"; }); function updateInk() { const wrap = tabsRef.value; if (!wrap) return; const index = TABS.findIndex((tab) => tab.key === activeTab.value); const el = wrap.querySelectorAll(".fqa-tab")[index]; if (!el) return; inkStyle.value = { left: `${el.offsetLeft}px`, width: `${el.offsetWidth}px` }; } function selectTab(key) { openedGroupName.value = null; activeTab.value = key; hideHover(true); } vue.watch(activeTab, () => vue.nextTick(updateInk)); vue.watch(counts, () => vue.nextTick(updateInk), { deep: true }); function computePosition(el) { const cover = el.querySelector(".fqa-cover") ?? el; const rect = cover.getBoundingClientRect(); let x = rect.right + HOVER_GAP; if (x + HOVER_WIDTH > window.innerWidth - VIEWPORT_MARGIN) { x = rect.left - HOVER_WIDTH - HOVER_GAP; } x = Math.max(VIEWPORT_MARGIN, Math.min(x, window.innerWidth - HOVER_WIDTH - VIEWPORT_MARGIN)); const topLimit = VIEWPORT_MARGIN; const bottomLimit = window.innerHeight - VIEWPORT_MARGIN; const height = Math.min(rect.height, bottomLimit - topLimit); const y = Math.max(topLimit, Math.min(rect.top, bottomLimit - height)); hoverHeight.value = height; hoverPos.value = { x, y }; } function onCardHover({ entry, el }) { if (hoverTimer) clearTimeout(hoverTimer); if (hideTimer) { clearTimeout(hideTimer); hideTimer = void 0; } if (!entry.detail) return; hoverAnchor = el; if (hoverVisible.value && hoverEntry.value !== entry) { computePosition(el); hoverEntry.value = entry; return; } hoverTimer = setTimeout(() => { hoverTimer = void 0; computePosition(el); hoverEntry.value = entry; hoverVisible.value = true; }, HOVER_DELAY); } function scheduleHide() { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = void 0; } if (hideTimer) clearTimeout(hideTimer); hideTimer = setTimeout(() => { hideTimer = void 0; if (!pointerInPanel.value) hideHover(true); }, HIDE_DELAY); } function hideHover(immediate = false) { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = void 0; } if (hideTimer) { clearTimeout(hideTimer); hideTimer = void 0; } pointerInPanel.value = false; hoverAnchor = null; hoverVisible.value = false; if (immediate) hoverEntry.value = null; } function onPanelEnter() { if (hideTimer) { clearTimeout(hideTimer); hideTimer = void 0; } pointerInPanel.value = true; } function onPanelLeave() { pointerInPanel.value = false; scheduleHide(); } let pendingItems = []; let flushTimer; function queueDetails(entries) { const pending = entries.filter((entry) => !entry.detail); if (!pending.length) return; pendingItems.push(...pending.map((entry) => entry.item)); if (flushTimer) return; flushTimer = setTimeout(() => { flushTimer = void 0; const batch = pendingItems; pendingItems = []; void ensureDetails(batch); }, 50); } function onCardVisible(entry) { queueDetails([entry]); } function onGroupVisible(entries) { queueDetails(entries); } function continueReadingUrl(entry) { var _a; const chapterId = ((_a = entry.detail) == null ? void 0 : _a.current_chapter_id) || entry.item.last_read_chapter_id; return chapterId && chapterId !== "0" ? readerUrl(chapterId) : null; } function openBook({ entry, mods }) { hideHover(true); const wantRead = settings$1.bookshelfClickAction === "read" !== mods.flip; const url = wantRead && continueReadingUrl(entry) || bookPageUrl(entry.item.book_id); openUrl(url, mods.newTab); } function openGroup(group) { hideHover(true); openedGroupName.value = group.name; } const menuVisible = vue.ref(false); const menuPos = vue.ref({ x: 0, y: 0 }); const menuEntry = vue.ref(null); const toast = vue.ref(null); let toastTimer; function showToast(msg) { toast.value = msg; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { toast.value = null; toastTimer = void 0; }, 2600); } const NO_GROUP_KEY = `${MOVE_PREFIX}`; const menuItems = vue.computed(() => { const entry = menuEntry.value; if (!entry) return []; const current = entry.item.group_name ?? ""; const targets = groups.value.filter((g2) => g2.name !== current).map((g2) => ({ key: `${MOVE_PREFIX}${g2.name}`, label: g2.name })); if (current) targets.push({ key: NO_GROUP_KEY, label: "无分组" }); const items = [ { key: "read", label: "继续阅读" }, { key: "detail", label: "查看详情" }, { key: "move", label: "移动到分组", disabled: targets.length === 0, children: targets } ]; if (settings$1.enableDownload) { items.push({ key: "download", label: "下载", children: [ { key: `${DOWNLOAD_PREFIX$1}epub`, label: "EPUB" }, { key: `${DOWNLOAD_PREFIX$1}txt`, label: "TXT" } ] }); } items.push({ key: "remove", label: "从书架删除", danger: true }); return items; }); function onCardContextMenu({ entry, x, y }) { hideHover(true); menuEntry.value = entry; menuPos.value = { x, y }; menuVisible.value = true; } async function onMenuSelect(key) { var _a; const entry = menuEntry.value; if (!entry) return; const bookId2 = entry.item.book_id; if (key === "read") { openUrl(continueReadingUrl(entry) ?? bookPageUrl(bookId2)); return; } if (key === "detail") { openUrl(bookPageUrl(bookId2)); return; } if (key.startsWith(DOWNLOAD_PREFIX$1)) { const format = key.slice(DOWNLOAD_PREFIX$1.length); void startDownload(bookId2, { format }); return; } if (key === "remove") { if (!unsafeWindow.confirm(`确定要把《${((_a = entry.detail) == null ? void 0 : _a.title) ?? bookId2}》从书架删除吗?`)) return; try { await removeFromBookshelf(bookId2); showToast("已从书架删除"); await refresh(); } catch (err) { console.error("[fqa:bookshelf] 删除失败:", err); showToast(err instanceof Error ? err.message : "删除失败"); } return; } if (key.startsWith(MOVE_PREFIX)) { const groupName = key.slice(MOVE_PREFIX.length); try { await moveToGroup(bookId2, groupName); showToast(groupName ? `已移动到「${groupName}」` : "已移出分组"); await refresh(); } catch (err) { console.error("[fqa:bookshelf] 移动分组失败:", err); showToast(err instanceof Error ? err.message : "移动分组失败"); } } } function backToList() { openedGroupName.value = null; hideHover(true); } function onScrollOrResize() { if (hoverAnchor && (hoverVisible.value || hoverTimer)) { if (hoverAnchor.isConnected) { computePosition(hoverAnchor); } else { hideHover(true); } } updateInk(); } vue.onMounted(async () => { await load2(); await vue.nextTick(); updateInk(); window.addEventListener("scroll", onScrollOrResize, true); window.addEventListener("resize", onScrollOrResize); if (typeof IntersectionObserver !== "undefined") { pageObserver = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting) && hasMore.value) { visibleCount.value += PAGE_SIZE2; } }, { rootMargin: "400px" } ); vue.watch( sentinel, (el) => { pageObserver == null ? void 0 : pageObserver.disconnect(); if (el) pageObserver == null ? void 0 : pageObserver.observe(el); }, { immediate: true } ); } }); vue.onBeforeUnmount(() => { if (hoverTimer) clearTimeout(hoverTimer); if (hideTimer) clearTimeout(hideTimer); if (flushTimer) clearTimeout(flushTimer); pageObserver == null ? void 0 : pageObserver.disconnect(); pageObserver = null; window.removeEventListener("scroll", onScrollOrResize, true); window.removeEventListener("resize", onScrollOrResize); }); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", _hoisted_1$4, [ vue.createElementVNode("div", _hoisted_2$4, [ _cache[1] || (_cache[1] = vue.createElementVNode("div", { class: "fqa-bs-title" }, "我的书架", -1)), vue.createElementVNode("div", _hoisted_3$4, [ vue.unref(detailLoading) ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_4$4, "正在补全详情…")) : vue.createCommentVNode("", true), vue.createElementVNode("button", { class: "fqa-btn", disabled: vue.unref(loading), onClick: refresh }, vue.toDisplayString(vue.unref(loading) ? "刷新中…" : "刷新"), 9, _hoisted_5$4) ]) ]), vue.createElementVNode("div", { ref_key: "tabsRef", ref: tabsRef, class: "fqa-tabs", role: "tablist" }, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(vue.unref(TABS), (tab) => { return vue.openBlock(), vue.createElementBlock("div", { key: tab.key, class: vue.normalizeClass(["fqa-tab", { "fqa-tab-active": activeTab.value === tab.key }]), role: "tab", tabindex: "0", "aria-selected": activeTab.value === tab.key, onClick: ($event) => selectTab(tab.key), onKeydown: vue.withKeys(vue.withModifiers(($event) => selectTab(tab.key), ["prevent"]), ["enter"]) }, [ vue.createTextVNode(vue.toDisplayString(tab.label), 1), vue.createElementVNode("span", _hoisted_7$4, vue.toDisplayString(vue.unref(counts)[tab.key]), 1) ], 42, _hoisted_6$4); }), 128)), vue.createElementVNode("span", { class: "fqa-tab-ink", style: vue.normalizeStyle(inkStyle.value) }, null, 4) ], 512), openedGroup.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_8$4, [ vue.createElementVNode("button", { class: "fqa-btn", onClick: backToList }, "← 返回"), vue.createElementVNode("span", _hoisted_9$4, vue.toDisplayString(openedGroup.value.name), 1), vue.createElementVNode("span", _hoisted_10$4, "共" + vue.toDisplayString(openedGroup.value.books.length) + "本书", 1) ])) : vue.createCommentVNode("", true), vue.unref(error) ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_11$3, [ _cache[2] || (_cache[2] = vue.createElementVNode("div", { class: "fqa-status-title" }, "书架加载失败", -1)), vue.createElementVNode("div", null, vue.toDisplayString(vue.unref(error)), 1), vue.createElementVNode("button", { class: "fqa-btn", onClick: refresh }, "重试") ])) : isEmpty.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_12$3, [ vue.createElementVNode("div", _hoisted_13$3, vue.toDisplayString(emptyText.value), 1) ])) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 3 }, [ vue.createElementVNode("div", _hoisted_14$3, [ vue.unref(loading) ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, vue.renderList(8, (n) => { return vue.createElementVNode("div", { key: `sk-${n}`, class: "fqa-card" }, [..._cache[3] || (_cache[3] = [ vue.createElementVNode("div", { class: "fqa-sk-cover fqa-sk-anim" }, null, -1), vue.createElementVNode("div", { class: "fqa-sk-line fqa-sk-anim", style: { "width": "90%" } }, null, -1), vue.createElementVNode("div", { class: "fqa-sk-line fqa-sk-anim", style: { "width": "55%" } }, null, -1) ])]); }), 64)) : (vue.openBlock(true), vue.createElementBlock(vue.Fragment, { key: 1 }, vue.renderList(cells.value, (cell) => { return vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: cell.key }, [ cell.kind === "book" ? (vue.openBlock(), vue.createBlock(_sfc_main$8, { key: 0, entry: cell.entry, onHover: onCardHover, onLeave: scheduleHide, onOpen: openBook, onVisible: onCardVisible, onContextmenu: onCardContextMenu }, null, 8, ["entry"])) : (vue.openBlock(), vue.createBlock(_sfc_main$7, { key: 1, group: cell.group, onOpen: openGroup, onVisible: onGroupVisible }, null, 8, ["group"])) ], 64); }), 128)) ]), !vue.unref(loading) && hasMore.value ? (vue.openBlock(), vue.createElementBlock("div", { key: 0, ref_key: "sentinel", ref: sentinel, class: "fqa-loadmore" }, "加载中…", 512)) : vue.createCommentVNode("", true) ], 64)), (vue.openBlock(), vue.createBlock(vue.Teleport, { to: "body" }, [ vue.createVNode(_sfc_main$6, { entry: hoverEntry.value, x: hoverPos.value.x, y: hoverPos.value.y, height: hoverHeight.value, visible: hoverVisible.value, onPanelEnter, onPanelLeave }, null, 8, ["entry", "x", "y", "height", "visible"]), vue.createVNode(_sfc_main$5, { visible: menuVisible.value, x: menuPos.value.x, y: menuPos.value.y, items: menuItems.value, onSelect: onMenuSelect, onClose: _cache[0] || (_cache[0] = ($event) => menuVisible.value = false) }, null, 8, ["visible", "x", "y", "items"]), toast.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_15$1, vue.toDisplayString(toast.value), 1)) : vue.createCommentVNode("", true) ])) ]); }; } }); const bookshelfcss = "#fqa-bookshelf {\n --fqa-text: #1f2329;\n --fqa-text-sub: #646a73;\n --fqa-text-weak: #8f959e;\n --fqa-border: rgba(31, 35, 41, 0.08);\n --fqa-hover: rgba(31, 35, 41, 0.04);\n --fqa-accent: #ff6f3d;\n --fqa-skeleton: rgba(31, 35, 41, 0.06);\n --fqa-skeleton-hl: rgba(31, 35, 41, 0.12);\n --fqa-shadow: 0 4px 16px rgba(31, 35, 41, 0.08);\n\n display: block;\n box-sizing: border-box;\n width: 100%;\n max-width: 1100px;\n margin: 0 auto;\n /* 顶部留出原站 fixed 顶栏(80px)的高度,否则标题和 tab 会被压在下面 */\n padding: calc(80px + 24px) 16px 64px;\n color: var(--fqa-text);\n font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,\n sans-serif;\n}\n\n#fqa-bookshelf *,\n#fqa-bookshelf *::before,\n#fqa-bookshelf *::after {\n box-sizing: border-box;\n}\n\n#fqa-bookshelf div,\n#fqa-bookshelf span,\n#fqa-bookshelf h1,\n#fqa-bookshelf ul,\n#fqa-bookshelf li {\n margin: 0;\n padding: 0;\n border: 0;\n list-style: none;\n float: none;\n position: static;\n}\n\n/* ------------------------------ 顶部 / Tabs ------------------------------ */\n\n#fqa-bookshelf .fqa-bs-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n margin-bottom: 8px;\n}\n\n#fqa-bookshelf .fqa-bs-title {\n font-size: 24px;\n font-weight: 600;\n line-height: 1.4;\n}\n\n#fqa-bookshelf .fqa-bs-actions {\n display: flex;\n align-items: center;\n gap: 12px;\n font-size: 13px;\n color: var(--fqa-text-weak);\n}\n\n#fqa-bookshelf .fqa-btn {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 6px 14px;\n border: 1px solid var(--fqa-border);\n border-radius: 999px;\n background: transparent;\n color: var(--fqa-text-sub);\n font-size: 13px;\n font-family: inherit;\n line-height: 1.4;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;\n}\n\n#fqa-bookshelf .fqa-btn:hover:not(:disabled) {\n border-color: var(--fqa-accent);\n color: var(--fqa-accent);\n background: rgba(255, 111, 61, 0.06);\n}\n\n#fqa-bookshelf .fqa-btn:disabled {\n opacity: 0.5;\n cursor: default;\n}\n\n#fqa-bookshelf .fqa-tabs {\n position: relative;\n display: flex;\n align-items: center;\n gap: 4px;\n margin-bottom: 24px;\n border-bottom: 1px solid var(--fqa-border);\n}\n\n#fqa-bookshelf .fqa-tab {\n padding: 10px 16px;\n color: var(--fqa-text-sub);\n font-size: 15px;\n line-height: 22px;\n cursor: pointer;\n user-select: none;\n transition: color 0.15s ease;\n}\n\n#fqa-bookshelf .fqa-tab:hover {\n color: var(--fqa-text);\n}\n\n#fqa-bookshelf .fqa-tab-active {\n color: var(--fqa-accent);\n font-weight: 600;\n}\n\n#fqa-bookshelf .fqa-tab-count {\n margin-left: 4px;\n font-size: 12px;\n font-weight: 400;\n color: var(--fqa-text-weak);\n}\n\n#fqa-bookshelf .fqa-tab-ink {\n position: absolute;\n bottom: -1px;\n left: 0;\n width: 0;\n height: 2px;\n border-radius: 2px;\n background: var(--fqa-accent);\n transition: left 0.25s ease, width 0.25s ease;\n}\n\n/* ------------------------------- 书架网格 ------------------------------- */\n\n/* 原站一排最多 4 本;窄屏逐级降到 3 / 2 */\n#fqa-bookshelf .fqa-grid {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: 28px 24px;\n align-items: start;\n}\n\n@media (max-width: 900px) {\n #fqa-bookshelf .fqa-grid {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n }\n}\n\n@media (max-width: 600px) {\n #fqa-bookshelf .fqa-grid {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n}\n\n#fqa-bookshelf .fqa-card {\n display: block;\n border-radius: 8px;\n cursor: pointer;\n outline: none;\n}\n\n#fqa-bookshelf .fqa-card:focus-visible {\n box-shadow: 0 0 0 2px var(--fqa-accent);\n}\n\n/* 封面:3:4,靠 aspect-ratio 定高,内部元素绝对定位 */\n#fqa-bookshelf .fqa-cover {\n position: relative;\n display: block;\n width: 100%;\n aspect-ratio: 3 / 4;\n border-radius: 6px;\n overflow: hidden;\n background: var(--fqa-skeleton);\n transition: transform 0.2s ease, box-shadow 0.2s ease;\n}\n\n#fqa-bookshelf .fqa-card:hover .fqa-cover {\n transform: translateY(-4px);\n box-shadow: var(--fqa-shadow);\n}\n\n#fqa-bookshelf .fqa-cover-img {\n position: absolute;\n inset: 0;\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n transition: opacity 0.25s ease;\n}\n\n#fqa-bookshelf .fqa-cover-img-loading {\n opacity: 0;\n}\n\n#fqa-bookshelf .fqa-cover-tag {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n padding: 2px 6px;\n border-radius: 0 6px 0 6px;\n background: var(--fqa-accent);\n color: #fff;\n font-size: 11px;\n line-height: 16px;\n font-weight: 500;\n white-space: nowrap;\n}\n\n/* 连载 / 完结 / 断更共用:灰底,弱化于“更新”角标 */\n#fqa-bookshelf .fqa-cover-tag-gray {\n background: rgba(31, 35, 41, 0.55);\n}\n\n@media (prefers-color-scheme: dark) {\n #fqa-bookshelf .fqa-cover-tag-gray {\n background: rgba(0, 0, 0, 0.6);\n }\n}\n\n#fqa-bookshelf .fqa-cover-progress {\n position: absolute;\n left: 0;\n right: 0;\n bottom: 0;\n z-index: 2;\n height: 3px;\n background: rgba(255, 255, 255, 0.35);\n}\n\n#fqa-bookshelf .fqa-cover-progress-bar {\n display: block;\n height: 100%;\n background: var(--fqa-accent);\n transition: width 0.3s ease;\n}\n\n/* 文字区:与封面同为普通流元素,不会重叠 */\n#fqa-bookshelf .fqa-card-title {\n display: -webkit-box;\n margin-top: 8px;\n color: var(--fqa-text);\n font-size: 14px;\n line-height: 20px;\n font-weight: 500;\n -webkit-line-clamp: 2;\n line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n word-break: break-all;\n}\n\n#fqa-bookshelf .fqa-card-sub {\n margin-top: 4px;\n color: var(--fqa-text-weak);\n font-size: 12px;\n line-height: 18px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* ------------------------------- 分组卡片 ------------------------------- */\n\n#fqa-bookshelf .fqa-group-cover {\n position: relative;\n display: block;\n width: 100%;\n aspect-ratio: 3 / 4;\n border-radius: 6px;\n overflow: hidden;\n background: linear-gradient(135deg, rgba(255, 111, 61, 0.12), rgba(78, 131, 253, 0.12));\n transition: transform 0.2s ease, box-shadow 0.2s ease;\n}\n\n#fqa-bookshelf .fqa-card:hover .fqa-group-cover {\n transform: translateY(-4px);\n box-shadow: var(--fqa-shadow);\n}\n\n#fqa-bookshelf .fqa-group-grid {\n position: absolute;\n inset: 0;\n display: grid;\n grid-template-columns: 1fr 1fr;\n grid-template-rows: 1fr 1fr;\n gap: 4px;\n padding: 6px;\n}\n\n#fqa-bookshelf .fqa-group-cell {\n position: relative;\n border-radius: 3px;\n overflow: hidden;\n background: rgba(31, 35, 41, 0.06);\n}\n\n#fqa-bookshelf .fqa-group-cell img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n/* 分组详情返回条 */\n\n#fqa-bookshelf .fqa-groupbar {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 16px;\n}\n\n#fqa-bookshelf .fqa-groupbar-name {\n font-size: 16px;\n font-weight: 600;\n}\n\n#fqa-bookshelf .fqa-groupbar-count {\n color: var(--fqa-text-weak);\n font-size: 13px;\n}\n\n/* --------------------------- hover 详情浮层 --------------------------- */\n\n/*\n * 用 popover 进入浏览器顶层,不参与页面 z-index 竞争,\n * 因此不会被相邻卡片或原站的层叠上下文盖住。z-index 仅作降级保险。\n */\n#fqa-bookshelf-hover {\n position: fixed;\n z-index: 2147483000;\n /* 容器本身透传,只有内部卡片接收事件,避免空白区挡住下层 */\n pointer-events: none;\n opacity: 0;\n transform: translateY(4px);\n /* allow-discrete:顶层元素从 display:none 切入时也能播放淡入 */\n transition: opacity 0.16s ease, transform 0.16s ease, display 0.16s allow-discrete;\n font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,\n sans-serif;\n}\n\n/* popover 默认带边框/内边距/居中定位,全部清掉,改由 left/top 控制 */\n#fqa-bookshelf-hover:popover-open,\n#fqa-bookshelf-hover[popover] {\n margin: 0;\n padding: 0;\n border: 0;\n background: transparent;\n overflow: visible;\n inset: auto;\n width: auto;\n height: auto;\n max-width: none;\n max-height: none;\n color: inherit;\n}\n\n#fqa-bookshelf-hover::backdrop {\n background: transparent;\n}\n\n#fqa-bookshelf-hover.fqa-visible {\n opacity: 1;\n transform: translateY(0);\n}\n\n@starting-style {\n #fqa-bookshelf-hover.fqa-visible {\n opacity: 0;\n transform: translateY(4px);\n }\n}\n\n/* 高度由 JS 按封面尺寸设定;纵向 flex 让简介吃掉剩余空间 */\n#fqa-bookshelf-hover .fqa-hover-inner {\n display: flex;\n flex-direction: column;\n box-sizing: border-box;\n width: 280px;\n padding: 12px 14px;\n border-radius: 10px;\n background: #fff;\n box-shadow: 0 8px 32px rgba(31, 35, 41, 0.16);\n color: #1f2329;\n overflow: hidden;\n /* 卡片可交互:鼠标可以移进来而不触发收起 */\n pointer-events: auto;\n}\n\n#fqa-bookshelf-hover .fqa-hover-title {\n flex: none;\n margin: 0;\n font-size: 14px;\n line-height: 20px;\n font-weight: 600;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n#fqa-bookshelf-hover .fqa-hover-author {\n flex: none;\n margin-top: 2px;\n color: #8f959e;\n font-size: 12px;\n line-height: 17px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stats {\n display: flex;\n flex: none;\n margin-top: 10px;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat {\n flex: 1 1 0;\n min-width: 0;\n padding: 0 6px;\n text-align: center;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat:first-child {\n padding-left: 0;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat:last-child {\n padding-right: 0;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat + .fqa-hover-stat {\n border-left: 1px solid rgba(31, 35, 41, 0.08);\n}\n\n/* 第一栏可悬停切换为更新时间,给个可交互提示 */\n#fqa-bookshelf-hover .fqa-hover-stat:first-child {\n border-radius: 4px;\n cursor: default;\n transition: background 0.15s ease;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat:first-child:hover {\n background: rgba(31, 35, 41, 0.05);\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat-v {\n font-size: 13px;\n line-height: 18px;\n font-weight: 600;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat-k {\n margin-top: 1px;\n color: #8f959e;\n font-size: 11px;\n line-height: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* 梗概 / 简介 双栏切换 */\n#fqa-bookshelf-hover .fqa-hover-seg {\n display: flex;\n flex: none;\n gap: 4px;\n margin-top: 10px;\n padding-top: 10px;\n border-top: 1px solid rgba(31, 35, 41, 0.08);\n}\n\n#fqa-bookshelf-hover .fqa-hover-seg-btn {\n flex: 1 1 0;\n padding: 4px 0;\n border: 0;\n border-radius: 5px;\n background: rgba(31, 35, 41, 0.05);\n color: #646a73;\n font-family: inherit;\n font-size: 12px;\n line-height: 18px;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n}\n\n#fqa-bookshelf-hover .fqa-hover-seg-btn:hover {\n color: #1f2329;\n}\n\n#fqa-bookshelf-hover .fqa-hover-seg-active {\n background: rgba(255, 111, 61, 0.12);\n color: #ff6f3d;\n font-weight: 500;\n}\n\n#fqa-bookshelf-hover .fqa-hover-seg-active:hover {\n color: #ff6f3d;\n}\n\n/*\n * 撑满剩余高度。行数不再写死,由容器高度自然裁切;\n * min-height:0 让 flex 子项允许被压缩,否则 overflow 不生效。\n */\n#fqa-bookshelf-hover .fqa-hover-abstract {\n flex: 1 1 auto;\n min-height: 0;\n margin-top: 8px;\n color: #646a73;\n font-size: 12px;\n line-height: 18px;\n overflow-y: auto;\n overscroll-behavior: contain;\n}\n\n#fqa-bookshelf-hover .fqa-hover-abstract::-webkit-scrollbar {\n width: 4px;\n}\n\n#fqa-bookshelf-hover .fqa-hover-abstract::-webkit-scrollbar-thumb {\n border-radius: 2px;\n background: rgba(31, 35, 41, 0.18);\n}\n\n\n#fqa-bookshelf-hover .fqa-hover-chapter {\n display: block;\n margin-bottom: 1px;\n color: #1f2329;\n font-weight: 500;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* ------------------------------- 骨架屏 ------------------------------- */\n\n#fqa-bookshelf .fqa-sk-cover {\n display: block;\n width: 100%;\n aspect-ratio: 3 / 4;\n border-radius: 6px;\n background: var(--fqa-skeleton);\n}\n\n#fqa-bookshelf .fqa-sk-line {\n height: 12px;\n margin-top: 8px;\n border-radius: 4px;\n background: var(--fqa-skeleton);\n}\n\n#fqa-bookshelf .fqa-sk-anim {\n position: relative;\n overflow: hidden;\n}\n\n/* keyframes fqa-shimmer 在 script.css 里全局声明 */\n#fqa-bookshelf .fqa-sk-anim::after {\n content: '';\n position: absolute;\n inset: 0;\n transform: translateX(-100%);\n background: linear-gradient(90deg, transparent, var(--fqa-skeleton-hl), transparent);\n animation: fqa-shimmer 1.4s infinite;\n}\n\n/* --------------------------- 空态 / 错误态 --------------------------- */\n\n#fqa-bookshelf .fqa-loadmore {\n padding: 24px 0;\n text-align: center;\n color: var(--fqa-text-weak);\n font-size: 13px;\n}\n\n#fqa-bookshelf .fqa-status {\n padding: 80px 16px;\n text-align: center;\n color: var(--fqa-text-weak);\n font-size: 14px;\n line-height: 22px;\n}\n\n#fqa-bookshelf .fqa-status-title {\n margin-bottom: 8px;\n color: var(--fqa-text);\n font-size: 16px;\n font-weight: 500;\n}\n\n#fqa-bookshelf .fqa-status .fqa-btn {\n margin-top: 16px;\n}\n\n/* ------------------------------- 深色模式 ------------------------------- */\n\n@media (prefers-color-scheme: dark) {\n #fqa-bookshelf {\n --fqa-text: #e6e6e6;\n --fqa-text-sub: #a6a6a6;\n --fqa-text-weak: #7a7a7a;\n --fqa-border: rgba(255, 255, 255, 0.1);\n --fqa-hover: rgba(255, 255, 255, 0.06);\n --fqa-skeleton: rgba(255, 255, 255, 0.08);\n --fqa-skeleton-hl: rgba(255, 255, 255, 0.14);\n --fqa-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);\n }\n\n #fqa-bookshelf-hover .fqa-hover-inner {\n background: #212125;\n color: #e6e6e6;\n box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);\n }\n\n #fqa-bookshelf-hover .fqa-hover-chapter {\n color: #e6e6e6;\n }\n\n #fqa-bookshelf-hover .fqa-hover-abstract {\n color: #a6a6a6;\n border-top-color: rgba(255, 255, 255, 0.1);\n }\n\n #fqa-bookshelf-hover .fqa-hover-stat + .fqa-hover-stat {\n border-left-color: rgba(255, 255, 255, 0.1);\n }\n}\n\n/* 右键菜单与 toast 样式已移到 script.css,书架和搜索共用 */\n"; const CONTAINER_ID$2 = "fqa-bookshelf-root"; const STYLE_ID$3 = "fqa-bookshelf-style"; const ORIGIN_SELECTOR$1 = ".muye-bookshelf, .muye-bookshelf-home-page, .bookshelf-tabs"; let app$2 = null; let container$2 = null; let observer$2 = null; function injectStyle$3() { if (document.getElementById(STYLE_ID$3)) return; const style = document.createElement("style"); style.id = STYLE_ID$3; style.textContent = bookshelfcss; document.head.appendChild(style); } function hideOrigin$1(root = document) { root.querySelectorAll(ORIGIN_SELECTOR$1).forEach((el) => { if (el.id === CONTAINER_ID$2 || el.closest(`#${CONTAINER_ID$2}`)) return; el.classList.add("fqa-hide"); }); } function isBookshelfPath(path) { return path.startsWith("/bookshelf"); } function unmount$1() { var _a; observer$2 == null ? void 0 : observer$2.disconnect(); observer$2 = null; app$2 == null ? void 0 : app$2.unmount(); app$2 = null; container$2 == null ? void 0 : container$2.remove(); container$2 = null; (_a = document.getElementById("fqa-bookshelf-hover")) == null ? void 0 : _a.remove(); document.querySelectorAll(ORIGIN_SELECTOR$1).forEach((el) => { el.classList.remove("fqa-hide"); }); } async function mainHook$2(_previous) { if (!isBookshelfPath(window.location.pathname)) { unmount$1(); return; } if (app$2) { hideOrigin$1(); return; } injectStyle$3(); const origin = await waitForElement(ORIGIN_SELECTOR$1); if (!isBookshelfPath(window.location.pathname)) return; if (app$2) return; hideOrigin$1(); container$2 = document.createElement("div"); container$2.id = CONTAINER_ID$2; const anchor = origin ?? document.querySelector("#root") ?? document.body; if (origin == null ? void 0 : origin.parentElement) { origin.insertAdjacentElement("beforebegin", container$2); } else { anchor.appendChild(container$2); } app$2 = vue.createApp(_sfc_main$4); app$2.config.errorHandler = (err, _instance, info) => { console.error(`[fqa:bookshelf] Vue error (${info}):`, err); }; app$2.mount(container$2); console.log("[fqa:bookshelf] 书架视图已挂载"); document.title = "我的书架 - 番茄小说"; observer$2 = new MutationObserver((mutations) => { var _a; for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (!(node instanceof HTMLElement)) continue; if (node.id === CONTAINER_ID$2 || node.closest(`#${CONTAINER_ID$2}`)) continue; if ((_a = node.matches) == null ? void 0 : _a.call(node, ORIGIN_SELECTOR$1)) { node.classList.add("fqa-hide"); } else { hideOrigin$1(node); } } } }); observer$2.observe(document.body, { childList: true, subtree: true }); } function filter$2(path, _query, _hash) { return (isBookshelfPath(path) || !!app$2) && userState.isLogin; } const _exports$2 = [ { id: "bookshelfHook_onload", event: "load", filter: filter$2, handler: mainHook$2 }, { id: "bookshelfHook_onurlchange", event: "onUrlChange", filter: filter$2, handler: mainHook$2 } ]; const _hoisted_1$3 = ["aria-label"]; const _hoisted_2$3 = { class: "fqa-sr-cover" }; const _hoisted_3$3 = ["src", "alt"]; const _hoisted_4$3 = { key: 0, class: "fqa-sr-badge" }; const _hoisted_5$3 = { class: "fqa-sr-body" }; const _hoisted_6$3 = ["innerHTML"]; const _hoisted_7$3 = { key: 1, class: "fqa-sr-title" }; const _hoisted_8$3 = { class: "fqa-sr-author" }; const _hoisted_9$3 = { key: 2, class: "fqa-sr-summary" }; const _hoisted_10$3 = { class: "fqa-sr-meta" }; const _hoisted_11$2 = { key: 0, class: "fqa-sr-read" }; const _hoisted_12$2 = { key: 3, class: "fqa-sr-update" }; const _hoisted_13$2 = { key: 0, class: "fqa-sr-chapter" }; const _hoisted_14$2 = { key: 1, class: "fqa-sr-time" }; const _sfc_main$3 = /* @__PURE__ */ vue.defineComponent({ __name: "SearchBookCard", props: { book: {} }, emits: ["open", "contextmenu"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; const titleHtml = vue.computed(() => props.book.highlight_title ?? null); const statusText = vue.computed(() => mappingCreationStatus(props.book.status)); const wordText = vue.computed(() => { const n = props.book.word_count; if (!n) return ""; if (n >= 1e4) return `${(n / 1e4).toFixed(1).replace(/\.0$/, "")}万字`; return `${n}字`; }); const metaParts = vue.computed( () => [statusText.value, wordText.value, props.book.category].filter(Boolean) ); const readText = vue.computed(() => { if (props.book.sub_info) return props.book.sub_info; const n = props.book.read_count; if (!n) return ""; return n >= 1e4 ? `${(n / 1e4).toFixed(1).replace(/\.0$/, "")}万人在读` : `${n}人在读`; }); const updateText = vue.computed(() => { const t = props.book.last_publish_time; return t ? `${fromNow(t)}更新` : ""; }); const imgLoaded = vue.ref(false); vue.watch( () => props.book.cover_url, () => { imgLoaded.value = false; } ); function onContextMenu(event) { event.preventDefault(); emit("contextmenu", { book: props.book, x: event.clientX, y: event.clientY }); } return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", { class: "fqa-sr-card", role: "link", tabindex: "0", "aria-label": __props.book.title, onClick: _cache[2] || (_cache[2] = ($event) => emit("open", __props.book)), onKeydown: _cache[3] || (_cache[3] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.book), ["prevent"]), ["enter"])), onContextmenu: onContextMenu }, [ vue.createElementVNode("div", _hoisted_2$3, [ vue.createElementVNode("img", { class: vue.normalizeClass(["fqa-sr-cover-img", { "fqa-sr-cover-loading": !imgLoaded.value }]), crossorigin: "anonymous", loading: "lazy", referrerpolicy: "no-referrer", src: __props.book.cover_url, alt: __props.book.title, onLoad: _cache[0] || (_cache[0] = ($event) => imgLoaded.value = true), onError: _cache[1] || (_cache[1] = ($event) => imgLoaded.value = true) }, null, 42, _hoisted_3$3), __props.book.in_bookshelf ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_4$3, "在书架")) : vue.createCommentVNode("", true) ]), vue.createElementVNode("div", _hoisted_5$3, [ titleHtml.value ? (vue.openBlock(), vue.createElementBlock("h3", { key: 0, class: "fqa-sr-title", innerHTML: titleHtml.value }, null, 8, _hoisted_6$3)) : (vue.openBlock(), vue.createElementBlock("h3", _hoisted_7$3, vue.toDisplayString(__props.book.title), 1)), vue.createElementVNode("div", _hoisted_8$3, [ vue.createElementVNode("span", null, vue.toDisplayString(__props.book.author), 1), vue.createElementVNode("span", { class: vue.normalizeClass(["fqa-sr-score", { "fqa-sr-score-none": !__props.book.score }]) }, vue.toDisplayString(__props.book.score ? `${__props.book.score}分` : "暂无评分"), 3) ]), __props.book.summary ? (vue.openBlock(), vue.createElementBlock("p", _hoisted_9$3, vue.toDisplayString(__props.book.summary), 1)) : vue.createCommentVNode("", true), vue.createElementVNode("div", _hoisted_10$3, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(metaParts.value, (part) => { return vue.openBlock(), vue.createElementBlock("span", { key: part, class: "fqa-sr-tag" }, vue.toDisplayString(part), 1); }), 128)), readText.value ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_11$2, vue.toDisplayString(readText.value), 1)) : vue.createCommentVNode("", true) ]), updateText.value || __props.book.last_chapter_title ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_12$2, [ __props.book.last_chapter_title ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_13$2, vue.toDisplayString(__props.book.last_chapter_title), 1)) : vue.createCommentVNode("", true), updateText.value ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_14$2, vue.toDisplayString(updateText.value), 1)) : vue.createCommentVNode("", true) ])) : vue.createCommentVNode("", true) ]) ], 40, _hoisted_1$3); }; } }); const BOOK_TAB_TYPE = 1; const RESULT_SHOW_TYPE = 110; const RENDERABLE_TABS = /* @__PURE__ */ new Set([1, 2, 3, 5, 8]); function credentials() { return settings$1.searchPersonalized ? "include" : "omit"; } function num(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; } function bool(v) { if (typeof v === "string") return v !== "" && v !== "0" && v !== "false"; return Boolean(v); } function sanitizeHighlight(html) { if (typeof html !== "string" || !html) return void 0; return html.replace(/<(?!\/?em\s*\/?>)[^>]*>/gi, ""); } function normalizeBook(raw, cell) { var _a; const bookId2 = String((raw == null ? void 0 : raw.book_id) ?? ""); if (!bookId2 || bookId2 === "0") return null; const hl = cell == null ? void 0 : cell.search_high_light; return { book_id: bookId2, title: raw.book_name || raw.original_book_name || "", author: raw.author || "", cover_url: raw.thumb_url || raw.audio_thumb_url_hd || "", summary: raw.abstract || "", status: String(raw.creation_status ?? ""), word_count: num(raw.word_number), // sub_info 是接口给的展示文案,如「404章」「10.5万人在读」 sub_info: String(raw.sub_info ?? ""), read_count: num(raw.read_count), // 未评分的书下发 "0",视为没有评分 score: num(raw.score) > 0 ? String(raw.score) : "", category: raw.category || "", chapter_count: num(raw.serial_count), last_chapter_title: raw.last_chapter_title || "", last_publish_time: num(raw.last_publish_time) * 1e3, highlight_title: sanitizeHighlight((_a = hl == null ? void 0 : hl.title) == null ? void 0 : _a.rich_text), genre: raw.genre != null ? String(raw.genre) : void 0, in_bookshelf: bool(raw.in_bookshelf) }; } function collectBooks(cells) { const books = []; const seen = /* @__PURE__ */ new Set(); const take = (cell) => { for (const raw of (cell == null ? void 0 : cell.book_data) ?? []) { const book = normalizeBook(raw, cell); if (!book || seen.has(book.book_id)) continue; seen.add(book.book_id); books.push(book); } }; const results = cells.filter((c) => Number(c == null ? void 0 : c.show_type) === RESULT_SHOW_TYPE); if (results.length) { results.forEach(take); return books; } cells.filter((c) => Array.isArray(c == null ? void 0 : c.book_data) && c.book_data.length).forEach(take); return books; } function collectTabs(tabs) { return tabs.map((t) => { var _a; return { tab_type: num(t == null ? void 0 : t.tab_type), // 字段名是 title,不是 tab_name tab_name: String((t == null ? void 0 : t.title) ?? ""), has_selector: Array.isArray((_a = t == null ? void 0 : t.selector) == null ? void 0 : _a.rows) && t.selector.rows.length > 0 }; }).filter((t) => t.tab_type > 0 && t.tab_name && RENDERABLE_TABS.has(t.tab_type)); } function collectSelectorRows(tabs) { var _a, _b; const rows = (_b = (_a = tabs.find((t) => { var _a2; return Array.isArray((_a2 = t == null ? void 0 : t.selector) == null ? void 0 : _a2.rows); })) == null ? void 0 : _a.selector) == null ? void 0 : _b.rows; if (!Array.isArray(rows)) return []; return rows.map((row) => ({ name: String((row == null ? void 0 : row.row_name) ?? ""), items: (Array.isArray(row == null ? void 0 : row.items) ? row.items : []).map((item) => ({ id: String((item == null ? void 0 : item.selector_item_id) ?? ""), name: String((item == null ? void 0 : item.show_name) ?? ""), default: Boolean(item == null ? void 0 : item.is_default_selected) })).filter((item) => item.id && item.name) })).filter((row) => row.name && row.items.length > 0); } async function search({ query, passback = 0, selectedItems = [], tabType = BOOK_TAB_TYPE }) { const selected = tabType === BOOK_TAB_TYPE ? selectedItems.filter(Boolean).join(",") : ""; const j = await webGet( "/bookapi/search/tab/v", { query, passback: String(passback), selected_items: selected, tab_type: String(tabType) }, credentials() ); if ((j == null ? void 0 : j.code) !== void 0 && j.code !== 0) { throw new Error(j.message || `搜索失败(code=${j.code})`); } const tabs = Array.isArray(j == null ? void 0 : j.search_tabs) ? j.search_tabs : []; const current = tabs.find((t) => num(t == null ? void 0 : t.tab_type) === tabType); const cells = Array.isArray(current == null ? void 0 : current.data) ? current.data : []; const books = collectBooks(cells); return { books, // 回填服务端游标;没给就按已取回的书数往前推,至少不会原地打转 next_passback: num(current == null ? void 0 : current.passback) || passback + Math.max(books.length, 1), has_more: Boolean(current == null ? void 0 : current.has_more) && books.length > 0, tabs: collectTabs(tabs), selector_rows: collectSelectorRows(tabs) }; } function collectWords(list) { if (!Array.isArray(list)) return []; return list.map((t) => ({ word: String((t == null ? void 0 : t.tag_title) ?? ""), // 「877405热搜值」之类的附加说明 tag: (t == null ? void 0 : t.tag_attached) ? String(t.tag_attached) : void 0, label: (t == null ? void 0 : t.label) ? String(t.label) : void 0 })).filter((w) => w.word); } function collectSugBooks(list) { if (!Array.isArray(list)) return []; return list.map((b) => ({ book_id: String((b == null ? void 0 : b.book_id) ?? ""), title: String((b == null ? void 0 : b.book_name) ?? ""), cover_url: String((b == null ? void 0 : b.thumb_url) ?? ""), author: (b == null ? void 0 : b.author) ? String(b.author) : void 0, desc: (b == null ? void 0 : b.sub_info) ? String(b.sub_info) : void 0 })).filter((b) => b.book_id && b.title); } function pushSection(out, title, words, books) { if (!words.length && !books.length) return; out.push({ title: title || (words.length ? "热搜" : "推荐"), kind: words.length ? "hotword" : "book", words, books }); } async function getSearchLanding() { const j = await webGet( "/bookapi/plan/v", { search_source: "1", scene: "10", new_search_middle_page: "true", search_middle_page_version: "2", from: "search_input_page", tab_name: "store", bookstore_tab: "2", bookstore_tab_type: "2", hot_word_exchange: "false", query_history_removed: "false", user_is_login: settings$1.searchPersonalized ? "1" : "0" }, credentials() ); if ((j == null ? void 0 : j.code) !== void 0 && j.code !== 0) { throw new Error(j.message || `加载推荐失败(code=${j.code})`); } const sections = []; for (const cell of Array.isArray(j == null ? void 0 : j.data) ? j.data : []) { if (Array.isArray(cell == null ? void 0 : cell.cell_data)) { for (const sub of cell.cell_data) { pushSection( sections, String((sub == null ? void 0 : sub.cell_name) ?? ""), collectWords(sub == null ? void 0 : sub.search_tag_data), collectSugBooks(sub == null ? void 0 : sub.book_data) ); } continue; } pushSection( sections, String((cell == null ? void 0 : cell.cell_name) ?? "") || "猜你想搜", collectWords(cell == null ? void 0 : cell.search_tag_data), collectSugBooks(cell == null ? void 0 : cell.book_data) ); } return sections; } const _hoisted_1$2 = { class: "fqa-s-landing" }; const _hoisted_2$2 = { key: 0, class: "fqa-s-landing-sk" }; const _hoisted_3$2 = { key: 1, class: "fqa-s-status" }; const _hoisted_4$2 = { key: 2, class: "fqa-s-status" }; const _hoisted_5$2 = { class: "fqa-s-sec-title" }; const _hoisted_6$2 = { key: 0, class: "fqa-s-words" }; const _hoisted_7$2 = ["onClick"]; const _hoisted_8$2 = { key: 0, class: "fqa-s-word-label" }; const _hoisted_9$2 = { key: 1, class: "fqa-s-word-tag" }; const _hoisted_10$2 = { key: 1, class: "fqa-s-sugs" }; const _hoisted_11$1 = ["onClick", "onKeydown"]; const _hoisted_12$1 = ["src", "alt"]; const _hoisted_13$1 = ["title"]; const _hoisted_14$1 = { key: 0, class: "fqa-s-sug-sub" }; const _sfc_main$2 = /* @__PURE__ */ vue.defineComponent({ __name: "SearchLanding", emits: ["word"], setup(__props, { emit: __emit }) { const emit = __emit; const sections = vue.ref([]); const loading = vue.ref(true); const error = vue.ref(null); async function load2() { loading.value = true; error.value = null; try { sections.value = await getSearchLanding(); } catch (err) { console.error("[fqa:search] 推荐加载失败:", err); error.value = err instanceof Error ? err.message : String(err); } finally { loading.value = false; } } function openBook(bookId2) { unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId2}`; } vue.onMounted(load2); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", _hoisted_1$2, [ loading.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_2$2, [ (vue.openBlock(), vue.createElementBlock(vue.Fragment, null, vue.renderList(12, (n) => { return vue.createElementVNode("div", { key: n, class: "fqa-sk-chip fqa-sk-anim" }); }), 64)) ])) : error.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_3$2, [ _cache[0] || (_cache[0] = vue.createElementVNode("div", { class: "fqa-s-status-title" }, "推荐内容加载失败", -1)), _cache[1] || (_cache[1] = vue.createElementVNode("div", null, "直接在上方输入关键词也可以搜索", -1)), vue.createElementVNode("button", { class: "fqa-s-submit", onClick: load2 }, "重试") ])) : !sections.value.length ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_4$2, [..._cache[2] || (_cache[2] = [ vue.createElementVNode("div", { class: "fqa-s-status-title" }, "输入关键词开始搜索", -1) ])])) : (vue.openBlock(true), vue.createElementBlock(vue.Fragment, { key: 3 }, vue.renderList(sections.value, (section, i2) => { return vue.openBlock(), vue.createElementBlock("section", { key: `${section.title}-${i2}`, class: "fqa-s-sec" }, [ vue.createElementVNode("h3", _hoisted_5$2, vue.toDisplayString(section.title), 1), section.words.length ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_6$2, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(section.words, (w, wi) => { return vue.openBlock(), vue.createElementBlock("button", { key: `${w.word}-${wi}`, class: "fqa-s-word", onClick: ($event) => emit("word", w.word) }, [ w.label ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_8$2, vue.toDisplayString(w.label), 1)) : vue.createCommentVNode("", true), vue.createTextVNode(" " + vue.toDisplayString(w.word) + " ", 1), w.tag ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_9$2, vue.toDisplayString(w.tag), 1)) : vue.createCommentVNode("", true) ], 8, _hoisted_7$2); }), 128)) ])) : vue.createCommentVNode("", true), section.books.length ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_10$2, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(section.books, (b) => { return vue.openBlock(), vue.createElementBlock("div", { key: b.book_id, class: "fqa-s-sug", role: "link", tabindex: "0", onClick: ($event) => openBook(b.book_id), onKeydown: vue.withKeys(vue.withModifiers(($event) => openBook(b.book_id), ["prevent"]), ["enter"]) }, [ vue.createElementVNode("img", { class: "fqa-s-sug-cover", crossorigin: "anonymous", loading: "lazy", referrerpolicy: "no-referrer", src: b.cover_url, alt: b.title }, null, 8, _hoisted_12$1), vue.createElementVNode("div", { class: "fqa-s-sug-title", title: b.title }, vue.toDisplayString(b.title), 9, _hoisted_13$1), b.desc || b.author ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_14$1, vue.toDisplayString(b.desc || b.author), 1)) : vue.createCommentVNode("", true) ], 40, _hoisted_11$1); }), 128)) ])) : vue.createCommentVNode("", true) ]); }), 128)) ]); }; } }); const FALLBACK_TABS = [{ tab_type: BOOK_TAB_TYPE, tab_name: "综合", has_selector: true }]; function useSearch() { const query = vue.ref(""); const tabType = vue.ref(BOOK_TAB_TYPE); const tabs = vue.ref([]); const selectorRows = vue.ref([]); const selected = vue.ref({}); const books = vue.ref([]); const passback = vue.ref(0); const hasMore = vue.ref(false); const loading = vue.ref(false); const loadingMore = vue.ref(false); const error = vue.ref(null); const searched = vue.ref(false); let token = 0; const selectedItems = vue.computed( () => Object.values(selected.value).filter(Boolean) ); const availableTabs = vue.computed(() => tabs.value.length ? tabs.value : FALLBACK_TABS); const canFilter = vue.computed(() => tabType.value === BOOK_TAB_TYPE && selectorRows.value.length > 0); const filterCount = vue.computed(() => selectedItems.value.length); async function run(reset2) { const q = query.value.trim(); if (!q) return; const current = ++token; if (reset2) { passback.value = 0; loading.value = true; error.value = null; } else { if (loadingMore.value || !hasMore.value) return; loadingMore.value = true; } try { const result = await search({ query: q, passback: passback.value, selectedItems: selectedItems.value, tabType: tabType.value }); if (current !== token) return; if (reset2) { books.value = result.books; } else { const seen = new Set(books.value.map((b) => b.book_id)); books.value = [...books.value, ...result.books.filter((b) => !seen.has(b.book_id))]; } passback.value = result.next_passback; hasMore.value = result.has_more; if (result.tabs.length) tabs.value = result.tabs; if (result.selector_rows.length) selectorRows.value = result.selector_rows; searched.value = true; } catch (err) { if (current !== token) return; console.error("[fqa:search] 搜索失败:", err); error.value = err instanceof Error ? err.message : String(err); if (reset2) books.value = []; searched.value = true; } finally { if (current === token) { loading.value = false; loadingMore.value = false; } } } async function submit(q) { if (q !== void 0) query.value = q; await run(true); } function reset() { token++; query.value = ""; books.value = []; passback.value = 0; hasMore.value = false; searched.value = false; error.value = null; loading.value = false; loadingMore.value = false; selected.value = {}; tabType.value = BOOK_TAB_TYPE; } async function loadMore() { if (!hasMore.value || loading.value || loadingMore.value) return; await run(false); } function selectTab(next) { if (tabType.value === next) return; tabType.value = next; if (next !== BOOK_TAB_TYPE) selected.value = {}; void run(true); } function toggleFilter(rowName, itemId2) { const cur = selected.value[rowName]; if (cur === itemId2) { const next = { ...selected.value }; delete next[rowName]; selected.value = next; } else { selected.value = { ...selected.value, [rowName]: itemId2 }; } void run(true); } function clearFilters() { if (!filterCount.value) return; selected.value = {}; void run(true); } const isEmpty = vue.computed( () => searched.value && !loading.value && !error.value && books.value.length === 0 ); return { query, tabType, availableTabs, selectorRows, selected, canFilter, filterCount, books, hasMore, loading, loadingMore, error, searched, isEmpty, submit, reset, loadMore, selectTab, toggleFilter, clearFilters }; } const routeQuery = vue.ref(""); function parseQuery(path) { const rest = path.replace(/^\/search\/?/, ""); if (!rest) return ""; try { return decodeURIComponent(rest); } catch { return rest; } } function syncFromUrl(path) { const q = parseQuery(path); if (routeQuery.value !== q) routeQuery.value = q; } function pushQuery(q) { routeQuery.value = q; const next = q ? `/search/${encodeURIComponent(q)}` : "/search"; if (unsafeWindow.location.pathname === next) return; unsafeWindow.history.pushState(null, "", next); } const _hoisted_1$1 = { id: "fqa-search" }; const _hoisted_2$1 = { class: "fqa-s-bar" }; const _hoisted_3$1 = { class: "fqa-s-inputwrap" }; const _hoisted_4$1 = ["disabled"]; const _hoisted_5$1 = ["aria-selected", "onClick", "onKeydown"]; const _hoisted_6$1 = { key: 0, class: "fqa-s-filterbar" }; const _hoisted_7$1 = { key: 0, class: "fqa-s-fcount" }; const _hoisted_8$1 = { class: "fqa-s-hint" }; const _hoisted_9$1 = { key: 1, class: "fqa-s-filters" }; const _hoisted_10$1 = { class: "fqa-s-frow-name" }; const _hoisted_11 = { class: "fqa-s-fitems" }; const _hoisted_12 = ["onClick"]; const _hoisted_13 = { key: 2, class: "fqa-s-status" }; const _hoisted_14 = { key: 3, class: "fqa-s-status" }; const _hoisted_15 = { class: "fqa-s-list" }; const _hoisted_16 = { key: 1, class: "fqa-s-loadmore" }; const _hoisted_17 = { key: 2, class: "fqa-s-inline-error" }; const _hoisted_18 = { key: 2, class: "fqa-s-privacy" }; const _hoisted_19 = { key: 0, class: "fqa-toast" }; const DOWNLOAD_PREFIX = "download:"; const _sfc_main$1 = /* @__PURE__ */ vue.defineComponent({ __name: "SearchView", setup(__props) { const { query, tabType, availableTabs, selectorRows, selected, canFilter, filterCount, books, hasMore, loading, loadingMore, error, isEmpty, submit, reset, loadMore, selectTab, toggleFilter, clearFilters } = useSearch(); const input = vue.ref(routeQuery.value); const filterOpen = vue.ref(false); const showLanding = vue.computed(() => !query.value.trim()); async function doSearch(q = input.value) { const trimmed = q.trim(); if (!trimmed) return; input.value = trimmed; filterOpen.value = false; pushQuery(trimmed); await submit(trimmed); } function onWord(word) { void doSearch(word); } const sentinel = vue.ref(null); let io = null; vue.onMounted(async () => { if (routeQuery.value) await doSearch(routeQuery.value); if (typeof IntersectionObserver !== "undefined") { io = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting)) void loadMore(); }, { rootMargin: "400px" } ); vue.watch( sentinel, (el) => { io == null ? void 0 : io.disconnect(); if (el) io == null ? void 0 : io.observe(el); }, { immediate: true, flush: "post" } ); } }); vue.onBeforeUnmount(() => { io == null ? void 0 : io.disconnect(); io = null; }); vue.watch( routeQuery, (q) => { input.value = q; if (q === query.value.trim()) return; if (q) { void submit(q); } else { filterOpen.value = false; reset(); } } ); const menuVisible = vue.ref(false); const menuPos = vue.ref({ x: 0, y: 0 }); const menuBook = vue.ref(null); const toast = vue.ref(null); let toastTimer; function showToast(msg) { toast.value = msg; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { toast.value = null; toastTimer = void 0; }, 2600); } const menuItems = vue.computed(() => { var _a, _b, _c; const items = [ { key: "detail", label: "查看详情" }, { key: "shelf", label: ((_a = menuBook.value) == null ? void 0 : _a.in_bookshelf) ? "已在书架" : "加入书架", disabled: !userState.isLogin || Boolean((_b = menuBook.value) == null ? void 0 : _b.in_bookshelf) }, { key: "author", label: "搜索该作者", disabled: !((_c = menuBook.value) == null ? void 0 : _c.author) } ]; if (settings$1.enableDownload) { items.push({ key: "download", label: "下载", children: [ { key: `${DOWNLOAD_PREFIX}epub`, label: "EPUB" }, { key: `${DOWNLOAD_PREFIX}txt`, label: "TXT" } ] }); } return items; }); function onCardContextMenu({ book, x, y }) { menuBook.value = book; menuPos.value = { x, y }; menuVisible.value = true; } function openBook(book) { unsafeWindow.location.href = `https://fanqienovel.com/page/${book.book_id}`; } async function onMenuSelect(key) { const book = menuBook.value; if (!book) return; if (key === "detail") { openBook(book); return; } if (key === "author") { void doSearch(book.author); return; } if (key.startsWith(DOWNLOAD_PREFIX)) { const format = key.slice(DOWNLOAD_PREFIX.length); void startDownload(book.book_id, { format }); return; } if (key === "shelf") { try { await addToBookshelf(book.book_id); book.in_bookshelf = true; showToast(`已把《${book.title}》加入书架`); } catch (err) { console.error("[fqa:search] 加入书架失败:", err); showToast(err instanceof Error ? err.message : "加入书架失败"); } } } const tabsRef = vue.ref(null); const inkStyle = vue.ref({ left: "0px", width: "0px" }); function updateInk() { const wrap = tabsRef.value; if (!wrap) return; const index = availableTabs.value.findIndex((t) => t.tab_type === tabType.value); const el = wrap.querySelectorAll(".fqa-s-tab")[index]; if (!el) return; inkStyle.value = { left: `${el.offsetLeft}px`, width: `${el.offsetWidth}px` }; } vue.watch([tabType, availableTabs], () => vue.nextTick(updateInk), { deep: true }); vue.watch(showLanding, () => vue.nextTick(updateInk)); vue.onMounted(() => { window.addEventListener("resize", updateInk); }); vue.onBeforeUnmount(() => { window.removeEventListener("resize", updateInk); if (toastTimer) clearTimeout(toastTimer); }); const resultHint = vue.computed(() => { if (loading.value) return "搜索中…"; if (books.value.length) return `已加载 ${books.value.length} 条`; return ""; }); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", _hoisted_1$1, [ vue.createElementVNode("div", _hoisted_2$1, [ vue.createElementVNode("div", _hoisted_3$1, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => input.value = $event), class: "fqa-s-input", type: "search", placeholder: "搜索书名、作者", "aria-label": "搜索", onKeydown: _cache[1] || (_cache[1] = vue.withKeys(vue.withModifiers(($event) => doSearch(), ["prevent"]), ["enter"])) }, null, 544), [ [vue.vModelText, input.value] ]), input.value ? (vue.openBlock(), vue.createElementBlock("button", { key: 0, class: "fqa-s-clear", "aria-label": "清空", onClick: _cache[2] || (_cache[2] = ($event) => input.value = "") }, " ✕ ")) : vue.createCommentVNode("", true) ]), vue.createElementVNode("button", { class: "fqa-s-submit", disabled: !input.value.trim() || vue.unref(loading), onClick: _cache[3] || (_cache[3] = ($event) => doSearch()) }, " 搜索 ", 8, _hoisted_4$1) ]), showLanding.value ? (vue.openBlock(), vue.createBlock(_sfc_main$2, { key: 0, onWord })) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 1 }, [ vue.createElementVNode("div", { ref_key: "tabsRef", ref: tabsRef, class: "fqa-s-tabs", role: "tablist" }, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(vue.unref(availableTabs), (tab) => { return vue.openBlock(), vue.createElementBlock("div", { key: tab.tab_type, class: vue.normalizeClass(["fqa-s-tab", { "fqa-s-tab-active": vue.unref(tabType) === tab.tab_type }]), role: "tab", tabindex: "0", "aria-selected": vue.unref(tabType) === tab.tab_type, onClick: ($event) => vue.unref(selectTab)(tab.tab_type), onKeydown: vue.withKeys(vue.withModifiers(($event) => vue.unref(selectTab)(tab.tab_type), ["prevent"]), ["enter"]) }, vue.toDisplayString(tab.tab_name), 43, _hoisted_5$1); }), 128)), vue.createElementVNode("span", { class: "fqa-s-tab-ink", style: vue.normalizeStyle(inkStyle.value) }, null, 4) ], 512), vue.unref(canFilter) ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_6$1, [ vue.createElementVNode("button", { class: vue.normalizeClass(["fqa-s-fbtn", { "fqa-s-fbtn-on": filterOpen.value || vue.unref(filterCount) > 0 }]), onClick: _cache[4] || (_cache[4] = ($event) => filterOpen.value = !filterOpen.value) }, [ _cache[8] || (_cache[8] = vue.createTextVNode(" 筛选", -1)), vue.unref(filterCount) ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_7$1, vue.toDisplayString(vue.unref(filterCount)), 1)) : vue.createCommentVNode("", true) ], 2), vue.unref(filterCount) ? (vue.openBlock(), vue.createElementBlock("button", { key: 0, class: "fqa-s-fclear", onClick: _cache[5] || (_cache[5] = //@ts-ignore (...args) => vue.unref(clearFilters) && vue.unref(clearFilters)(...args)) }, "清除")) : vue.createCommentVNode("", true), vue.createElementVNode("span", _hoisted_8$1, vue.toDisplayString(resultHint.value), 1) ])) : vue.createCommentVNode("", true), vue.unref(canFilter) && filterOpen.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_9$1, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(vue.unref(selectorRows), (row) => { return vue.openBlock(), vue.createElementBlock("div", { key: row.name, class: "fqa-s-frow" }, [ vue.createElementVNode("span", _hoisted_10$1, vue.toDisplayString(row.name), 1), vue.createElementVNode("div", _hoisted_11, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(row.items, (item) => { return vue.openBlock(), vue.createElementBlock("button", { key: item.id, class: vue.normalizeClass(["fqa-s-fitem", { "fqa-s-fitem-on": vue.unref(selected)[row.name] === item.id }]), onClick: ($event) => vue.unref(toggleFilter)(row.name, item.id) }, vue.toDisplayString(item.name), 11, _hoisted_12); }), 128)) ]) ]); }), 128)) ])) : vue.createCommentVNode("", true), vue.unref(error) && !vue.unref(books).length ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_13, [ _cache[9] || (_cache[9] = vue.createElementVNode("div", { class: "fqa-s-status-title" }, "搜索失败", -1)), vue.createElementVNode("div", null, vue.toDisplayString(vue.unref(error)), 1), vue.createElementVNode("button", { class: "fqa-s-submit", onClick: _cache[6] || (_cache[6] = ($event) => doSearch()) }, "重试") ])) : vue.unref(isEmpty) ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_14, [..._cache[10] || (_cache[10] = [ vue.createElementVNode("div", { class: "fqa-s-status-title" }, "没有找到相关内容", -1), vue.createElementVNode("div", null, "换个关键词或者放宽筛选条件试试", -1) ])])) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 4 }, [ vue.createElementVNode("div", _hoisted_15, [ vue.unref(loading) ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, vue.renderList(5, (n) => { return vue.createElementVNode("div", { key: `sk-${n}`, class: "fqa-sr-card fqa-sr-skeleton" }, [..._cache[11] || (_cache[11] = [ vue.createStaticVNode('', 2) ])]); }), 64)) : (vue.openBlock(true), vue.createElementBlock(vue.Fragment, { key: 1 }, vue.renderList(vue.unref(books), (book) => { return vue.openBlock(), vue.createBlock(_sfc_main$3, { key: book.book_id, book, onOpen: openBook, onContextmenu: onCardContextMenu }, null, 8, ["book"]); }), 128)) ]), !vue.unref(loading) && vue.unref(hasMore) ? (vue.openBlock(), vue.createElementBlock("div", { key: 0, ref_key: "sentinel", ref: sentinel, class: "fqa-s-loadmore" }, vue.toDisplayString(vue.unref(loadingMore) ? "加载中…" : "滚动加载更多"), 513)) : !vue.unref(loading) && vue.unref(books).length ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_16, "没有更多了")) : vue.createCommentVNode("", true), vue.unref(error) && vue.unref(books).length ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_17, vue.toDisplayString(vue.unref(error)), 1)) : vue.createCommentVNode("", true) ], 64)) ], 64)), !vue.unref(settings$1).searchPersonalized ? (vue.openBlock(), vue.createElementBlock("p", _hoisted_18, " 当前使用匿名搜索。如需按你的阅读偏好排序,可在助手设置里开启个人化推荐。 ")) : vue.createCommentVNode("", true), (vue.openBlock(), vue.createBlock(vue.Teleport, { to: "body" }, [ vue.createVNode(_sfc_main$5, { visible: menuVisible.value, x: menuPos.value.x, y: menuPos.value.y, items: menuItems.value, onSelect: onMenuSelect, onClose: _cache[7] || (_cache[7] = ($event) => menuVisible.value = false) }, null, 8, ["visible", "x", "y", "items"]), toast.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_19, vue.toDisplayString(toast.value), 1)) : vue.createCommentVNode("", true) ])) ]); }; } }); const searchcss = "#fqa-search {\n --fqa-text: #1f2329;\n --fqa-text-sub: #646a73;\n --fqa-text-weak: #8f959e;\n --fqa-border: rgba(31, 35, 41, 0.08);\n --fqa-hover: rgba(31, 35, 41, 0.04);\n --fqa-accent: #ff6f3d;\n --fqa-skeleton: rgba(31, 35, 41, 0.06);\n --fqa-skeleton-hl: rgba(31, 35, 41, 0.12);\n --fqa-shadow: 0 4px 16px rgba(31, 35, 41, 0.08);\n --fqa-card-bg: #fff;\n\n display: block;\n box-sizing: border-box;\n width: 100%;\n max-width: 1000px;\n margin: 0 auto;\n /* 顶部让开原站 fixed 顶栏 */\n padding: calc(80px + 24px) 16px 64px;\n color: var(--fqa-text);\n font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,\n sans-serif;\n}\n\n#fqa-search *,\n#fqa-search *::before,\n#fqa-search *::after {\n box-sizing: border-box;\n}\n\n#fqa-search div,\n#fqa-search span,\n#fqa-search h3,\n#fqa-search p {\n margin: 0;\n padding: 0;\n border: 0;\n float: none;\n position: static;\n}\n\n/* -------------------------------- 搜索栏 -------------------------------- */\n\n#fqa-search .fqa-s-bar {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 20px;\n}\n\n#fqa-search .fqa-s-inputwrap {\n position: relative;\n flex: 1 1 auto;\n min-width: 0;\n}\n\n#fqa-search .fqa-s-input {\n width: 100%;\n padding: 11px 36px 11px 16px;\n box-sizing: border-box;\n border: 1px solid var(--fqa-border);\n border-radius: 999px;\n background: var(--fqa-card-bg);\n color: var(--fqa-text);\n font-size: 15px;\n font-family: inherit;\n line-height: 22px;\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\n}\n\n#fqa-search .fqa-s-input::placeholder {\n color: var(--fqa-text-weak);\n}\n\n#fqa-search .fqa-s-input:focus {\n outline: none;\n border-color: var(--fqa-accent);\n box-shadow: 0 0 0 3px rgba(255, 111, 61, 0.12);\n}\n\n/* 隐藏浏览器自带的 search 清除按钮,用自己的 */\n#fqa-search .fqa-s-input::-webkit-search-cancel-button {\n appearance: none;\n}\n\n#fqa-search .fqa-s-clear {\n position: absolute;\n top: 50%;\n right: 10px;\n transform: translateY(-50%);\n width: 22px;\n height: 22px;\n padding: 0;\n border: 0;\n border-radius: 50%;\n background: var(--fqa-hover);\n color: var(--fqa-text-weak);\n font-size: 11px;\n line-height: 1;\n cursor: pointer;\n}\n\n#fqa-search .fqa-s-clear:hover {\n color: var(--fqa-text);\n}\n\n#fqa-search .fqa-s-submit {\n flex: 0 0 auto;\n padding: 11px 24px;\n border: 0;\n border-radius: 999px;\n background: var(--fqa-accent);\n color: #fff;\n font-size: 15px;\n font-family: inherit;\n line-height: 22px;\n cursor: pointer;\n transition: opacity 0.15s ease;\n}\n\n#fqa-search .fqa-s-submit:hover:not(:disabled) {\n opacity: 0.88;\n}\n\n#fqa-search .fqa-s-submit:disabled {\n opacity: 0.45;\n cursor: default;\n}\n\n/* --------------------------------- tab --------------------------------- */\n\n#fqa-search .fqa-s-tabs {\n position: relative;\n display: flex;\n align-items: center;\n gap: 4px;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--fqa-border);\n overflow-x: auto;\n scrollbar-width: none;\n}\n\n#fqa-search .fqa-s-tabs::-webkit-scrollbar {\n display: none;\n}\n\n#fqa-search .fqa-s-tab {\n flex: 0 0 auto;\n padding: 10px 16px;\n color: var(--fqa-text-sub);\n font-size: 15px;\n line-height: 22px;\n white-space: nowrap;\n cursor: pointer;\n user-select: none;\n transition: color 0.15s ease;\n}\n\n#fqa-search .fqa-s-tab:hover {\n color: var(--fqa-text);\n}\n\n#fqa-search .fqa-s-tab-active {\n color: var(--fqa-accent);\n font-weight: 600;\n}\n\n#fqa-search .fqa-s-tab-ink {\n position: absolute;\n bottom: -1px;\n left: 0;\n width: 0;\n height: 2px;\n border-radius: 2px;\n background: var(--fqa-accent);\n transition: left 0.25s ease, width 0.25s ease;\n}\n\n/* -------------------------------- 筛选器 -------------------------------- */\n\n#fqa-search .fqa-s-filterbar {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 12px;\n}\n\n#fqa-search .fqa-s-fbtn,\n#fqa-search .fqa-s-fclear {\n padding: 5px 14px;\n border: 1px solid var(--fqa-border);\n border-radius: 999px;\n background: transparent;\n color: var(--fqa-text-sub);\n font-size: 13px;\n font-family: inherit;\n line-height: 20px;\n cursor: pointer;\n transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;\n}\n\n#fqa-search .fqa-s-fbtn:hover,\n#fqa-search .fqa-s-fclear:hover {\n border-color: var(--fqa-accent);\n color: var(--fqa-accent);\n}\n\n#fqa-search .fqa-s-fbtn-on {\n border-color: var(--fqa-accent);\n color: var(--fqa-accent);\n background: rgba(255, 111, 61, 0.08);\n}\n\n#fqa-search .fqa-s-fcount {\n display: inline-block;\n min-width: 16px;\n margin-left: 5px;\n padding: 0 4px;\n border-radius: 8px;\n background: var(--fqa-accent);\n color: #fff;\n font-size: 11px;\n line-height: 16px;\n text-align: center;\n}\n\n#fqa-search .fqa-s-hint {\n margin-left: auto;\n color: var(--fqa-text-weak);\n font-size: 13px;\n}\n\n#fqa-search .fqa-s-filters {\n margin-bottom: 16px;\n padding: 14px 16px;\n border: 1px solid var(--fqa-border);\n border-radius: 10px;\n background: var(--fqa-card-bg);\n}\n\n#fqa-search .fqa-s-frow {\n display: flex;\n align-items: flex-start;\n gap: 12px;\n padding: 7px 0;\n}\n\n#fqa-search .fqa-s-frow-name {\n flex: 0 0 58px;\n padding-top: 5px;\n color: var(--fqa-text-weak);\n font-size: 13px;\n line-height: 20px;\n}\n\n#fqa-search .fqa-s-fitems {\n display: flex;\n flex: 1 1 auto;\n flex-wrap: wrap;\n gap: 8px;\n min-width: 0;\n}\n\n#fqa-search .fqa-s-fitem {\n padding: 4px 12px;\n border: 1px solid transparent;\n border-radius: 6px;\n background: var(--fqa-hover);\n color: var(--fqa-text-sub);\n font-size: 13px;\n font-family: inherit;\n line-height: 20px;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n}\n\n#fqa-search .fqa-s-fitem:hover {\n color: var(--fqa-text);\n}\n\n#fqa-search .fqa-s-fitem-on {\n background: rgba(255, 111, 61, 0.12);\n border-color: rgba(255, 111, 61, 0.4);\n color: var(--fqa-accent);\n font-weight: 500;\n}\n\n/* ------------------------------- 结果列表 ------------------------------- */\n\n#fqa-search .fqa-s-list {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n#fqa-search .fqa-sr-card {\n display: flex;\n gap: 16px;\n padding: 16px 12px;\n border-radius: 10px;\n cursor: pointer;\n outline: none;\n transition: background 0.15s ease;\n}\n\n#fqa-search .fqa-sr-card:hover {\n background: var(--fqa-hover);\n}\n\n#fqa-search .fqa-sr-card:focus-visible {\n box-shadow: 0 0 0 2px var(--fqa-accent);\n}\n\n#fqa-search .fqa-sr-skeleton {\n cursor: default;\n}\n\n#fqa-search .fqa-sr-skeleton:hover {\n background: transparent;\n}\n\n#fqa-search .fqa-sr-cover {\n position: relative;\n flex: 0 0 96px;\n width: 96px;\n aspect-ratio: 3 / 4;\n border-radius: 6px;\n overflow: hidden;\n background: var(--fqa-skeleton);\n}\n\n#fqa-search .fqa-sr-cover-img {\n position: absolute;\n inset: 0;\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n transition: opacity 0.25s ease;\n}\n\n#fqa-search .fqa-sr-cover-loading {\n opacity: 0;\n}\n\n#fqa-search .fqa-sr-badge {\n position: absolute;\n left: 0;\n right: 0;\n bottom: 0;\n padding: 2px 4px;\n background: rgba(31, 35, 41, 0.7);\n color: #fff;\n font-size: 10px;\n line-height: 14px;\n text-align: center;\n}\n\n#fqa-search .fqa-sr-body {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n min-width: 0;\n}\n\n#fqa-search .fqa-sr-title {\n color: var(--fqa-text);\n font-size: 16px;\n font-weight: 600;\n line-height: 24px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* 接口下发的高亮片段 */\n#fqa-search .fqa-sr-title em {\n color: var(--fqa-accent);\n font-style: normal;\n}\n\n#fqa-search .fqa-sr-author {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 3px;\n color: var(--fqa-text-sub);\n font-size: 13px;\n line-height: 20px;\n}\n\n#fqa-search .fqa-sr-author > span:first-child {\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n}\n\n#fqa-search .fqa-sr-score {\n flex: 0 0 auto;\n color: var(--fqa-accent);\n font-weight: 500;\n}\n\n/* 无评分时弱化,不跟真实分数抢注意力 */\n#fqa-search .fqa-sr-score-none {\n color: var(--fqa-text-weak);\n font-weight: 400;\n}\n\n#fqa-search .fqa-sr-summary {\n display: -webkit-box;\n margin-top: 6px;\n color: var(--fqa-text-sub);\n font-size: 13px;\n line-height: 20px;\n -webkit-line-clamp: 2;\n line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n}\n\n#fqa-search .fqa-sr-meta {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 6px;\n margin-top: 8px;\n}\n\n#fqa-search .fqa-sr-tag {\n padding: 1px 7px;\n border-radius: 4px;\n background: var(--fqa-hover);\n color: var(--fqa-text-weak);\n font-size: 12px;\n line-height: 18px;\n}\n\n#fqa-search .fqa-sr-read {\n color: var(--fqa-text-weak);\n font-size: 12px;\n line-height: 18px;\n}\n\n#fqa-search .fqa-sr-update {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 6px;\n color: var(--fqa-text-weak);\n font-size: 12px;\n line-height: 18px;\n}\n\n#fqa-search .fqa-sr-chapter {\n min-width: 0;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n}\n\n#fqa-search .fqa-sr-time {\n flex: 0 0 auto;\n}\n\n@media (max-width: 600px) {\n #fqa-search .fqa-sr-cover {\n flex-basis: 72px;\n width: 72px;\n }\n\n #fqa-search .fqa-sr-summary {\n -webkit-line-clamp: 3;\n line-clamp: 3;\n }\n}\n\n/* -------------------------------- 落地页 -------------------------------- */\n\n#fqa-search .fqa-s-sec {\n margin-bottom: 28px;\n}\n\n#fqa-search .fqa-s-sec-title {\n margin-bottom: 12px;\n font-size: 16px;\n font-weight: 600;\n line-height: 24px;\n}\n\n#fqa-search .fqa-s-words {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n#fqa-search .fqa-s-word {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n padding: 6px 14px;\n border: 0;\n border-radius: 999px;\n background: var(--fqa-hover);\n color: var(--fqa-text-sub);\n font-size: 13px;\n font-family: inherit;\n line-height: 20px;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n}\n\n#fqa-search .fqa-s-word:hover {\n background: rgba(255, 111, 61, 0.1);\n color: var(--fqa-accent);\n}\n\n#fqa-search .fqa-s-word-tag {\n color: var(--fqa-text-weak);\n font-size: 11px;\n}\n\n/* 「荐」「热」这类角标 */\n#fqa-search .fqa-s-word-label {\n padding: 0 4px;\n border-radius: 3px;\n background: var(--fqa-accent);\n color: #fff;\n font-size: 10px;\n line-height: 15px;\n}\n\n#fqa-search .fqa-s-sugs {\n display: grid;\n grid-template-columns: repeat(6, minmax(0, 1fr));\n gap: 20px 16px;\n}\n\n@media (max-width: 900px) {\n #fqa-search .fqa-s-sugs {\n grid-template-columns: repeat(4, minmax(0, 1fr));\n }\n}\n\n@media (max-width: 600px) {\n #fqa-search .fqa-s-sugs {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n }\n}\n\n#fqa-search .fqa-s-sug {\n cursor: pointer;\n outline: none;\n}\n\n#fqa-search .fqa-s-sug-cover {\n display: block;\n width: 100%;\n aspect-ratio: 3 / 4;\n border-radius: 6px;\n object-fit: cover;\n background: var(--fqa-skeleton);\n transition: transform 0.2s ease, box-shadow 0.2s ease;\n}\n\n#fqa-search .fqa-s-sug:hover .fqa-s-sug-cover {\n transform: translateY(-4px);\n box-shadow: var(--fqa-shadow);\n}\n\n#fqa-search .fqa-s-sug-title {\n margin-top: 7px;\n font-size: 13px;\n line-height: 19px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n#fqa-search .fqa-s-sug-sub {\n margin-top: 2px;\n color: var(--fqa-text-weak);\n font-size: 12px;\n line-height: 18px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n#fqa-search .fqa-s-landing-sk {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n#fqa-search .fqa-sk-chip {\n width: 84px;\n height: 32px;\n border-radius: 999px;\n background: var(--fqa-skeleton);\n}\n\n/* ------------------------------ 骨架 / 状态 ------------------------------ */\n\n#fqa-search .fqa-sk-cover {\n flex: 0 0 96px;\n width: 96px;\n aspect-ratio: 3 / 4;\n border-radius: 6px;\n background: var(--fqa-skeleton);\n}\n\n#fqa-search .fqa-sk-line {\n height: 12px;\n margin-bottom: 9px;\n border-radius: 4px;\n background: var(--fqa-skeleton);\n}\n\n#fqa-search .fqa-sk-anim {\n position: relative;\n overflow: hidden;\n}\n\n#fqa-search .fqa-sk-anim::after {\n content: '';\n position: absolute;\n inset: 0;\n transform: translateX(-100%);\n background: linear-gradient(90deg, transparent, var(--fqa-skeleton-hl), transparent);\n animation: fqa-shimmer 1.4s infinite;\n}\n\n#fqa-search .fqa-s-status {\n padding: 72px 16px;\n text-align: center;\n color: var(--fqa-text-weak);\n font-size: 14px;\n line-height: 22px;\n}\n\n#fqa-search .fqa-s-status-title {\n margin-bottom: 8px;\n color: var(--fqa-text);\n font-size: 16px;\n font-weight: 500;\n}\n\n#fqa-search .fqa-s-status .fqa-s-submit {\n margin-top: 16px;\n}\n\n#fqa-search .fqa-s-loadmore {\n padding: 24px 0;\n text-align: center;\n color: var(--fqa-text-weak);\n font-size: 13px;\n}\n\n#fqa-search .fqa-s-inline-error {\n padding: 12px 0;\n text-align: center;\n color: #f5222d;\n font-size: 13px;\n}\n\n#fqa-search .fqa-s-privacy {\n margin-top: 24px;\n padding-top: 16px;\n border-top: 1px solid var(--fqa-border);\n color: var(--fqa-text-weak);\n font-size: 12px;\n line-height: 18px;\n text-align: center;\n}\n\n/* ------------------------------- 深色模式 ------------------------------- */\n\n@media (prefers-color-scheme: dark) {\n #fqa-search {\n --fqa-text: #e6e6e6;\n --fqa-text-sub: #a6a6a6;\n --fqa-text-weak: #7a7a7a;\n --fqa-border: rgba(255, 255, 255, 0.1);\n --fqa-hover: rgba(255, 255, 255, 0.06);\n --fqa-skeleton: rgba(255, 255, 255, 0.08);\n --fqa-skeleton-hl: rgba(255, 255, 255, 0.14);\n --fqa-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);\n --fqa-card-bg: #212125;\n }\n}\n"; const CONTAINER_ID$1 = "fqa-search-root"; const STYLE_ID$2 = "fqa-search-style"; const RESULT_SELECTOR = ".muye-search"; const NOTFOUND_SELECTOR = ".muye-undefined"; const ORIGIN_SELECTOR = `${RESULT_SELECTOR}, ${NOTFOUND_SELECTOR}`; let app$1 = null; let container$1 = null; let observer$1 = null; let stopTitleWatch = null; function injectStyle$2() { if (document.getElementById(STYLE_ID$2)) return; const style = document.createElement("style"); style.id = STYLE_ID$2; style.textContent = searchcss; document.head.appendChild(style); } function isSearchPath(path) { return path === "/search" || path === "/search/" || path.startsWith("/search/"); } function hideOrigin(root = document) { root.querySelectorAll(ORIGIN_SELECTOR).forEach((el) => { if (el.id === CONTAINER_ID$1 || el.closest(`#${CONTAINER_ID$1}`)) return; el.classList.add("fqa-hide"); }); } function unmount() { observer$1 == null ? void 0 : observer$1.disconnect(); observer$1 = null; stopTitleWatch == null ? void 0 : stopTitleWatch(); stopTitleWatch = null; app$1 == null ? void 0 : app$1.unmount(); app$1 = null; container$1 == null ? void 0 : container$1.remove(); container$1 = null; document.querySelectorAll(ORIGIN_SELECTOR).forEach((el) => { el.classList.remove("fqa-hide"); }); } async function mainHook$1(_previous) { const path = window.location.pathname; if (!isSearchPath(path) || !settings$1.enhanceSearch) { unmount(); return; } syncFromUrl(path); if (app$1) { hideOrigin(); return; } injectStyle$2(); const origin = await waitForElement(ORIGIN_SELECTOR, 8e3); if (!isSearchPath(window.location.pathname)) return; if (app$1) return; hideOrigin(); mount(origin); observer$1 = new MutationObserver((mutations) => { var _a; for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (!(node instanceof HTMLElement)) continue; if (node.id === CONTAINER_ID$1 || node.closest(`#${CONTAINER_ID$1}`)) continue; if ((_a = node.matches) == null ? void 0 : _a.call(node, ORIGIN_SELECTOR)) { node.classList.add("fqa-hide"); } else { hideOrigin(node); } } } }); observer$1.observe(document.body, { childList: true, subtree: true }); } function mount(origin) { container$1 = document.createElement("div"); container$1.id = CONTAINER_ID$1; if (origin == null ? void 0 : origin.parentElement) { origin.insertAdjacentElement("beforebegin", container$1); } else { const anchor = document.querySelector("#root") ?? document.body; anchor.appendChild(container$1); } app$1 = vue.createApp(_sfc_main$1); app$1.config.errorHandler = (err, _instance, info) => { console.error(`[fqa:search] Vue error (${info}):`, err); }; app$1.mount(container$1); stopTitleWatch = vue.watch( routeQuery, (q) => { document.title = q ? `${q} - 搜索 - 番茄小说` : "搜索 - 番茄小说"; }, { immediate: true } ); console.log("[fqa:search] 搜索视图已挂载:", routeQuery.value || "(落地页)"); } function filter$1(path, _query, _hash) { return isSearchPath(path) || !!app$1; } async function overloadTitle(_previous) { const searchKey = routeQuery.value || "搜索"; document.title = `${searchKey} - 番茄小说`; } const _exports$1 = [ { id: "searchHook_onload", event: "load", filter: filter$1, handler: mainHook$1 }, { id: "searchHook_onurlchange", event: "onUrlChange", filter: filter$1, handler: mainHook$1 }, { id: "searchHook_e", event: "enter", filter: filter$1, handler: overloadTitle } ]; const downloadcss = "/* 下载进度弹窗 */\n\n.fqa-dl-mask {\n position: fixed;\n inset: 0;\n z-index: 2147483300;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.45);\n font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,\n sans-serif;\n font-size: 14px;\n line-height: 1.6;\n}\n\n.fqa-dl-box {\n --fqa-dl-bg: #fff;\n --fqa-dl-text: #1f2329;\n --fqa-dl-sub: #8f959e;\n --fqa-dl-border: rgba(31, 35, 41, 0.1);\n --fqa-dl-accent: #ff6f3d;\n --fqa-dl-track: rgba(31, 35, 41, 0.08);\n --fqa-dl-danger: #f53f3f;\n\n box-sizing: border-box;\n width: min(420px, 90vw);\n padding: 22px 24px 18px;\n background: var(--fqa-dl-bg);\n color: var(--fqa-dl-text);\n border-radius: 12px;\n box-shadow: 0 12px 48px rgba(0, 0, 0, 0.24);\n}\n\n.fqa-dl-title {\n margin: 0 0 4px;\n font-size: 16px;\n font-weight: 600;\n}\n\n/* 正在处理的章节名,长标题截断 */\n.fqa-dl-sub {\n margin: 0 0 14px;\n min-height: 20px;\n color: var(--fqa-dl-sub);\n font-size: 12px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.fqa-dl-bar {\n height: 8px;\n border-radius: 4px;\n background: var(--fqa-dl-track);\n overflow: hidden;\n}\n\n.fqa-dl-fill {\n height: 100%;\n border-radius: 4px;\n background: var(--fqa-dl-accent);\n transition: width 0.2s ease;\n}\n\n/* 总量未知时的循环动画 */\n.fqa-dl-fill-indeterminate {\n width: 35% !important;\n animation: fqa-dl-slide 1.1s ease-in-out infinite;\n}\n\n@keyframes fqa-dl-slide {\n 0% {\n margin-left: -35%;\n }\n 100% {\n margin-left: 100%;\n }\n}\n\n.fqa-dl-meta {\n display: flex;\n justify-content: space-between;\n margin-top: 8px;\n color: var(--fqa-dl-sub);\n font-size: 12px;\n font-variant-numeric: tabular-nums;\n}\n\n.fqa-dl-error {\n margin: 12px 0 0;\n padding: 8px 10px;\n border-radius: 6px;\n background: rgba(245, 63, 63, 0.1);\n color: var(--fqa-dl-danger);\n font-size: 12px;\n word-break: break-all;\n}\n\n.fqa-dl-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n margin-top: 18px;\n}\n\n.fqa-dl-btn {\n padding: 7px 18px;\n border: 1px solid var(--fqa-dl-border);\n border-radius: 6px;\n background: var(--fqa-dl-bg);\n color: var(--fqa-dl-text);\n font-size: 13px;\n font-family: inherit;\n cursor: pointer;\n}\n\n.fqa-dl-btn:hover {\n border-color: var(--fqa-dl-accent);\n color: var(--fqa-dl-accent);\n}\n\n.fqa-dl-btn:disabled {\n color: var(--fqa-dl-sub);\n border-color: var(--fqa-dl-border);\n cursor: not-allowed;\n}\n\n.fqa-dl-btn-primary {\n border-color: var(--fqa-dl-accent);\n background: var(--fqa-dl-accent);\n color: #fff;\n}\n\n.fqa-dl-btn-primary:hover {\n background: #ff8a5f;\n border-color: #ff8a5f;\n color: #fff;\n}\n\n/**\n * 详情页注入的下载按钮。\n *\n * 原站 .info-btn / .add-bookshelf-btn 自带 `position:absolute; bottom:0` 和固定\n * 尺寸,但这两个 class 同时带页面行为(会被站点脚本当成自己的按钮),所以不能照抄。\n * 这里复刻它们的尺寸,水平位置由脚本按前面按钮的实际宽度算出来。\n */\n.fqa-dl-entry {\n position: absolute;\n bottom: 0;\n width: 150px;\n height: 40px;\n line-height: 40px;\n padding: 0;\n border-radius: 4px;\n font-size: 16px;\n text-align: center;\n}\n\n@media (prefers-color-scheme: dark) {\n .fqa-dl-box {\n --fqa-dl-bg: #23272e;\n --fqa-dl-text: #e5e6eb;\n --fqa-dl-border: rgba(255, 255, 255, 0.12);\n --fqa-dl-track: rgba(255, 255, 255, 0.12);\n }\n}\n"; const STYLE_ID$1 = "fqa-download-style"; const ENTRY_CLASS = "fqa-dl-entry"; const ANCHOR_SELECTOR = `.add-bookshelf-btn:not(.${ENTRY_CLASS}), .info-btn:not(.${ENTRY_CLASS})`; let observer = null; function injectStyle$1() { if (document.getElementById(STYLE_ID$1)) return; const style = document.createElement("style"); style.id = STYLE_ID$1; style.textContent = downloadcss; document.head.appendChild(style); } function parseBookId(path) { var _a; return ((_a = path.match(/^\/page\/(\d+)/)) == null ? void 0 : _a[1]) ?? ""; } function isPagePath(path) { return /^\/page\/\d+/.test(path); } const FUNCTIONAL_CLASSES = ["add-bookshelf-btn", "info-btn"]; const BUTTON_GAP = 10; function createButton(anchor, bookId2) { const btn = document.createElement("button"); btn.type = "button"; const classes = Array.from(anchor.classList).filter((c) => !FUNCTIONAL_CLASSES.includes(c)); btn.className = [...classes, ENTRY_CLASS].join(" "); const span = document.createElement("span"); span.textContent = "下载"; btn.appendChild(span); btn.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); void startDownload(bookId2, { format: settings$1.downloadFormat }); }); return btn; } function placeButton(btn, siblings) { let right = 0; for (const el of siblings) { if (el === btn) continue; right = Math.max(right, el.offsetLeft + el.offsetWidth); } const left = `${right + BUTTON_GAP}px`; if (btn.style.left === left) return; btn.style.position = "absolute"; btn.style.bottom = "0"; btn.style.left = left; } function inject(bookId2) { const anchors = Array.from(document.querySelectorAll(ANCHOR_SELECTOR)); if (anchors.length === 0) return; const existing = document.querySelector(`.${ENTRY_CLASS}`); if (existing) { placeButton(existing, anchors); return; } const anchor = anchors[anchors.length - 1]; if (!anchor.parentElement) return; const btn = createButton(anchor, bookId2); anchor.insertAdjacentElement("afterend", btn); placeButton(btn, anchors); } function teardown() { observer == null ? void 0 : observer.disconnect(); observer = null; document.querySelectorAll(`.${ENTRY_CLASS}`).forEach((el) => el.remove()); } async function mainHook(_previous) { const path = window.location.pathname; if (!isPagePath(path) || !settings$1.enableDownload) { teardown(); return; } const bookId2 = parseBookId(path); if (!bookId2) return; injectStyle$1(); teardown(); await waitForElement(ANCHOR_SELECTOR, 8e3); if (window.location.pathname !== path) return; inject(bookId2); observer = new MutationObserver(() => { if (window.location.pathname !== path) return; inject(bookId2); }); observer.observe(document.body, { childList: true, subtree: true }); } function filter(path, _query, _hash) { return isPagePath(path) || Boolean(observer); } const _exports = [ { id: "downloadHook_onload", event: "load", filter, handler: mainHook }, { id: "downloadHook_onurlchange", event: "onUrlChange", filter, handler: mainHook } ]; const hooks = [ ..._exports$4, ..._exports$5, ..._exports$3, ..._exports$2, ..._exports$1, ..._exports ]; async function onEvent(event, previous) { console.log(`onEvent, event: ${event}, previous: ${previous}`); const path = window.location.pathname; const hash2 = window.location.hash; const params = new URLSearchParams(window.location.search); const tasks = []; for (const hook of hooks) { if (hook.event === event && hook.filter(path, params, hash2)) { tasks.push(async () => { try { await hook.handler(previous); } catch (err) { console.error(`[hook:${hook.id}] handler failed:`, err); } }); } } console.log(`tasks: `, tasks); if (tasks.length > 0) { await Promise.allSettled(tasks.map((task) => task())); } } async function onUrlChange(previous) { return await onEvent("onUrlChange", previous); } async function onHashChange(previous) { return await onEvent("onHashChange", previous); } async function onLoad() { return await onEvent("load"); } async function onEnter() { return await onEvent("enter"); } const _hoisted_1 = { class: "fqa-dl-mask" }; const _hoisted_2 = { class: "fqa-dl-box", role: "dialog", "aria-modal": "true", "aria-label": "下载进度" }; const _hoisted_3 = { class: "fqa-dl-title" }; const _hoisted_4 = { class: "fqa-dl-sub" }; const _hoisted_5 = { class: "fqa-dl-bar" }; const _hoisted_6 = { class: "fqa-dl-meta" }; const _hoisted_7 = { key: 0 }; const _hoisted_8 = { key: 0, class: "fqa-dl-error" }; const _hoisted_9 = { class: "fqa-dl-actions" }; const _hoisted_10 = ["disabled"]; const _sfc_main = /* @__PURE__ */ vue.defineComponent({ __name: "DownloadProgress", props: { task: {} }, emits: ["close"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; const progress = vue.ref(props.task.snapshot); const stop = props.task.subscribe((p) => progress.value = p); vue.onBeforeUnmount(stop); const percent = vue.computed(() => { const { current, total } = progress.value; if (total <= 0) return 0; return Math.min(100, Math.round(current / total * 100)); }); const indeterminate = vue.computed(() => progress.value.total <= 0 && !progress.value.done); function cancel() { props.task.cancel(); } return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", _hoisted_1, [ vue.createElementVNode("div", _hoisted_2, [ vue.createElementVNode("h3", _hoisted_3, vue.toDisplayString(progress.value.title), 1), vue.createElementVNode("p", _hoisted_4, vue.toDisplayString(progress.value.subtitle), 1), vue.createElementVNode("div", _hoisted_5, [ vue.createElementVNode("div", { class: vue.normalizeClass(["fqa-dl-fill", { "fqa-dl-fill-indeterminate": indeterminate.value }]), style: vue.normalizeStyle({ width: `${percent.value}%` }) }, null, 6) ]), vue.createElementVNode("div", _hoisted_6, [ vue.createElementVNode("span", null, vue.toDisplayString(indeterminate.value ? "" : `${percent.value}%`), 1), progress.value.total > 0 && !progress.value.percentOnly ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_7, vue.toDisplayString(progress.value.current) + "/" + vue.toDisplayString(progress.value.total), 1)) : vue.createCommentVNode("", true) ]), progress.value.error ? (vue.openBlock(), vue.createElementBlock("p", _hoisted_8, vue.toDisplayString(progress.value.error), 1)) : vue.createCommentVNode("", true), vue.createElementVNode("div", _hoisted_9, [ !progress.value.done ? (vue.openBlock(), vue.createElementBlock("button", { key: 0, class: "fqa-dl-btn", disabled: progress.value.cancelled, onClick: cancel }, vue.toDisplayString(progress.value.cancelled ? "正在停止…" : "取消"), 9, _hoisted_10)) : vue.createCommentVNode("", true), progress.value.cancelled && !progress.value.done ? (vue.openBlock(), vue.createElementBlock("button", { key: 1, class: "fqa-dl-btn", onClick: _cache[0] || (_cache[0] = ($event) => emit("close")) }, " 关闭 ")) : vue.createCommentVNode("", true), progress.value.done ? (vue.openBlock(), vue.createElementBlock("button", { key: 2, class: "fqa-dl-btn fqa-dl-btn-primary", onClick: _cache[1] || (_cache[1] = ($event) => emit("close")) }, " 确定 ")) : vue.createCommentVNode("", true) ]) ]) ]); }; } }); const CONTAINER_ID = "fqa-download-root"; const STYLE_ID = "fqa-download-style"; let app = null; let container = null; function injectStyle() { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = downloadcss; document.head.appendChild(style); } function close() { app == null ? void 0 : app.unmount(); app = null; container == null ? void 0 : container.remove(); container = null; } function dismiss() { close(); clearFinishedTask(); } function open(task) { if (app) return; injectStyle(); container = document.createElement("div"); container.id = CONTAINER_ID; document.body.appendChild(container); app = vue.createApp({ render: () => vue.h(_sfc_main, { task, onClose: dismiss }) }); app.config.errorHandler = (err, _instance, info) => { console.error(`[fqa:download] Vue error (${info}):`, err); }; app.mount(container); } function initDownloadPanel() { onTaskChange((task) => { if (task) open(task); else close(); }); } const win = unsafeWindow; let previousUrl = win.location.href; let previousHash = win.location.hash; function installNavigationHooks() { for (const method of ["pushState", "replaceState"]) { const original = win.history[method]; win.history[method] = function(...args) { const result = original.apply(this, args); console.debug(`history.${method} called with args:`, args); void onUrlChange(previousUrl); previousUrl = win.location.href; return result; }; } win.addEventListener("popstate", () => { void onUrlChange(previousUrl); previousUrl = win.location.href; }); win.addEventListener("hashchange", () => { void onHashChange(previousHash); previousHash = win.location.hash; }); } async function mainInit() { console.log(`================================================`); console.log(`== ${name} - ${version} ==`); console.log(`================================================`); installNavigationHooks(); void onEnter(); initFontDecrypt(); await inject$1(); initUserStyle(); initDownloadPanel(); await ensureDevice(); await init(); void onLoad(); } mainInit(); })(Vue, moment, JSZip);
(点到 blk 或行内标签上也能命中), * 再拿 idx 去时间点表里查覆盖它的那一段。 */ tagIndexOfNode(node) { var _a; const paragraph = nearestParagraph(node); if (paragraph) { const idx = Number(paragraph.getAttribute("idx")); if (Number.isFinite(idx)) return this.tagIndexOfIdx(idx); } if (node instanceof Node && ((_a = titleTarget(this.tags)) == null ? void 0 : _a.contains(node))) { return this.tags.findIndex((t) => t.is_title); } return -1; } /** idx 落在哪个时间点区间。时间点可能跨多段,所以按区间判断 */ tagIndexOfIdx(idx) { return this.tags.findIndex((t) => !t.is_title && idx >= t.startidx && idx <= t.endidx); } /** 第 index 个时间点的起始秒数,供 seek 用 */ startSecondsOf(index) { const tag = this.tags[index]; return tag ? tag.startms / 1e3 : null; } } function nearestParagraph(node) { let current = node; while (current) { if (current instanceof HTMLElement && current.matches("p[idx]")) return current; if (current instanceof HTMLElement && current.id === "fqa-reader-content") return null; current = current.parentNode; } return null; } const TONE_STORE_KEY = "audiobook_tone"; const state = vue.reactive({ open: false, collapsed: false, tonePickerOpen: false, loading: false, playing: false, spinning: false, tones: [], toneId: null, toneName: "", cover: "", title: "", error: "" }); const highlighter = new ParagraphHighlighter(); let audio = null; let player = null; let itemId = ""; let bookId = ""; let trackTimer = null; let pendingTagIndex = -1; let playToken = 0; let paragraphClickBound = false; const onParagraphClick = (event) => { if (!state.open || state.toneId === null) return; const target = event.target; if (target instanceof HTMLElement && target.closest("a, button, sup")) return; const index = highlighter.tagIndexOfNode(target); if (index < 0) return; const seconds = highlighter.startSecondsOf(index); if (seconds === null || !audio) return; audio.currentTime = seconds; highlighter.applyTag(index, false); if (!state.playing) void togglePlay(); }; function bindParagraphClick() { if (paragraphClickBound) return; document.addEventListener("click", onParagraphClick); paragraphClickBound = true; } function unbindParagraphClick() { if (!paragraphClickBound) return; document.removeEventListener("click", onParagraphClick); paragraphClickBound = false; } function ensurePlayer() { if (player) return player; audio = document.createElement("audio"); audio.preload = "none"; audio.style.display = "none"; document.body.appendChild(audio); player = new CencAudioPlayer(audio, { // 音频 CDN 不在页面 CSP 的 connect-src 里,页面 fetch 会被 report-only // 策略上报到 mon.zijieapi.com。走 GM 通道绕开上报,详见 gmFetch fetch: gmAudioFetch, onStateChange: onPlayerState, onMessage: (message, level) => { if (level === "error") { console.error("[fqa:audio]", message); state.error = message; } else { console.log("[fqa:audio]", message); } } }); return player; } function onPlayerState(playerState) { state.playing = playerState === "playing"; state.spinning = playerState === "playing" || playerState === "loading"; if (playerState === "playing") state.error = ""; if (playerState === "playing") startTracking(); else stopTracking(); if (playerState === "ended") { highlighter.clear(); onChapterEnd(); } } const NEXT_CHAPTER_SELECTOR = "div.chapter-btn.next"; function onChapterEnd() { var _a; if (settings$1.audiobookChapterEnd !== "next") { state.spinning = false; return; } const next = (_a = document.querySelector(NEXT_CHAPTER_SELECTOR)) == null ? void 0 : _a.firstChild; if (typeof (next == null ? void 0 : next.click) !== "function") { state.spinning = false; state.error = "已经是最后一章"; return; } state.spinning = true; const before = itemId; next.click(); unsafeWindow.setTimeout(() => { var _a2; if (!state.open || itemId !== before) return; const current = ((_a2 = window.location.pathname.split("/").pop()) == null ? void 0 : _a2.substring(0, 19)) || ""; if (current && current !== before) { void switchChapter(current, { cover: state.cover, title: state.title }); } else { state.spinning = false; state.error = "自动切章失败"; } }, 3e3); } function startTracking() { if (trackTimer !== null) return; trackTimer = unsafeWindow.setInterval(() => { if (!audio) return; highlighter.update(audio.currentTime * 1e3, settings$1.audiobookFollow); }, 120); } function stopTracking() { if (trackTimer === null) return; unsafeWindow.clearInterval(trackTimer); trackTimer = null; } function storedToneId() { const raw = read(TONE_STORE_KEY); return typeof raw === "number" ? raw : null; } async function openAudiobook(chapter, book, meta) { itemId = chapter; bookId = book; state.cover = meta.cover; state.title = meta.title; state.error = ""; state.open = true; state.collapsed = false; if (state.tones.length === 0) { try { state.tones = await getBookAvailableTones(bookId); } catch (e) { state.error = "取音色列表失败"; console.error("[fqa:audio] 取音色列表失败:", e); return; } } if (state.tones.length === 0) { state.error = "这本书没有可用音色"; return; } const remembered = storedToneId(); if (remembered !== null && state.tones.some((t) => t.id === remembered)) { await selectTone(remembered); return; } state.tonePickerOpen = true; } async function selectTone(toneId) { state.tonePickerOpen = false; const tone = state.tones.find((t) => t.id === toneId); state.toneId = toneId; state.toneName = (tone == null ? void 0 : tone.name) ?? ""; write(TONE_STORE_KEY, toneId); await playCurrent(); } function openTonePicker() { state.tonePickerOpen = true; } function closeTonePicker() { state.tonePickerOpen = false; if (state.toneId === null) closeAudiobook(); } async function changeTone(toneId) { if (toneId === state.toneId) { state.tonePickerOpen = false; return; } pendingTagIndex = highlighter.currentTagIndex; await selectTone(toneId); } async function playCurrent() { const toneId = state.toneId; if (toneId === null || !itemId) return; const token = ++playToken; const chapter = itemId; state.loading = true; state.spinning = true; state.error = ""; try { const [contexts, tags] = await Promise.all([ getPlayInfo(chapter, toneId), getChapterParagraphTimeTag(chapter, toneId) ]); if (token !== playToken) return; const context = contexts.find((c) => c.item_id === chapter) ?? contexts[0]; const url = context == null ? void 0 : context.urls[0]; if (!context || !url) { state.error = "这一章没有音频"; return; } highlighter.reset(tags); bindParagraphClick(); const instance = ensurePlayer(); await instance.start({ url, key: context.key }); if (token !== playToken) return; if (pendingTagIndex >= 0) { const tag = tags[pendingTagIndex]; if (tag && audio) { audio.currentTime = tag.startms / 1e3; highlighter.applyTag(pendingTagIndex, true); } pendingTagIndex = -1; } } catch (e) { const message = e instanceof Error ? e.message : String(e); state.error = message; console.error("[fqa:audio] 播放失败:", e); } finally { state.loading = false; if (!state.playing) state.spinning = false; } } async function togglePlay() { if (!player) return; if (state.playing) { player.pause(); return; } if (player.currentState === "ended" && audio) audio.currentTime = 0; try { await player.play(); } catch (e) { console.warn("[fqa:audio] play() 被拒:", e); } } function toggleCollapsed() { state.collapsed = !state.collapsed; } async function switchChapter(chapter, meta) { if (!state.open || state.toneId === null) return; itemId = chapter; state.cover = meta.cover; state.title = meta.title; pendingTagIndex = -1; await playCurrent(); } function refreshParagraphs() { if (state.open) highlighter.refresh(); } function closeAudiobook() { stopTracking(); unbindParagraphClick(); playToken++; highlighter.clear(); player == null ? void 0 : player.stop(); state.open = false; state.collapsed = false; state.tonePickerOpen = false; state.playing = false; state.spinning = false; state.loading = false; state.error = ""; } const _hoisted_1$b = { class: "fqa-tone-box", role: "dialog", "aria-modal": "true", "aria-label": "选择音色" }; const _hoisted_2$b = { class: "fqa-tone-title" }; const _hoisted_3$b = { class: "fqa-tone-list" }; const _hoisted_4$a = ["onClick"]; const _hoisted_5$a = ["src", "alt"]; const _hoisted_6$a = { key: 1, class: "fqa-tone-icon fqa-tone-icon-empty" }; const _hoisted_7$8 = { class: "fqa-tone-text" }; const _hoisted_8$8 = { class: "fqa-tone-name" }; const _hoisted_9$8 = { key: 0, class: "fqa-tone-gender" }; const _hoisted_10$8 = { key: 0, class: "fqa-tone-desc" }; const _hoisted_11$6 = { class: "fqa-tone-actions" }; const _sfc_main$b = /* @__PURE__ */ vue.defineComponent({ __name: "TonePicker", props: { tones: {}, current: {} }, emits: ["select", "close"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; function genderLabel(gender) { if (gender === 1) return "男声"; if (gender === 2) return "女声"; return ""; } return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", { class: "fqa-tone-mask", onClick: _cache[1] || (_cache[1] = vue.withModifiers(($event) => emit("close"), ["self"])) }, [ vue.createElementVNode("div", _hoisted_1$b, [ vue.createElementVNode("h3", _hoisted_2$b, vue.toDisplayString(props.current === null ? "选择音色" : "切换音色"), 1), _cache[2] || (_cache[2] = vue.createElementVNode("p", { class: "fqa-tone-sub" }, "切换后会从当前段落开头继续", -1)), vue.createElementVNode("div", _hoisted_3$b, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(props.tones, (tone) => { return vue.openBlock(), vue.createElementBlock("button", { key: tone.id, type: "button", class: vue.normalizeClass(["fqa-tone-item", { "fqa-tone-item-active": tone.id === props.current }]), onClick: ($event) => emit("select", tone.id) }, [ tone.icon ? (vue.openBlock(), vue.createElementBlock("img", { key: 0, class: "fqa-tone-icon", src: tone.icon, alt: tone.name }, null, 8, _hoisted_5$a)) : (vue.openBlock(), vue.createElementBlock("span", _hoisted_6$a, vue.toDisplayString(tone.name.slice(0, 1)), 1)), vue.createElementVNode("span", _hoisted_7$8, [ vue.createElementVNode("span", _hoisted_8$8, [ vue.createTextVNode(vue.toDisplayString(tone.name) + " ", 1), genderLabel(tone.gender) ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_9$8, vue.toDisplayString(genderLabel(tone.gender)), 1)) : vue.createCommentVNode("", true) ]), tone.description ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_10$8, vue.toDisplayString(tone.description), 1)) : vue.createCommentVNode("", true) ]) ], 10, _hoisted_4$a); }), 128)) ]), vue.createElementVNode("div", _hoisted_11$6, [ vue.createElementVNode("button", { type: "button", class: "fqa-tone-btn", onClick: _cache[0] || (_cache[0] = ($event) => emit("close")) }, "取消") ]) ]) ]); }; } }); const _hoisted_1$a = ["innerHTML"]; const _hoisted_2$a = { key: 1, class: "fqa-audio-bar" }; const _hoisted_3$a = ["title", "aria-label"]; const _hoisted_4$9 = ["src"]; const _hoisted_5$9 = { key: 1, class: "fqa-audio-cover-empty" }; const _hoisted_6$9 = ["innerHTML"]; const _hoisted_7$7 = { class: "fqa-audio-meta" }; const _hoisted_8$7 = { class: "fqa-audio-title" }; const _hoisted_9$7 = { class: "fqa-audio-sub" }; const _hoisted_10$7 = ["innerHTML"]; const _sfc_main$a = /* @__PURE__ */ vue.defineComponent({ __name: "AudioBar", setup(__props) { function onSelect(id) { if (state.toneId === null) void selectTone(id); else void changeTone(id); } return (_ctx, _cache) => { return vue.unref(state).open ? (vue.openBlock(), vue.createElementBlock("div", { key: 0, class: vue.normalizeClass(["fqa-audio-root", { "fqa-audio-collapsed": vue.unref(state).collapsed }]) }, [ vue.unref(state).collapsed ? (vue.openBlock(), vue.createElementBlock("button", { key: 0, type: "button", class: "fqa-audio-expand", title: "展开听书栏", "aria-label": "展开听书栏", onClick: _cache[0] || (_cache[0] = //@ts-ignore (...args) => vue.unref(toggleCollapsed) && vue.unref(toggleCollapsed)(...args)) }, [ vue.createElementVNode("span", { class: "fqa-audio-icon fqa-audio-icon-flip", innerHTML: vue.unref(leftIcon) }, null, 8, _hoisted_1$a) ])) : (vue.openBlock(), vue.createElementBlock("div", _hoisted_2$a, [ vue.createElementVNode("button", { type: "button", class: "fqa-audio-cover", title: vue.unref(state).playing ? "暂停" : "播放", "aria-label": vue.unref(state).playing ? "暂停" : "播放", onClick: _cache[1] || (_cache[1] = //@ts-ignore (...args) => vue.unref(togglePlay) && vue.unref(togglePlay)(...args)) }, [ vue.createElementVNode("span", { class: vue.normalizeClass(["fqa-audio-disc", { "fqa-audio-spin": vue.unref(state).spinning }]) }, [ vue.unref(state).cover ? (vue.openBlock(), vue.createElementBlock("img", { key: 0, src: vue.unref(state).cover, alt: "" }, null, 8, _hoisted_4$9)) : (vue.openBlock(), vue.createElementBlock("span", _hoisted_5$9, "听")) ], 2), vue.createElementVNode("span", { class: "fqa-audio-state", innerHTML: vue.unref(state).playing ? vue.unref(playingIcon) : vue.unref(pausedIcon) }, null, 8, _hoisted_6$9) ], 8, _hoisted_3$a), vue.createElementVNode("div", _hoisted_7$7, [ vue.createElementVNode("span", _hoisted_8$7, vue.toDisplayString(vue.unref(state).title), 1), vue.createElementVNode("span", _hoisted_9$7, vue.toDisplayString(vue.unref(state).error || (vue.unref(state).loading ? "缓冲中…" : vue.unref(state).toneName)), 1) ]), vue.createElementVNode("button", { type: "button", class: "fqa-audio-btn", title: "切换音色", "aria-label": "切换音色", onClick: _cache[2] || (_cache[2] = //@ts-ignore (...args) => vue.unref(openTonePicker) && vue.unref(openTonePicker)(...args)) }, [..._cache[5] || (_cache[5] = [ vue.createElementVNode("span", { class: "fqa-audio-dots" }, null, -1) ])]), vue.createElementVNode("button", { type: "button", class: "fqa-audio-btn", title: "收起", "aria-label": "收起听书栏", onClick: _cache[3] || (_cache[3] = //@ts-ignore (...args) => vue.unref(toggleCollapsed) && vue.unref(toggleCollapsed)(...args)) }, [ vue.createElementVNode("span", { class: "fqa-audio-icon", innerHTML: vue.unref(leftIcon) }, null, 8, _hoisted_10$7) ]), vue.createElementVNode("button", { type: "button", class: "fqa-audio-btn fqa-audio-close", title: "关闭听书", "aria-label": "关闭听书", onClick: _cache[4] || (_cache[4] = //@ts-ignore (...args) => vue.unref(closeAudiobook) && vue.unref(closeAudiobook)(...args)) }, " × ") ])), vue.unref(state).tonePickerOpen ? (vue.openBlock(), vue.createBlock(_sfc_main$b, { key: 2, tones: vue.unref(state).tones, current: vue.unref(state).toneId, onSelect, onClose: vue.unref(closeTonePicker) }, null, 8, ["tones", "current", "onClose"])) : vue.createCommentVNode("", true) ], 2)) : vue.createCommentVNode("", true); }; } }); const audiobookcss = `/* 听书悬浮栏与音色弹窗。 * 暗色跟随页面的 div.muye-reader-dark(与 userHook 的判定一致)。 */ .fqa-audio-root { --fqa-audio-bg: #fff; --fqa-audio-text: #1f2329; --fqa-audio-sub: #8f959e; --fqa-audio-hover: rgba(31, 35, 41, 0.06); --fqa-audio-border: rgba(31, 35, 41, 0.1); --fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.16); position: fixed; left: 20px; bottom: 20px; z-index: 2147483000; font-size: 14px; color: var(--fqa-audio-text); } /* * 暗色反色。悬浮栏挂在 body 下,够不到 .muye-reader-dark 的后代选择器, * 所以 audioPanel 在 #fqa-audio-root 容器上打 .fqa-audio-dark 类; * 真正的 .fqa-audio-root(Vue 根)是容器的子节点,用后代选择器作用到它。 */ .fqa-audio-dark .fqa-audio-root { --fqa-audio-bg: #2b2b2b; --fqa-audio-text: #b3b3b3; --fqa-audio-sub: #7a7a7a; --fqa-audio-hover: rgba(255, 255, 255, 0.08); --fqa-audio-border: rgba(255, 255, 255, 0.12); --fqa-audio-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); } .fqa-audio-bar { display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 8px; border-radius: 999px; background: var(--fqa-audio-bg); box-shadow: var(--fqa-audio-shadow); } /* ------------------------------- 旋转封面 ------------------------------- */ .fqa-audio-cover { position: relative; flex: 0 0 auto; width: 44px; height: 44px; padding: 0; border: none; border-radius: 50%; background: var(--fqa-audio-hover); cursor: pointer; } /* * 只有这一层转。状态图标是 .fqa-audio-state,放在旋转层外面, * 否则会跟着封面一起转,反而更看不清。 * 播放/缓冲时转,暂停时停在当前角度(animation-play-state 比移除动画更平滑) */ .fqa-audio-disc { position: absolute; inset: 0; border-radius: 50%; overflow: hidden; animation: fqa-audio-rotate 8s linear infinite; animation-play-state: paused; } .fqa-audio-disc.fqa-audio-spin { animation-play-state: running; } .fqa-audio-disc img { display: block; width: 100%; height: 100%; object-fit: cover; } .fqa-audio-cover-empty { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: var(--fqa-audio-sub); font-size: 18px; } /* 播放/暂停状态。压一层遮罩,浅色封面上也看得清 */ .fqa-audio-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: rgba(0, 0, 0, 0.38); color: #fff; transition: background 0.2s ease; } .fqa-audio-cover:hover .fqa-audio-state { background: rgba(0, 0, 0, 0.55); } /* * 只给尺寸,不写 fill:paused.svg 里那块透明点击区是 fill="none", * CSS 的 fill 优先级高于表现属性,会把它填成一个白方块盖住图标。 * 两个 svg 自身已经用 currentColor,跟着上面的 color 走。 */ .fqa-audio-state svg { width: 18px; height: 18px; } @keyframes fqa-audio-rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .fqa-audio-disc { animation: none; } } /* -------------------------------- 文字区 -------------------------------- */ .fqa-audio-meta { display: flex; flex-direction: column; justify-content: center; min-width: 0; max-width: 180px; line-height: 1.35; } .fqa-audio-title, .fqa-audio-sub { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .fqa-audio-sub { color: var(--fqa-audio-sub); font-size: 12px; } /* -------------------------------- 按钮 -------------------------------- */ .fqa-audio-btn, .fqa-audio-expand { display: flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 32px; height: 32px; padding: 0; border: none; border-radius: 50%; background: transparent; color: var(--fqa-audio-text); cursor: pointer; } .fqa-audio-btn:hover, .fqa-audio-expand:hover { background: var(--fqa-audio-hover); } .fqa-audio-close { font-size: 20px; line-height: 1; } /* svg 用 currentColor,跟着按钮的 color 走 */ .fqa-audio-icon { display: flex; align-items: center; justify-content: center; width: 16px; height: 16px; } .fqa-audio-icon svg { width: 100%; height: 100%; fill: currentColor; } /* 收起后箭头水平翻转朝右 */ .fqa-audio-icon-flip { transform: scaleX(-1); } .fqa-audio-expand { background: var(--fqa-audio-bg); box-shadow: var(--fqa-audio-shadow); width: 36px; height: 36px; } /* 三个点 */ .fqa-audio-dots, .fqa-audio-dots::before, .fqa-audio-dots::after { width: 4px; height: 4px; border-radius: 50%; background: currentColor; } .fqa-audio-dots { position: relative; } .fqa-audio-dots::before, .fqa-audio-dots::after { content: ''; position: absolute; top: 0; } .fqa-audio-dots::before { left: -7px; } .fqa-audio-dots::after { left: 7px; } /* ----------------------------- 段落高亮 ----------------------------- */ .fqa-audio-active { background: var(--web-brand_light, rgba(241, 70, 70, 0.12)); border-radius: 4px; transition: background 0.2s ease; } /* ----------------------------- 音色弹窗 ----------------------------- */ .fqa-tone-mask { position: fixed; inset: 0; z-index: 2147483002; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.45); } .fqa-tone-box { width: min(420px, calc(100vw - 32px)); max-height: min(560px, calc(100vh - 64px)); display: flex; flex-direction: column; padding: 20px; border-radius: 12px; background: var(--fqa-audio-bg, #fff); color: var(--fqa-audio-text, #1f2329); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.24); } .fqa-tone-title { margin: 0 0 4px; font-size: 17px; font-weight: 600; } .fqa-tone-sub { margin: 0 0 14px; color: var(--fqa-audio-sub, #8f959e); font-size: 12px; } .fqa-tone-list { flex: 1 1 auto; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; } .fqa-tone-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: inherit; text-align: left; cursor: pointer; } .fqa-tone-item:hover { background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); } .fqa-tone-item-active { border-color: var(--web-brand, #f14646); } .fqa-tone-icon { flex: 0 0 auto; width: 36px; height: 36px; border-radius: 50%; object-fit: cover; } .fqa-tone-icon-empty { display: flex; align-items: center; justify-content: center; background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); color: var(--fqa-audio-sub, #8f959e); } .fqa-tone-text { display: flex; flex-direction: column; min-width: 0; } .fqa-tone-name { display: flex; align-items: center; gap: 6px; font-size: 14px; } .fqa-tone-gender { color: var(--fqa-audio-sub, #8f959e); font-size: 11px; } .fqa-tone-desc { overflow: hidden; color: var(--fqa-audio-sub, #8f959e); font-size: 12px; white-space: nowrap; text-overflow: ellipsis; } .fqa-tone-actions { display: flex; justify-content: flex-end; margin-top: 14px; } .fqa-tone-btn { padding: 6px 16px; border: 1px solid var(--fqa-audio-border, rgba(31, 35, 41, 0.1)); border-radius: 6px; background: transparent; color: inherit; cursor: pointer; } .fqa-tone-btn:hover { background: var(--fqa-audio-hover, rgba(31, 35, 41, 0.06)); } `; const CONTAINER_ID$4 = "fqa-audio-root"; const STYLE_ID$5 = "fqa-audio-style"; let app$4 = null; let container$4 = null; let themeObserver = null; function injectStyle$5() { if (document.getElementById(STYLE_ID$5)) return; const style = document.createElement("style"); style.id = STYLE_ID$5; style.textContent = audiobookcss; document.head.appendChild(style); } function syncTheme() { if (!container$4) return; const dark = document.querySelector("div.muye-reader-dark") !== null; container$4.classList.toggle("fqa-audio-dark", dark); } function watchTheme() { if (themeObserver) return; themeObserver = new MutationObserver(syncTheme); themeObserver.observe(document.body, { subtree: true, attributes: true, attributeFilter: ["class"] }); } function initAudioPanel() { if (app$4) return; injectStyle$5(); container$4 = document.createElement("div"); container$4.id = CONTAINER_ID$4; document.body.appendChild(container$4); syncTheme(); watchTheme(); app$4 = vue.createApp({ render: () => vue.h(_sfc_main$a) }); app$4.config.errorHandler = (err, _instance, info) => { console.error(`[fqa:audio] Vue error (${info}):`, err); }; app$4.mount(container$4); } const SHELF_BASE = "https://fanqienovel.com/reading/bookapi/bookshelf"; async function getBookshelf() { const responses = await Promise.all([ fetch$1(SHELF_BASE + "/info/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700"), fetch$1("https://fanqienovel.com/api/reader/book/progress") ]); const response = responses[0]; const response2 = responses[1]; const data = await response.json(); const info = data.data.book_shelf_info; const data_ = await response2.json(); const data2 = data_.data; const all_items = info.map((item) => item.book_id); const response3 = await fetch$1("https://fanqienovel.com/api/book/simple/info", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ book_ids: all_items }) }); const data__ = await response3.json(); const data3 = data__.data.bookList; const results = []; for (let i2 of data.data.book_shelf_info) { const progress = data2.find((it) => it.book_id === i2.book_id); const simple = data3.find((it) => it.book_id === i2.book_id); const item = { book_id: i2.book_id, last_operate_time: progress ? progress.read_timestamp : i2.last_operate_time, add_shelf_time: i2.add_shelf_time, group_name: i2.group_name, last_read_timestamp: progress ? progress.read_timestamp : 0, last_read_chapter_id: progress ? progress.item_id : "0", is_publish: (simple == null ? void 0 : simple.genre) === "6" }; results.push(item); } return results; } async function multidetail(books) { const response = await fetch$1("https://fanqienovel.com/api/bookshelf/multidetail", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ books: books.map((book) => { return { book_id: book.book_id, item_id: book.last_read_chapter_id }; }) }) }); const data = await response.json(); const list = data.data.detail_list; const results = []; for (let i2 of list) { const item = { book_id: i2.book_id, summary: i2.abstract, title: i2.book_name, author: i2.author_name, cover_url: i2.thumb_url, current_chapter_title: i2.item_show_title, current_chapter_id: i2.item_id, current_chapter_order: i2.real_chapter_order, total_chapter_count: i2.serial_count, last_chapter_update_time: i2.last_chapter_update_time * 1e3, last_chapter_id: i2.last_chapter_item_id, current_chapter_summary: i2.item_abstract, // last_chapter_title: i.last_chapter_show_title, status: i2.update_stop === "1" ? 4 : i2.creation_status, update_status: i2.update_status }; results.push(item); } return results; } function identify(bookId2, modifyTime = 0) { return { asterisked: false, book_id: bookId2, book_type: 0, modify_time: modifyTime }; } async function shelfPost(path, body) { const res = await fetch$1(`${SHELF_BASE}${path}/v?aid=1967`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(body) }); const json = await res.json().catch(() => null); if (!res.ok || json && json.code !== 0 && json.code !== void 0) { throw new Error(`书架操作失败(${path}): ${(json == null ? void 0 : json.message) ?? res.status}`); } return json; } async function addToBookshelf(bookId2) { await shelfPost("/add", { add_book_source: 0, identify_data: [identify(bookId2)] }); } async function removeFromBookshelf(bookId2) { await shelfPost("/delete", { identify_data: [identify(bookId2, Date.now())] }); } async function moveToGroup(bookId2, groupName) { await shelfPost("/update", { book_data: [ { asterisked: false, book_id: bookId2, book_type: 0, group_name: groupName, has_shown: false, is_pin: false, modify_time: Date.now() } ] }); } async function isInBookshelf(book_id) { const response = await fetch$1(`https://fanqienovel.com/reading/bookapi/bookshelf/check/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700&book_id=${book_id}`); const data = await response.json(); return Boolean(data.data); } function addResponseModifier(modifier) { modifiers.push(modifier); return () => { const i2 = modifiers.indexOf(modifier); if (i2 >= 0) modifiers.splice(i2, 1); }; } const modifiers = []; const blackList = [ "mcs.zijieapi.com", "vcs.zijieapi.com/vc/setting", "mon.zijieapi.com", "mssdk.bytedance.com/web/common", "hm.baidu.com" ]; const BLOCKED_BODY = JSON.stringify({ e: 0, sc: 10, tc: 10 }); function checkBlack(url) { if (!settings$1.blockReport) return false; return blackList.some((black) => url.includes(black)); } function toLocalArrayBuffer(buf) { const bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); const out = new ArrayBuffer(bytes.byteLength); new Uint8Array(out).set(bytes); return out; } function textToBody(text) { return toLocalArrayBuffer(new TextEncoder().encode(text)); } function bodyToText(body) { if (typeof body === "string") return body; return new TextDecoder().decode(body); } function bodyToBuffer(body) { if (typeof body === "string") return textToBody(body); return body; } function resolveSimple(simple) { return { ...simple, responseBody: bodyToBuffer(simple.responseBody) }; } function matchingModifiers(url) { return modifiers.filter((m) => m.matcher(url)); } function applyModifiers(url, matched, simple) { let current = simple; for (const m of matched) { if (m.modify_response) current = m.modify_response(url, current); } return current; } function responseAllowsBody(status) { return status !== 204 && status !== 205 && status !== 304; } function simpleToResponse(simple) { return new Response(responseAllowsBody(simple.statusCode) ? simple.responseBody : null, { status: simple.statusCode, headers: simple.responseHeaders }); } async function responseToSimple(res) { const body = toLocalArrayBuffer(await res.arrayBuffer()); const headers = {}; res.headers.forEach((value, key) => { headers[key] = value; }); return { responseBody: body, statusCode: res.status, responseHeaders: headers }; } const originalFetch = unsafeWindow.fetch.bind(unsafeWindow); unsafeWindow.fetch = async function fetch2(input, init2) { let url; if (input instanceof Request) { url = input.url; } else if (input instanceof URL) { url = input.href; } else { url = input; } if (checkBlack(url)) { console.log("blocked request: " + url); return new Response(BLOCKED_BODY, { status: 200, headers: { "Content-Type": "application/json" } }); } const matched = matchingModifiers(url); if (matched.length === 0) return originalFetch(input, init2); const breaker = matched.find((m) => m.should_break(url)); if (breaker == null ? void 0 : breaker.make_response) { return simpleToResponse(breaker.make_response(url)); } if (!matched.some((m) => m.modify_response)) return originalFetch(input, init2); const res = await originalFetch(input, init2); try { const simple = await responseToSimple(res.clone()); return simpleToResponse(applyModifiers(url, matched, simple)); } catch (error) { console.error("[fqa:fetch] 修改响应失败,回退原始响应:", error); return res; } }; const originalXMLHttpRequest = unsafeWindow.XMLHttpRequest; const XHR_PROTO = originalXMLHttpRequest.prototype; const XHR_DESC = { status: Object.getOwnPropertyDescriptor(XHR_PROTO, "status"), responseText: Object.getOwnPropertyDescriptor(XHR_PROTO, "responseText"), response: Object.getOwnPropertyDescriptor(XHR_PROTO, "response") }; const XHR_GETALL = XHR_PROTO.getAllResponseHeaders; const XHR_GETONE = XHR_PROTO.getResponseHeader; function readXhrHeaders(self) { const headers = {}; const all = XHR_GETALL.call(self); if (!all) return headers; for (const line of all.trim().split(/[\r\n]+/)) { const idx = line.indexOf(":"); if (idx > 0) { headers[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); } } return headers; } function readXhrBody(self) { const type = self.responseType; if (type === "arraybuffer") { return toLocalArrayBuffer(XHR_DESC.response.get.call(self)); } if (type === "json") { const parsed = XHR_DESC.response.get.call(self); return textToBody(JSON.stringify(parsed)); } return textToBody(XHR_DESC.responseText.get.call(self)); } unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpRequest { constructor() { super(...arguments); __publicField(this, "_blockedUrl"); __publicField(this, "_fxaUrl", ""); __publicField(this, "_fxaMatched", null); __publicField(this, "_fxaBreaker", null); __publicField(this, "_fxaApplied", false); __publicField(this, "_fxaSimple", null); } open(method, url, async = true, user, password) { this._fxaUrl = url; this._fxaMatched = null; this._fxaBreaker = null; this._fxaApplied = false; this._fxaSimple = null; if (checkBlack(url)) { console.log("blocked request: " + url); this._blockedUrl = url; return; } this._blockedUrl = void 0; const matched = matchingModifiers(url); const breaker = matched.find((m) => m.should_break(url)); if (breaker == null ? void 0 : breaker.make_response) { this._fxaBreaker = breaker; this._fxaMatched = matched; } else if (matched.some((m) => m.modify_response)) { this._fxaMatched = matched; } super.open(method, url, async, user, password); } setRequestHeader(name2, value) { if (this._blockedUrl !== void 0) return; super.setRequestHeader(name2, value); } send(body) { var _a, _b; if (this._blockedUrl !== void 0) { this._synthesizeFromSimple({ responseBody: textToBody(BLOCKED_BODY), statusCode: 200, responseHeaders: { "content-type": "application/json" } }); return; } if ((_a = this._fxaBreaker) == null ? void 0 : _a.make_response) { this._synthesizeFromSimple(this._fxaBreaker.make_response(this._fxaUrl)); return; } if ((_b = this._fxaMatched) == null ? void 0 : _b.length) this._armTransform(); super.send(body); } abort() { if (this._blockedUrl !== void 0) return; super.abort(); } getAllResponseHeaders() { if (this._blockedUrl !== void 0) return "content-type: application/json\r\n"; return super.getAllResponseHeaders(); } getResponseHeader(name2) { if (this._blockedUrl !== void 0) { return name2.toLowerCase() === "content-type" ? "application/json" : null; } return super.getResponseHeader(name2); } /** * 中断请求后合成一个假响应并手动派发完成事件。 * 与 fetch 的 make_response 对齐,也复用了黑名单那条原本的 setTimeout 派发逻辑。 */ _synthesizeFromSimple(raw) { const url = this._blockedUrl ?? this._fxaUrl; const simple = resolveSimple(raw); const headers = simple.responseHeaders; const shadow = (prop, value) => Object.defineProperty(this, prop, { configurable: true, get: () => value }); setTimeout(() => { const text = bodyToText(simple.responseBody); shadow("readyState", 4); shadow("status", simple.statusCode); shadow("statusText", "OK"); shadow("responseURL", url); const type = this.responseType; shadow("responseText", type === "" || type === "text" ? text : ""); let value; if (type === "json") { try { value = JSON.parse(text); } catch { value = text; } } else if (type === "arraybuffer") { value = simple.responseBody; } else if (type === "blob") { value = new Blob([simple.responseBody]); } else { value = text; } shadow("response", value); shadow( "getAllResponseHeaders", () => Object.entries(headers).map(([k, v]) => `${k}: ${v}\r `).join("") ); shadow("getResponseHeader", (name2) => { const lower = name2.toLowerCase(); const key = Object.keys(headers).find((k) => k.toLowerCase() === lower); return key ? headers[key] : null; }); this.dispatchEvent(new Event("readystatechange")); this.dispatchEvent(new ProgressEvent("load")); this.dispatchEvent(new ProgressEvent("loadend")); }, 0); } /** * 在实例上挂影子属性:等真实响应就绪(readyState=4), * 页面第一次读取时才同步套用 modify_response 并缓存结果。 * 这样不用跟页面的 onload / onreadystatechange 争先后顺序。 */ _armTransform() { const type = this.responseType; if (type === "blob" || type === "document") return; const self = this; const matched = this._fxaMatched; const url = this._fxaUrl; const ensure = () => { if (self._fxaApplied || self.readyState !== 4) return; self._fxaSimple = resolveSimple(applyModifiers(url, matched, { responseBody: readXhrBody(self), statusCode: XHR_DESC.status.get.call(self), responseHeaders: readXhrHeaders(self) })); self._fxaApplied = true; }; Object.defineProperty(this, "status", { configurable: true, get() { ensure(); if (self._fxaApplied) return self._fxaSimple.statusCode; return XHR_DESC.status.get.call(self); } }); Object.defineProperty(this, "responseText", { configurable: true, get() { ensure(); const t = self.responseType; if (!self._fxaApplied || t !== "" && t !== "text") { return XHR_DESC.responseText.get.call(self); } return bodyToText(self._fxaSimple.responseBody); } }); Object.defineProperty(this, "response", { configurable: true, get() { ensure(); if (!self._fxaApplied) return XHR_DESC.response.get.call(self); const body = self._fxaSimple.responseBody; switch (self.responseType) { case "": case "text": return bodyToText(body); case "json": return JSON.parse(bodyToText(body)); case "arraybuffer": return body; default: return XHR_DESC.response.get.call(self); } } }); this.getAllResponseHeaders = function getAllResponseHeaders() { ensure(); if (!self._fxaApplied) return XHR_GETALL.call(self); return Object.entries(self._fxaSimple.responseHeaders).map(([k, v]) => `${k}: ${v}\r `).join(""); }; this.getResponseHeader = function getResponseHeader(name2) { ensure(); if (!self._fxaApplied) return XHR_GETONE.call(self, name2); const lower = name2.toLowerCase(); const key = Object.keys(self._fxaSimple.responseHeaders).find((k) => k.toLowerCase() === lower); return key ? self._fxaSimple.responseHeaders[key] : null; }; } }; const _exports$5 = []; let userState = { isLogin: false, userInfo: null }; if (read("userState")) { userState = read("userState"); } console.log("userState:", userState); async function getDetailedUserInfo() { if (!(userState == null ? void 0 : userState.isLogin) || !(userState == null ? void 0 : userState.userInfo)) { return null; } if (userState.userInfo.gender !== void 0 && userState.userInfo.recommend_gender !== void 0 && userState.userInfo.fans_num !== void 0 && userState.userInfo.following_num !== void 0 && userState.userInfo.is_author !== void 0 && userState.userInfo.author_desc !== void 0 && userState.userInfo.read_book_num !== void 0 && userState.userInfo.read_book_time !== void 0) { return userState.userInfo; } const response = await fetch$1("https://fanqienovel.com/reading/user/basic_info/get/v?aid=1967"); const j = await response.json(); if (j == null ? void 0 : j.data) { const data = j.data; userState.userInfo.gender = data.profile_gender; userState.userInfo.recommend_gender = data.gender; userState.userInfo.fans_num = data.fans_num; userState.userInfo.following_num = data.follow_user_num; userState.userInfo.is_author = data.is_author; userState.userInfo.author_desc = data.author_desc; userState.userInfo.read_book_num = data.read_book_num; userState.userInfo.read_book_time = BigInt(data.read_book_time); return userState.userInfo; } write("userState", userState); return userState.userInfo; } async function checkLogin() { var _a, _b, _c, _d, _e; const response = await fetch$1("https://fanqienovel.com/api/user/info/v2"); const j = await response.json(); const _userInfo = { id: (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.id, username: (_b = j == null ? void 0 : j.data) == null ? void 0 : _b.name, avatar: (_c = j == null ? void 0 : j.data) == null ? void 0 : _c.avatar, desc: (_d = j == null ? void 0 : j.data) == null ? void 0 : _d.desc, age: (_e = j == null ? void 0 : j.data) == null ? void 0 : _e.age }; if (j.code !== -1) { userState.isLogin = true; userState.userInfo = _userInfo; write("userState", userState); return true; } else { del("userState"); return false; } } async function init() { var _a; await checkLogin(); if (userState.isLogin) { console.log("Hello, ", (_a = userState == null ? void 0 : userState.userInfo) == null ? void 0 : _a.username); } } let currentBook = null; let latestItemId = null; let currentChapterWithContent = null; const SCRIPT_CONTAINER_ID = "fqa-reader-content"; let comicObserver = null; addResponseModifier({ matcher: (url) => url.indexOf("/reading/bookapi/bookshelf/check/") !== -1 && location.pathname.startsWith("/reader"), should_break: () => true, make_response: () => { const body = JSON.stringify({ code: 0, message: "SUCCESS", data: 0 }); return { responseBody: body, statusCode: 200, responseHeaders: { "content-type": "application/json; charset=utf-8", "content-length": String(new TextEncoder().encode(body).length) } }; } }); function ensureScriptContainer(readerContainer, comic) { let scriptContainer = document.getElementById(SCRIPT_CONTAINER_ID); if (!scriptContainer) { scriptContainer = cloneElement(readerContainer); scriptContainer.id = SCRIPT_CONTAINER_ID; scriptContainer.classList.add("fqa"); readerContainer.insertAdjacentElement("beforebegin", scriptContainer); } scriptContainer.classList.toggle("fqa-comic-reader", comic); if (settings$1.allowCopy) scriptContainer.classList.remove("noselect"); comicObserver == null ? void 0 : comicObserver.disconnect(); comicObserver = null; scriptContainer.innerHTML = ""; readerContainer.classList.add("fqa-hide"); return scriptContainer; } async function insertContent() { var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p; const itemId2 = ((_a = window.location.pathname.split("/").pop()) == null ? void 0 : _a.substring(0, 19)) || ""; if (!itemId2) { console.warn("No item_id found in URL"); return; } latestItemId = itemId2; const chapter = await getChapter(itemId2); if (!chapter) { console.warn("No chapter found for item_id:", itemId2); return; } if (latestItemId !== itemId2) { console.debug("Stale chapter response discarded:", itemId2); return; } console.log("Chapter:", chapter); currentChapterWithContent = chapter; const pageState = unsafeWindow.__INITIAL_STATE__; const chapterTitle = ((_b = chapter.novel_data) == null ? void 0 : _b.title) || ((_d = (_c = pageState == null ? void 0 : pageState.reader) == null ? void 0 : _c.chapterData) == null ? void 0 : _d.title); if (typeof chapter.content === "string") { void applyBookCss((_e = chapter.novel_data) == null ? void 0 : _e.css_map, "#fqa-reader-content"); const dp = new DOMParser(); const doc = dp.parseFromString(chapter.content, "text/html"); const body = doc.body; body.querySelectorAll('link[rel="stylesheet"]').forEach((el) => el.remove()); let article = body.querySelector("article"); let toProcess = article || body; processFootnotes(toProcess); for (let i2 = 0; i2 < toProcess.childNodes.length; i2++) { if (i2 < 2 && ((_g = (_f = toProcess.childNodes[i2]) == null ? void 0 : _f.innerHTML) == null ? void 0 : _g.includes(chapterTitle))) { toProcess.removeChild(toProcess.childNodes[i2]); break; } } if (!article) { article = document.createElement("article"); article.innerHTML = toProcess.innerHTML; toProcess = article; } const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); if (readerContainer) { const scriptContainer = ensureScriptContainer(readerContainer, false); scriptContainer.appendChild(toProcess); bindFootnoteInteraction(scriptContainer); } } else if (chapter.content.picInfos) { if (chapter.content.encrypt) { const imgs = []; for (let i2 = 0; i2 < chapter.content.picInfos.length; i2++) { const picInfo = chapter.content.picInfos[i2]; const img = document.createElement("img"); img.className = "fqa-comic-img fqa-comic-encrypted"; img.alt = `第${i2 + 1}页`; img.dataset.encryptedUrl = picInfo.picUrl; img.dataset.encryptKey = chapter.content.encrypt_key; img.dataset.pageIndex = i2.toString(); img.style.minHeight = "500px"; img.style.backgroundColor = "#f0f0f0"; imgs.push(img); } const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); if (readerContainer) { const scriptContainer = ensureScriptContainer(readerContainer, true); imgs.forEach((img) => scriptContainer.appendChild(img)); const observer2 = new IntersectionObserver( async (entries) => { for (const entry of entries) { if (entry.isIntersecting) { const img = entry.target; if (img.dataset.encryptedUrl && img.dataset.encryptKey && !img.src) { observer2.unobserve(img); try { const encryptedBuffer = await fetchArrayBuffer( img.dataset.encryptedUrl ); const decryptedBuffer = await decryptComicImage( encryptedBuffer, img.dataset.encryptKey ); const blob = new Blob([decryptedBuffer], { type: "image/jpeg" }); const blobUrl = URL.createObjectURL(blob); img.src = blobUrl; img.style.minHeight = ""; img.style.backgroundColor = ""; img.onload = () => { URL.revokeObjectURL(blobUrl); }; } catch (error) { console.error(`解密图片失败 (页 ${img.dataset.pageIndex}):`, error); img.alt = `第${Number(img.dataset.pageIndex) + 1}页 - 解密失败`; img.style.backgroundColor = "#ffebee"; } } } } }, { rootMargin: "200px" } ); comicObserver = observer2; imgs.forEach((img) => observer2.observe(img)); } } else { const imgs = []; for (let i2 = 0; i2 < chapter.content.picInfos.length; i2++) { const picInfo = chapter.content.picInfos[i2]; const img = document.createElement("img"); img.className = "fqa-comic-img"; img.alt = `第${i2 + 1}页`; img.src = picInfo.picUrl; imgs.push(img); } const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)"); if (readerContainer) { const scriptContainer = ensureScriptContainer(readerContainer, true); imgs.forEach((img) => scriptContainer.appendChild(img)); } } } const muyeReaderTitle = document.querySelector("h1.muye-reader-title"); let muyeReaderSubtitle = document.querySelector("div.muye-reader-subtitle"); (_h = document.querySelector("#fqa-subtitle")) == null ? void 0 : _h.remove(); if (muyeReaderSubtitle) { let _cloned = cloneElement(muyeReaderSubtitle); muyeReaderSubtitle.classList.add("fqa-hide"); _cloned.id = "fqa-subtitle"; muyeReaderSubtitle.insertAdjacentElement("afterend", _cloned); muyeReaderSubtitle = _cloned; _cloned.classList.remove("fqa-hide"); console.log("clone subtitle: ", _cloned); } if (muyeReaderTitle) { muyeReaderTitle.textContent = chapterTitle; } console.log("Current book:", currentBook); if (!currentBook || currentBook == null || currentBook.book_id !== ((_i = chapter.novel_data) == null ? void 0 : _i.book_id)) { currentBook = await getBookInfoAndCatalog((_j = chapter.novel_data) == null ? void 0 : _j.book_id); console.log("Current book:", currentBook); } if (currentBook && currentBook.chapter_list) { const currentChapterItem = currentBook.chapter_list.find((c) => c.item_id === itemId2); if (currentChapterItem) { console.log("Current chapter:", currentChapterItem); document.title = currentChapterItem.title + " - " + currentBook.title + " - 番茄小说"; if (document.getElementById("fqa-current-chapter-volume")) { const c = document.getElementById("fqa-current-chapter-volume"); if (c) { c.textContent = currentChapterItem.volume_title; } } else { const volSpan = document.createElement("span"); volSpan.className = "desc-item"; volSpan.id = "fqa-current-chapter-volume"; volSpan.textContent = currentChapterItem.volume_title; const c = muyeReaderSubtitle == null ? void 0 : muyeReaderSubtitle.firstChild; if (c) { c.insertAdjacentElement("beforebegin", volSpan); } } let updateTimeSpans = (muyeReaderSubtitle == null ? void 0 : muyeReaderSubtitle.querySelectorAll("span.desc-item")) || []; console.log("spans len", updateTimeSpans.length, "assertIsOffshelf", updateTimeSpans.length < 3); if (updateTimeSpans.length >= 3) { console.log("if"); let updateTimeSpan = updateTimeSpans[updateTimeSpans.length - 1]; let uttspan = updateTimeSpan.firstChild; uttspan == null ? void 0 : uttspan.remove(); updateTimeSpan.innerHTML = "更新时间:" + currentChapterItem.update_time; } else { console.log("else"); (_k = updateTimeSpans[updateTimeSpans.length - 1]) == null ? void 0 : _k.remove(); const updateTimeSpan = document.createElement("span"); updateTimeSpan.className = "desc-item"; updateTimeSpan.textContent = `更新时间:${currentChapterItem.update_time}`; console.log("assert equal", currentChapterItem.item_id === ((_l = currentChapterWithContent == null ? void 0 : currentChapterWithContent.novel_data) == null ? void 0 : _l.item_id)); console.log("wordcnt", (_m = currentChapterWithContent == null ? void 0 : currentChapterWithContent.novel_data) == null ? void 0 : _m.chapter_word_number); const c = document.getElementById("fqa-current-chapter-volume"); let b = null; if (currentChapterItem.item_id === ((_n = currentChapterWithContent == null ? void 0 : currentChapterWithContent.novel_data) == null ? void 0 : _n.item_id)) { const wordCntSpan = document.createElement("span"); wordCntSpan.className = "desc-item"; wordCntSpan.textContent = `本章字数:${(_o = currentChapterWithContent == null ? void 0 : currentChapterWithContent.novel_data) == null ? void 0 : _o.chapter_word_number}字`; console.log(wordCntSpan); if (c) { console.log("insert wordcnt"); c.insertAdjacentElement("afterend", wordCntSpan); } b = wordCntSpan; } console.log("insert update time"); (_p = b || c) == null ? void 0 : _p.insertAdjacentElement("afterend", updateTimeSpan); } } } if (state.open) { await switchChapter(itemId2, { cover: (currentBook == null ? void 0 : currentBook.cover_url) ?? "", title: (currentBook == null ? void 0 : currentBook.title) ?? document.title }); } else { refreshParagraphs(); } } async function startAudioPlay() { var _a; if (state.open) { closeAudiobook(); return; } const itemId2 = ((_a = window.location.pathname.split("/").pop()) == null ? void 0 : _a.substring(0, 19)) || ""; if (!itemId2) return; initAudioPanel(); await openAudiobook(itemId2, (currentBook == null ? void 0 : currentBook.book_id) ?? "", { cover: (currentBook == null ? void 0 : currentBook.cover_url) ?? "", title: (currentBook == null ? void 0 : currentBook.title) ?? document.title }); } async function onUrlChange$1(_previous) { await insertContent(); } async function onHashChange$1(_previous) { } async function onLoad$1() { var _a; async function fetchBookInfo() { var _a2, _b; const pageState = unsafeWindow.__INITIAL_STATE__; const bid = (_b = (_a2 = pageState == null ? void 0 : pageState.reader) == null ? void 0 : _a2.chapterData) == null ? void 0 : _b.bookId; currentBook = await getBookInfoAndCatalog(bid); } void fetchBookInfo(); const toolbar = document.querySelector("div.reader-toolbar > div"); const toolbarButton = document.querySelector("div.reader-toolbar > div > div:nth-child(3)"); if (toolbarButton && toolbar) { const c = cloneElement(toolbarButton); c.id = "fqa-toggle-audiobook"; const listenIcon = document.createElement("span"); listenIcon.textContent = "听"; listenIcon.style.width = "24px"; listenIcon.style.height = "24px"; listenIcon.style.fontSize = "24px"; listenIcon.style.lineHeight = "24px"; listenIcon.classList.add("muyeicon-icon"); listenIcon.classList.add("reader-toolbar-item-icon"); (_a = c.firstChild) == null ? void 0 : _a.replaceWith(listenIcon); const l = c.lastChild; if (l) { l.textContent = "听书"; } c.addEventListener("click", () => void startAudioPlay()); toolbar.appendChild(c); } const bookshelfButton = document.querySelector("div.reader-toolbar-item"); if (userState.isLogin && bookshelfButton && bookshelfButton.innerHTML.includes("书架")) { const shelf = cloneElement(bookshelfButton); bookshelfButton.replaceWith(shelf); let inShelf = false; let ready = false; let busy = false; async function ensureBookshelfState() { while (!currentBook) { await sleep(50); } inShelf = await isInBookshelf(currentBook.book_id); shelf.classList.toggle("reader-toolbar-item-disabled", inShelf); const label = shelf.lastChild; if (label) label.textContent = inShelf ? "已在书架" : "加入书架"; ready = true; } void ensureBookshelfState(); shelf.addEventListener("click", async () => { const book = currentBook; if (!ready || busy || !book) return; busy = true; try { if (inShelf) { if (settings$1.shelfRemoveConfirm) { if (!unsafeWindow.confirm(`确定要把《${book.title ?? "这本书"}》从书架移出吗?`)) return; } await removeFromBookshelf(book.book_id); } else { await addToBookshelf(book.book_id); } await ensureBookshelfState(); } catch (error) { console.error("[fqa:reader] 书架操作失败:", error); unsafeWindow.alert(error instanceof Error ? error.message : "书架操作失败"); } finally { busy = false; } }); } document.querySelector("div.muye-reader-btns"); await insertContent(); } function readerFilter(path, _query, _hash) { return path.startsWith("/reader") || path.startsWith("reader"); } const _exports$4 = [ { id: "readerHook_load", event: "load", handler: onLoad$1, filter: readerFilter }, { id: "readerHook_urlChange", event: "onUrlChange", handler: onUrlChange$1, filter: readerFilter }, { id: "readerHook_hashChange", event: "onHashChange", handler: onHashChange$1, filter: readerFilter } ]; const bookshelf = '\r\n \r\n \r\n \r\n \r\n \r\n'; const settings = '\r\n\r\n \r\n \r\n'; const name = "fanqie-assistant"; const version = "0.0.6"; const _hoisted_1$9 = { class: "fqa-set-dialog", role: "dialog", "aria-modal": "true", "aria-label": "助手设置" }; const _hoisted_2$9 = { class: "fqa-set-side" }; const _hoisted_3$9 = ["onClick", "onKeydown"]; const _hoisted_4$8 = { class: "fqa-set-main" }; const _hoisted_5$8 = { class: "fqa-set-row" }; const _hoisted_6$8 = { class: "fqa-set-row" }; const _hoisted_7$6 = { class: "fqa-set-row" }; const _hoisted_8$6 = { class: "fqa-set-row" }; const _hoisted_9$6 = { class: "fqa-set-row" }; const _hoisted_10$6 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_11$5 = { class: "fqa-set-row" }; const _hoisted_12$5 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_13$4 = ["disabled"]; const _hoisted_14$4 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_15$2 = { class: "fqa-set-radios" }; const _hoisted_16$1 = { class: "fqa-set-radio" }; const _hoisted_17$1 = { class: "fqa-set-radio" }; const _hoisted_18$1 = { class: "fqa-set-row" }; const _hoisted_19$1 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_20 = { class: "fqa-set-row", style: { "padding-top": "0", "border-bottom": "none" } }; const _hoisted_21 = { class: "fqa-set-row" }; const _hoisted_22 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_23 = { class: "fqa-set-radios" }; const _hoisted_24 = { class: "fqa-set-radio" }; const _hoisted_25 = { class: "fqa-set-radio" }; const _hoisted_26 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_27 = { class: "fqa-set-radios" }; const _hoisted_28 = { class: "fqa-set-radio" }; const _hoisted_29 = { class: "fqa-set-radio" }; const _hoisted_30 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_31 = { class: "fqa-set-row", style: { "padding-top": "0", "border-bottom": "none" } }; const _hoisted_32 = { class: "fqa-set-row", style: { "padding-top": "0", "border-bottom": "none" } }; const _hoisted_33 = { class: "fqa-set-row", style: { "padding-top": "0", "border-bottom": "none" } }; const _hoisted_34 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_35 = { class: "fqa-set-field" }; const _hoisted_36 = { class: "fqa-set-field" }; const _hoisted_37 = { class: "fqa-set-field" }; const _hoisted_38 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_39 = { class: "fqa-set-radios" }; const _hoisted_40 = { class: "fqa-set-radio" }; const _hoisted_41 = { class: "fqa-set-radio" }; const _hoisted_42 = { class: "fqa-set-row" }; const _hoisted_43 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_44 = { class: "fqa-set-radios" }; const _hoisted_45 = { class: "fqa-set-radio" }; const _hoisted_46 = { class: "fqa-set-radio" }; const _hoisted_47 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_48 = { class: "fqa-set-field" }; const _hoisted_49 = ["placeholder"]; const _hoisted_50 = { class: "fqa-set-field" }; const _hoisted_51 = ["placeholder"]; const _hoisted_52 = { class: "fqa-set-field" }; const _hoisted_53 = ["placeholder"]; const _hoisted_54 = { class: "fqa-set-actions" }; const _hoisted_55 = ["disabled"]; const _hoisted_56 = { key: 0, class: "fqa-set-note" }; const _hoisted_57 = { class: "fqa-set-note" }; const _hoisted_58 = { class: "fqa-set-links" }; const _hoisted_59 = { class: "fqa-set-link-row" }; const _hoisted_60 = ["href"]; const GREASYFORK = "https://greasyfork.org/zh-CN/scripts/589115-%E7%95%AA%E8%8C%84%E5%B0%8F%E8%AF%B4%E5%8A%A9%E6%89%8B"; const GITHUB = "https://github.com/naiyQAQ/fanqie-assistant"; const _sfc_main$9 = /* @__PURE__ */ vue.defineComponent({ __name: "SettingsView", emits: ["close"], setup(__props, { emit: __emit }) { const emit = __emit; function resetDownloadTuning() { settings$1.downloadBatchSize = DEFAULT_SETTINGS.downloadBatchSize; settings$1.downloadInterval = DEFAULT_SETTINGS.downloadInterval; settings$1.downloadRetries = DEFAULT_SETTINGS.downloadRetries; flushSettings(); } const SECTIONS = [ { key: "general", label: "常规" }, { key: "ui", label: "界面" }, { key: "search", label: "搜索" }, { key: "download", label: "下载" }, { key: "audiobook", label: "听书" }, { key: "protocol", label: "协议" }, { key: "about", label: "关于" } ]; const active = vue.ref("general"); const registering = vue.ref(false); const registerMsg = vue.ref(null); async function reRegister() { if (registering.value) return; registering.value = true; registerMsg.value = "正在注册新设备…"; try { const c = await provisionDevice(); settings$1.deviceId = ""; settings$1.installId = ""; settings$1.deviceType = ""; flushSettings(); registerMsg.value = `注册成功:${c.device_id}`; } catch (err) { console.error("[fqa:settings] 设备注册失败:", err); registerMsg.value = err instanceof Error ? `注册失败:${err.message}` : "注册失败"; } finally { registering.value = false; } } const currentDevice = vue.computed(() => ({ device_id: settings$1.deviceId || _config.currentConfig.device_id, install_id: settings$1.installId || _config.currentConfig.install_id, device_type: settings$1.deviceType || _config.currentConfig.device_type || "" })); function close2() { flushSettings(); emit("close"); } function onKey(e) { if (e.key === "Escape") close2(); } vue.onMounted(() => document.addEventListener("keydown", onKey)); vue.onBeforeUnmount(() => document.removeEventListener("keydown", onKey)); const FEEDBACK = `${GREASYFORK}/feedback`; return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", { class: "fqa-set-mask", onClick: vue.withModifiers(close2, ["self"]) }, [ vue.createElementVNode("div", _hoisted_1$9, [ vue.createElementVNode("button", { class: "fqa-set-close", "aria-label": "关闭", onClick: close2 }, "✕"), vue.createElementVNode("nav", _hoisted_2$9, [ _cache[31] || (_cache[31] = vue.createElementVNode("div", { class: "fqa-set-side-title" }, "助手设置", -1)), (vue.openBlock(), vue.createElementBlock(vue.Fragment, null, vue.renderList(SECTIONS, (s) => { return vue.createElementVNode("div", { key: s.key, class: vue.normalizeClass(["fqa-set-nav", { "fqa-set-nav-active": active.value === s.key }]), role: "button", tabindex: "0", onClick: ($event) => active.value = s.key, onKeydown: vue.withKeys(vue.withModifiers(($event) => active.value = s.key, ["prevent"]), ["enter"]) }, vue.toDisplayString(s.label), 43, _hoisted_3$9); }), 64)) ]), vue.createElementVNode("section", _hoisted_4$8, [ active.value === "general" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, [ _cache[37] || (_cache[37] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "常规", -1)), vue.createElementVNode("label", _hoisted_5$8, [ _cache[32] || (_cache[32] = vue.createElementVNode("span", { class: "fqa-set-label" }, "解密网页端混淆字体", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => vue.unref(settings$1).decryptFont = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).decryptFont] ]) ]), vue.createElementVNode("label", _hoisted_6$8, [ _cache[33] || (_cache[33] = vue.createElementVNode("span", { class: "fqa-set-label" }, "拦截网页事件上报", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[1] || (_cache[1] = ($event) => vue.unref(settings$1).blockReport = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).blockReport] ]) ]), vue.createElementVNode("label", _hoisted_7$6, [ _cache[34] || (_cache[34] = vue.createElementVNode("span", { class: "fqa-set-label" }, "允许阅读器复制文本", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[2] || (_cache[2] = ($event) => vue.unref(settings$1).allowCopy = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).allowCopy] ]) ]), vue.createElementVNode("label", _hoisted_8$6, [ _cache[35] || (_cache[35] = vue.createElementVNode("span", { class: "fqa-set-label" }, "移出书架时二次确认", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[3] || (_cache[3] = ($event) => vue.unref(settings$1).shelfRemoveConfirm = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).shelfRemoveConfirm] ]) ]), vue.createElementVNode("label", _hoisted_9$6, [ _cache[36] || (_cache[36] = vue.createElementVNode("span", { class: "fqa-set-label" }, "退出登录时二次确认", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[4] || (_cache[4] = ($event) => vue.unref(settings$1).logoutConfirm = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).logoutConfirm] ]) ]) ], 64)) : active.value === "ui" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 1 }, [ _cache[46] || (_cache[46] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "界面", -1)), vue.createElementVNode("div", _hoisted_10$6, [ _cache[38] || (_cache[38] = vue.createElementVNode("span", { class: "fqa-set-label" }, "阅读器字体", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[5] || (_cache[5] = ($event) => vue.unref(settings$1).readerFont = $event), class: "fqa-set-input", type: "text", placeholder: "留空表示使用默认字体" }, null, 512), [ [vue.vModelText, vue.unref(settings$1).readerFont] ]), _cache[39] || (_cache[39] = vue.createElementVNode("p", { class: "fqa-set-note" }, "填写字体名称,例如「思源宋体」。留空则跟随网页默认。", -1)) ]), vue.createElementVNode("div", _hoisted_11$5, [ _cache[40] || (_cache[40] = vue.createElementVNode("span", { class: "fqa-set-label" }, "自定义 CSS", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[6] || (_cache[6] = ($event) => vue.unref(settings$1).customCssEnabled = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).customCssEnabled] ]) ]), vue.createElementVNode("div", _hoisted_12$5, [ vue.withDirectives(vue.createElementVNode("textarea", { "onUpdate:modelValue": _cache[7] || (_cache[7] = ($event) => vue.unref(settings$1).customCss = $event), class: "fqa-set-textarea", disabled: !vue.unref(settings$1).customCssEnabled, spellcheck: "false", placeholder: "/* 自定义 CSS */" }, null, 8, _hoisted_13$4), [ [vue.vModelText, vue.unref(settings$1).customCss] ]), _cache[41] || (_cache[41] = vue.createElementVNode("p", { class: "fqa-set-note" }, "关闭开关后内容会保留,只是不再应用。", -1)) ]), vue.createElementVNode("div", _hoisted_14$4, [ _cache[44] || (_cache[44] = vue.createElementVNode("span", { class: "fqa-set-label" }, "书架单击书本", -1)), vue.createElementVNode("div", _hoisted_15$2, [ vue.createElementVNode("label", _hoisted_16$1, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[8] || (_cache[8] = ($event) => vue.unref(settings$1).bookshelfClickAction = $event), type: "radio", value: "read" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).bookshelfClickAction] ]), _cache[42] || (_cache[42] = vue.createElementVNode("span", null, "继续阅读", -1)) ]), vue.createElementVNode("label", _hoisted_17$1, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[9] || (_cache[9] = ($event) => vue.unref(settings$1).bookshelfClickAction = $event), type: "radio", value: "detail" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).bookshelfClickAction] ]), _cache[43] || (_cache[43] = vue.createElementVNode("span", null, "查看详情", -1)) ]) ]), _cache[45] || (_cache[45] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 按住 Ctrl 单击去往另一项;中键在新标签页打开,Ctrl+中键同样反向。 选「继续阅读」但这本书还没读过时,会退回详情页。 ", -1)) ]) ], 64)) : active.value === "search" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 2 }, [ _cache[50] || (_cache[50] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "搜索", -1)), vue.createElementVNode("label", _hoisted_18$1, [ _cache[47] || (_cache[47] = vue.createElementVNode("span", { class: "fqa-set-label" }, "接管搜索界面", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[10] || (_cache[10] = ($event) => vue.unref(settings$1).enhanceSearch = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).enhanceSearch] ]) ]), vue.createElementVNode("div", _hoisted_19$1, [ vue.createElementVNode("label", _hoisted_20, [ _cache[48] || (_cache[48] = vue.createElementVNode("span", { class: "fqa-set-label" }, "个人化推荐", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[11] || (_cache[11] = ($event) => vue.unref(settings$1).searchPersonalized = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).searchPersonalized] ]) ]), _cache[49] || (_cache[49] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 开启后搜索走同源请求,由浏览器自动带上你的登录 Cookie,番茄据此按阅读偏好排序。 凭据不经过脚本,也不会发往番茄以外的任何地方。关闭时走匿名请求。 ", -1)) ]) ], 64)) : active.value === "download" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 3 }, [ _cache[71] || (_cache[71] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "下载", -1)), vue.createElementVNode("label", _hoisted_21, [ _cache[51] || (_cache[51] = vue.createElementVNode("span", { class: "fqa-set-label" }, "显示下载入口", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[12] || (_cache[12] = ($event) => vue.unref(settings$1).enableDownload = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).enableDownload] ]) ]), vue.createElementVNode("div", _hoisted_22, [ _cache[54] || (_cache[54] = vue.createElementVNode("span", { class: "fqa-set-label" }, "默认格式", -1)), vue.createElementVNode("div", _hoisted_23, [ vue.createElementVNode("label", _hoisted_24, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[13] || (_cache[13] = ($event) => vue.unref(settings$1).downloadFormat = $event), type: "radio", value: "epub" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadFormat] ]), _cache[52] || (_cache[52] = vue.createElementVNode("span", null, "EPUB", -1)) ]), vue.createElementVNode("label", _hoisted_25, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[14] || (_cache[14] = ($event) => vue.unref(settings$1).downloadFormat = $event), type: "radio", value: "txt" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadFormat] ]), _cache[53] || (_cache[53] = vue.createElementVNode("span", null, "TXT", -1)) ]) ]), _cache[55] || (_cache[55] = vue.createElementVNode("p", { class: "fqa-set-note" }, " EPUB 保留原始排版、插图与分卷目录;TXT 是纯文本。右键菜单里可以单次指定格式。 ", -1)) ]), vue.createElementVNode("div", _hoisted_26, [ _cache[58] || (_cache[58] = vue.createElementVNode("span", { class: "fqa-set-label" }, "TXT 编码", -1)), vue.createElementVNode("div", _hoisted_27, [ vue.createElementVNode("label", _hoisted_28, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[15] || (_cache[15] = ($event) => vue.unref(settings$1).downloadCharset = $event), type: "radio", value: "utf-8" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadCharset] ]), _cache[56] || (_cache[56] = vue.createElementVNode("span", null, "UTF-8", -1)) ]), vue.createElementVNode("label", _hoisted_29, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[16] || (_cache[16] = ($event) => vue.unref(settings$1).downloadCharset = $event), type: "radio", value: "gbk" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadCharset] ]), _cache[57] || (_cache[57] = vue.createElementVNode("span", null, "GBK", -1)) ]) ]), _cache[59] || (_cache[59] = vue.createElementVNode("p", { class: "fqa-set-note" }, "EPUB 固定使用 UTF-8。老设备或部分阅读器可能需要 GBK。", -1)) ]), vue.createElementVNode("div", _hoisted_30, [ _cache[63] || (_cache[63] = vue.createElementVNode("span", { class: "fqa-set-label" }, "EPUB 选项", -1)), vue.createElementVNode("label", _hoisted_31, [ _cache[60] || (_cache[60] = vue.createElementVNode("span", { class: "fqa-set-label" }, "下载正文插图", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[17] || (_cache[17] = ($event) => vue.unref(settings$1).downloadImages = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadImages] ]) ]), vue.createElementVNode("label", _hoisted_32, [ _cache[61] || (_cache[61] = vue.createElementVNode("span", { class: "fqa-set-label" }, "保留书籍排版样式", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[18] || (_cache[18] = ($event) => vue.unref(settings$1).downloadBookCss = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadBookCss] ]) ]), vue.createElementVNode("label", _hoisted_33, [ _cache[62] || (_cache[62] = vue.createElementVNode("span", { class: "fqa-set-label" }, "为每卷生成卷页", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[19] || (_cache[19] = ($event) => vue.unref(settings$1).downloadVolumePage = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadVolumePage] ]) ]), _cache[64] || (_cache[64] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 插图逐张下载,图多的书会明显变慢、文件也更大;关闭后正文里仍保留图片地址,联网可看。 ", -1)) ]), vue.createElementVNode("div", _hoisted_34, [ _cache[68] || (_cache[68] = vue.createElementVNode("span", { class: "fqa-set-label" }, "请求节奏", -1)), _cache[69] || (_cache[69] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 接口对批量正文有限制,调得太激进会导致大量章节抓不到甚至触发风控。不清楚就别改。 ", -1)), vue.createElementVNode("label", _hoisted_35, [ _cache[65] || (_cache[65] = vue.createElementVNode("span", null, "每批章节数(1-30)", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[20] || (_cache[20] = ($event) => vue.unref(settings$1).downloadBatchSize = $event), class: "fqa-set-input", type: "number", min: "1", max: "30" }, null, 512), [ [ vue.vModelText, vue.unref(settings$1).downloadBatchSize, void 0, { number: true } ] ]) ]), vue.createElementVNode("label", _hoisted_36, [ _cache[66] || (_cache[66] = vue.createElementVNode("span", null, "批次间隔 (ms)", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[21] || (_cache[21] = ($event) => vue.unref(settings$1).downloadInterval = $event), class: "fqa-set-input", type: "number", min: "0", max: "10000", step: "50" }, null, 512), [ [ vue.vModelText, vue.unref(settings$1).downloadInterval, void 0, { number: true } ] ]) ]), vue.createElementVNode("label", _hoisted_37, [ _cache[67] || (_cache[67] = vue.createElementVNode("span", null, "重试轮数", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[22] || (_cache[22] = ($event) => vue.unref(settings$1).downloadRetries = $event), class: "fqa-set-input", type: "number", min: "0", max: "10" }, null, 512), [ [ vue.vModelText, vue.unref(settings$1).downloadRetries, void 0, { number: true } ] ]) ]), _cache[70] || (_cache[70] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 实测单次请求最多返回 30 章正文,间隔小于约 750ms 会被限流成每次 1 章。 ", -1)), vue.createElementVNode("div", { class: "fqa-set-actions" }, [ vue.createElementVNode("button", { class: "fqa-set-btn", onClick: resetDownloadTuning }, "恢复推荐值") ]) ]) ], 64)) : active.value === "audiobook" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 4 }, [ _cache[77] || (_cache[77] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "听书", -1)), vue.createElementVNode("div", _hoisted_38, [ _cache[74] || (_cache[74] = vue.createElementVNode("span", { class: "fqa-set-label" }, "读完一章后", -1)), vue.createElementVNode("div", _hoisted_39, [ vue.createElementVNode("label", _hoisted_40, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[23] || (_cache[23] = ($event) => vue.unref(settings$1).audiobookChapterEnd = $event), type: "radio", value: "next" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).audiobookChapterEnd] ]), _cache[72] || (_cache[72] = vue.createElementVNode("span", null, "自动下一章", -1)) ]), vue.createElementVNode("label", _hoisted_41, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[24] || (_cache[24] = ($event) => vue.unref(settings$1).audiobookChapterEnd = $event), type: "radio", value: "stop" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).audiobookChapterEnd] ]), _cache[73] || (_cache[73] = vue.createElementVNode("span", null, "停止播放", -1)) ]) ]), _cache[75] || (_cache[75] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 自动下一章会点击页面上的「下一章」,读到全书最后一章时自动停下。 ", -1)) ]), vue.createElementVNode("label", _hoisted_42, [ _cache[76] || (_cache[76] = vue.createElementVNode("span", { class: "fqa-set-label" }, "高亮并跟随朗读段落", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[25] || (_cache[25] = ($event) => vue.unref(settings$1).audiobookFollow = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).audiobookFollow] ]) ]), _cache[78] || (_cache[78] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 关闭后仍会高亮当前段落,但不再自动滚动页面。点击任意段落可跳到该处朗读。 ", -1)) ], 64)) : active.value === "protocol" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 5 }, [ _cache[88] || (_cache[88] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "协议", -1)), vue.createElementVNode("div", _hoisted_43, [ _cache[81] || (_cache[81] = vue.createElementVNode("span", { class: "fqa-set-label" }, "API 偏好", -1)), vue.createElementVNode("div", _hoisted_44, [ vue.createElementVNode("label", _hoisted_45, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[26] || (_cache[26] = ($event) => vue.unref(settings$1).apiPreference = $event), type: "radio", value: "app" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).apiPreference] ]), _cache[79] || (_cache[79] = vue.createElementVNode("span", null, "番茄 APP", -1)) ]), vue.createElementVNode("label", _hoisted_46, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[27] || (_cache[27] = ($event) => vue.unref(settings$1).apiPreference = $event), type: "radio", value: "redcandle" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).apiPreference] ]), _cache[80] || (_cache[80] = vue.createElementVNode("span", null, "红烛 APP", -1)) ]) ]), _cache[82] || (_cache[82] = vue.createElementVNode("p", { class: "fqa-set-note" }, "如果某协议数据不全,脚本可能会选择其他接口作为补充。", -1)) ]), vue.createElementVNode("div", _hoisted_47, [ _cache[86] || (_cache[86] = vue.createElementVNode("span", { class: "fqa-set-label" }, "设备信息", -1)), _cache[87] || (_cache[87] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 如果不知道这是什么,请保持默认。乱填可能导致脚本功能异常。 ", -1)), vue.createElementVNode("label", _hoisted_48, [ _cache[83] || (_cache[83] = vue.createElementVNode("span", null, "device_id", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[28] || (_cache[28] = ($event) => vue.unref(settings$1).deviceId = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.device_id || "自动注册" }, null, 8, _hoisted_49), [ [vue.vModelText, vue.unref(settings$1).deviceId] ]) ]), vue.createElementVNode("label", _hoisted_50, [ _cache[84] || (_cache[84] = vue.createElementVNode("span", null, "install_id (iid)", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[29] || (_cache[29] = ($event) => vue.unref(settings$1).installId = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.install_id || "自动注册" }, null, 8, _hoisted_51), [ [vue.vModelText, vue.unref(settings$1).installId] ]) ]), vue.createElementVNode("label", _hoisted_52, [ _cache[85] || (_cache[85] = vue.createElementVNode("span", null, "device_type", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[30] || (_cache[30] = ($event) => vue.unref(settings$1).deviceType = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.device_type || "自动注册" }, null, 8, _hoisted_53), [ [vue.vModelText, vue.unref(settings$1).deviceType] ]) ]), vue.createElementVNode("div", _hoisted_54, [ vue.createElementVNode("button", { class: "fqa-set-btn", disabled: registering.value, onClick: reRegister }, vue.toDisplayString(registering.value ? "注册中…" : "重新注册"), 9, _hoisted_55), registerMsg.value ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_56, vue.toDisplayString(registerMsg.value), 1)) : vue.createCommentVNode("", true) ]) ]) ], 64)) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 6 }, [ _cache[93] || (_cache[93] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "关于", -1)), vue.createElementVNode("p", _hoisted_57, "番茄小说助手 v" + vue.toDisplayString(vue.unref(version)), 1), vue.createElementVNode("div", _hoisted_58, [ vue.createElementVNode("div", { class: "fqa-set-link-row" }, [ _cache[89] || (_cache[89] = vue.createElementVNode("span", null, "GreasyFork 地址:", -1)), vue.createElementVNode("a", { href: GREASYFORK, target: "_blank", rel: "noreferrer noopener" }, "跳转") ]), vue.createElementVNode("div", { class: "fqa-set-link-row" }, [ _cache[90] || (_cache[90] = vue.createElementVNode("span", null, "GitHub 地址:", -1)), vue.createElementVNode("a", { href: GITHUB, target: "_blank", rel: "noreferrer noopener" }, "跳转") ]), vue.createElementVNode("div", _hoisted_59, [ _cache[92] || (_cache[92] = vue.createElementVNode("span", null, "问题反馈:", -1)), vue.createElementVNode("a", { href: FEEDBACK, target: "_blank", rel: "noreferrer noopener" }, "GreasyFork"), vue.createElementVNode("span", null, [ _cache[91] || (_cache[91] = vue.createTextVNode(" 或 ", -1)), vue.createElementVNode("a", { href: `${GITHUB}/issues`, target: "_blank", rel: "noreferrer noopener" }, " GitHub Issues ", 8, _hoisted_60) ]) ]) ]), _cache[94] || (_cache[94] = vue.createElementVNode("div", { class: "fqa-set-license" }, [ vue.createElementVNode("p", null, " 本脚本基于 GNU General Public License 3.0 授权,完全开源且免费,修改/二次开发请注意遵守开源协议。 "), vue.createElementVNode("p", null, "本脚本使用 TypeScript + Vue 开发,请避免直接修改编译产物。") ], -1)) ], 64)) ]) ]) ]); }; } }); const settingscss = "/* 助手设置面板 */\n\n.fqa-set-mask {\n position: fixed;\n inset: 0;\n z-index: 2147483200;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.45);\n font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,\n sans-serif;\n font-size: 14px;\n line-height: 1.6;\n color: var(--fqa-set-text, #1f2329);\n}\n\n.fqa-set-dialog {\n --fqa-set-bg: #fff;\n --fqa-set-side-bg: #f7f8fa;\n --fqa-set-text: #1f2329;\n --fqa-set-sub: #8f959e;\n --fqa-set-border: rgba(31, 35, 41, 0.1);\n --fqa-set-accent: #ff6f3d;\n --fqa-set-hover: rgba(31, 35, 41, 0.05);\n\n position: relative;\n display: flex;\n width: min(760px, 92vw);\n height: min(520px, 84vh);\n background: var(--fqa-set-bg);\n color: var(--fqa-set-text);\n border-radius: 12px;\n overflow: hidden;\n box-shadow: 0 12px 48px rgba(0, 0, 0, 0.24);\n}\n\n.fqa-set-close {\n position: absolute;\n top: 10px;\n right: 12px;\n width: 28px;\n height: 28px;\n padding: 0;\n border: none;\n border-radius: 6px;\n background: transparent;\n color: var(--fqa-set-sub);\n font-size: 15px;\n line-height: 1;\n cursor: pointer;\n}\n\n.fqa-set-close:hover {\n background: var(--fqa-set-hover);\n color: var(--fqa-set-text);\n}\n\n/* 左侧栏 */\n.fqa-set-side {\n flex: 0 0 148px;\n padding: 16px 8px;\n box-sizing: border-box;\n background: var(--fqa-set-side-bg);\n border-right: 1px solid var(--fqa-set-border);\n overflow-y: auto;\n}\n\n.fqa-set-side-title {\n padding: 0 10px 12px;\n font-size: 15px;\n font-weight: 600;\n}\n\n.fqa-set-nav {\n padding: 8px 10px;\n margin-bottom: 2px;\n border-radius: 6px;\n cursor: pointer;\n user-select: none;\n}\n\n.fqa-set-nav:hover {\n background: var(--fqa-set-hover);\n}\n\n.fqa-set-nav-active {\n background: var(--fqa-set-hover);\n color: var(--fqa-set-accent);\n font-weight: 600;\n}\n\n/* 右侧内容 */\n.fqa-set-main {\n flex: 1 1 auto;\n padding: 20px 24px;\n box-sizing: border-box;\n overflow-y: auto;\n}\n\n.fqa-set-h {\n margin: 0 0 14px;\n font-size: 16px;\n font-weight: 600;\n}\n\n.fqa-set-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 10px 0;\n border-bottom: 1px solid var(--fqa-set-border);\n}\n\n.fqa-set-row:last-child {\n border-bottom: none;\n}\n\n.fqa-set-row-col {\n display: block;\n}\n\n.fqa-set-label {\n font-size: 14px;\n}\n\n.fqa-set-note {\n margin: 6px 0 0;\n font-size: 12px;\n color: var(--fqa-set-sub);\n}\n\n.fqa-set-warn {\n margin: 6px 0 10px;\n padding: 8px 10px;\n font-size: 12px;\n color: #a8371f;\n background: rgba(255, 111, 61, 0.1);\n border-left: 3px solid var(--fqa-set-accent);\n border-radius: 0 4px 4px 0;\n}\n\n/* 开关:用原生 checkbox 改造,避免额外依赖 */\n.fqa-set-switch {\n appearance: none;\n flex: 0 0 auto;\n position: relative;\n width: 38px;\n height: 22px;\n margin: 0;\n border-radius: 11px;\n background: rgba(31, 35, 41, 0.18);\n cursor: pointer;\n transition: background 0.18s ease;\n}\n\n.fqa-set-switch::after {\n content: '';\n position: absolute;\n top: 2px;\n left: 2px;\n width: 18px;\n height: 18px;\n border-radius: 50%;\n background: #fff;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n transition: transform 0.18s ease;\n}\n\n.fqa-set-switch:checked {\n background: var(--fqa-set-accent);\n}\n\n.fqa-set-switch:checked::after {\n transform: translateX(16px);\n}\n\n.fqa-set-input {\n width: 100%;\n margin-top: 8px;\n padding: 7px 10px;\n box-sizing: border-box;\n border: 1px solid var(--fqa-set-border);\n border-radius: 6px;\n background: var(--fqa-set-bg);\n color: var(--fqa-set-text);\n font-size: 13px;\n font-family: inherit;\n}\n\n.fqa-set-input:focus {\n outline: none;\n border-color: var(--fqa-set-accent);\n}\n\n.fqa-set-textarea {\n width: 100%;\n min-height: 150px;\n margin-top: 10px;\n padding: 10px;\n box-sizing: border-box;\n border: 1px solid var(--fqa-set-border);\n border-radius: 6px;\n background: var(--fqa-set-bg);\n color: var(--fqa-set-text);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n line-height: 1.6;\n resize: vertical;\n}\n\n.fqa-set-textarea:disabled {\n background: var(--fqa-set-side-bg);\n color: var(--fqa-set-sub);\n cursor: not-allowed;\n}\n\n.fqa-set-textarea:focus {\n outline: none;\n border-color: var(--fqa-set-accent);\n}\n\n.fqa-set-radios {\n display: flex;\n gap: 20px;\n margin-top: 8px;\n}\n\n.fqa-set-radio {\n display: flex;\n align-items: center;\n gap: 6px;\n cursor: pointer;\n}\n\n.fqa-set-radio input {\n accent-color: var(--fqa-set-accent);\n}\n\n.fqa-set-field {\n display: block;\n margin-top: 10px;\n font-size: 12px;\n color: var(--fqa-set-sub);\n}\n\n.fqa-set-actions {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-top: 14px;\n}\n\n.fqa-set-btn {\n padding: 7px 16px;\n border: 1px solid var(--fqa-set-border);\n border-radius: 6px;\n background: var(--fqa-set-bg);\n color: var(--fqa-set-text);\n font-size: 13px;\n font-family: inherit;\n cursor: pointer;\n}\n\n.fqa-set-btn:hover:not(:disabled) {\n border-color: var(--fqa-set-accent);\n color: var(--fqa-set-accent);\n}\n\n.fqa-set-btn:disabled {\n color: var(--fqa-set-sub);\n cursor: not-allowed;\n}\n\n/* 关于 */\n.fqa-set-links {\n margin-top: 12px;\n}\n\n.fqa-set-link-row {\n margin-bottom: 10px;\n font-size: 13px;\n word-break: break-all;\n}\n\n.fqa-set-link-row a {\n color: var(--fqa-set-accent);\n text-decoration: none;\n}\n\n.fqa-set-link-row a:hover {\n text-decoration: underline;\n}\n\n.fqa-set-license {\n margin-top: 20px;\n padding-top: 14px;\n border-top: 1px solid var(--fqa-set-border);\n font-size: 12px;\n color: var(--fqa-set-sub);\n}\n\n.fqa-set-license p {\n margin: 0 0 6px;\n}\n\n/* 深色 */\n@media (prefers-color-scheme: dark) {\n .fqa-set-dialog {\n --fqa-set-bg: #23272e;\n --fqa-set-side-bg: #1c2026;\n --fqa-set-text: #e5e6eb;\n --fqa-set-sub: #8f959e;\n --fqa-set-border: rgba(255, 255, 255, 0.12);\n --fqa-set-hover: rgba(255, 255, 255, 0.08);\n }\n\n .fqa-set-switch {\n background: rgba(255, 255, 255, 0.2);\n }\n\n .fqa-set-warn {\n color: #ffb59b;\n }\n}\n"; const CONTAINER_ID$3 = "fqa-settings-root"; const STYLE_ID$4 = "fqa-settings-style"; let app$3 = null; let container$3 = null; function injectStyle$4() { if (document.getElementById(STYLE_ID$4)) return; const style = document.createElement("style"); style.id = STYLE_ID$4; style.textContent = settingscss; document.head.appendChild(style); } function closeSettings() { app$3 == null ? void 0 : app$3.unmount(); app$3 = null; container$3 == null ? void 0 : container$3.remove(); container$3 = null; } function openSettings() { if (app$3) return; injectStyle$4(); container$3 = document.createElement("div"); container$3.id = CONTAINER_ID$3; document.body.appendChild(container$3); app$3 = vue.createApp({ render: () => vue.h(_sfc_main$9, { onClose: closeSettings }) }); app$3.config.errorHandler = (err, _instance, info) => { console.error(`[fqa:settings] Vue error (${info}):`, err); }; app$3.mount(container$3); } function formatReadingTime(readBookTime) { let minutes = readBookTime / 60000n; const hours = minutes / 60n; minutes = minutes % 60n; return `${hours} 时 ${minutes} 分`; } function createMenuItem(text, icon, onclick) { const item = document.createElement("div"); item.role = "menuitem"; item.classList.add("fqa-menu-item", "arco-menu-item", "serial-menu-item", "slogin-user-avatar__menu-item-wrapper"); const inner1 = document.createElement("div"); inner1.classList.add("slogin-user-avatar__menu-item"); if (icon) { const iconDiv = document.createElement("div"); iconDiv.classList.add("slogin-user-avatar__menu-item__icon"); if (document.querySelector("div.muye-reader-dark")) { console.log("isdark"); iconDiv.classList.add("fqa-icon-dark"); } else { console.log("islight"); } iconDiv.innerHTML = icon; inner1.appendChild(iconDiv); } const inner2 = document.createElement("div"); inner2.classList.add("slogin-user-avatar__menu-item__content"); inner2.textContent = text; inner1.appendChild(inner2); item.appendChild(inner1); if (onclick) { item.addEventListener("click", onclick); } return item; } async function mainHook$3(_previous) { const userInfo = await getDetailedUserInfo(); if (!userInfo) { return; } const injected = /* @__PURE__ */ new WeakSet(); const inject2 = (menuInner) => { if (menuInner.querySelectorAll("div.arco-menu-inline").length > 0) { return; } if (injected.has(menuInner)) { return; } injected.add(menuInner); console.log("user action dialog created", menuInner); const firstDiv = createMenuItem(`阅读 ${userInfo.read_book_num} 本书`); menuInner.insertAdjacentElement("afterbegin", firstDiv); const secondDiv = createMenuItem(formatReadingTime(userInfo.read_book_time ?? 0n)); firstDiv.insertAdjacentElement("afterend", secondDiv); const thirdDiv = createMenuItem("我的书架", bookshelf, () => { window.open("https://fanqienovel.com/bookshelf", "_blank"); }); secondDiv.insertAdjacentElement("afterend", thirdDiv); menuInner.appendChild(createMenuItem("助手设置", settings, openSettings)); menuInner.childNodes.forEach((node) => { var _a, _b; if (((_b = (_a = node.lastChild) == null ? void 0 : _a.textContent) == null ? void 0 : _b.trim()) === "退出登录") { const original = node; const replaced = cloneElement(node); original.classList.add("fqa-hide"); original.insertAdjacentElement("afterend", replaced); replaced.addEventListener("click", (_) => { if (!settings$1.logoutConfirm || unsafeWindow.confirm("确认退出登录吗?")) { original.click(); } }); } }); }; const scan = (root) => { if (root.classList.contains("arco-menu-inner")) { inject2(root); } root.querySelectorAll(".arco-menu-inner").forEach(inject2); }; const observer2 = new MutationObserver((mutationsList) => { mutationsList.forEach((mutation) => { if (mutation.type === "childList") { mutation.addedNodes.forEach((node) => { if (node instanceof HTMLElement) { scan(node); } }); } }); }); observer2.observe(document.body, { childList: true, subtree: true }); scan(document.body); } async function guestHook(_previous) { const injected = /* @__PURE__ */ new WeakSet(); const inject2 = (registerBtn) => { const parent = registerBtn.parentElement; if (!parent || injected.has(parent)) return; if (parent.querySelector(".fqa-settings-entry")) return; injected.add(parent); const entry = cloneElement(registerBtn); entry.classList.add("fqa-settings-entry"); entry.textContent = "助手设置"; entry.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); openSettings(); }); registerBtn.insertAdjacentElement("afterend", entry); }; const scan = (root) => { root.querySelectorAll(".slogin-user-avatar__buttons__item").forEach((el) => { var _a; if (el.classList.contains("fqa-settings-entry")) return; if (((_a = el.textContent) == null ? void 0 : _a.trim()) === "注册") inject2(el); }); }; const observer2 = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node instanceof HTMLElement) scan(node); } } }); observer2.observe(document.body, { childList: true, subtree: true }); scan(document); } function filter$3(path, _query, _hash) { return userState.isLogin && !path.startsWith("/writer") && !path.startsWith("/welfare"); } function guestFilter(path, _query, _hash) { return !userState.isLogin && !path.startsWith("/writer") && !path.startsWith("/welfare"); } const _exports$3 = [ { id: "userHook", event: "load", filter: filter$3, handler: mainHook$3 }, { id: "userHook_guest", event: "load", filter: guestFilter, handler: guestHook } ]; const MIDDLE_BUTTON = 1; function modifiersOf(event) { return { flip: event.ctrlKey || event.metaKey, newTab: "button" in event && event.button === MIDDLE_BUTTON }; } function isMiddleButton(event) { return event.button === MIDDLE_BUTTON; } function readerUrl(chapterId) { return `https://fanqienovel.com/reader/${chapterId}`; } function bookPageUrl(bookId2) { return `https://fanqienovel.com/page/${bookId2}`; } function openUrl(url, newTab = false) { if (!newTab) { unsafeWindow.location.href = url; return; } if (!unsafeWindow.open(url, "_blank")) { console.warn("[fqa:nav] 新标签页被拦截,请检查浏览器的弹窗设置:", url); } } const _hoisted_1$8 = ["aria-label", "onKeydown"]; const _hoisted_2$8 = { class: "fqa-cover" }; const _hoisted_3$8 = ["src", "alt"]; const _hoisted_4$7 = { key: 1, class: "fqa-cover-progress" }; const _hoisted_5$7 = ["title"]; const _hoisted_6$7 = ["title"]; const _sfc_main$8 = /* @__PURE__ */ vue.defineComponent({ __name: "BookCard", props: { entry: {} }, emits: ["hover", "leave", "open", "visible", "contextmenu"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; const detail = vue.computed(() => props.entry.detail); const title = vue.computed(() => { var _a; return ((_a = detail.value) == null ? void 0 : _a.title) ?? ""; }); const tag = vue.computed(() => { const d = detail.value; if (!d) return null; if (d.update_status === "1") return { text: "更新", cls: "" }; if (d.status === "4") return { text: "断更", cls: "fqa-cover-tag-gray" }; if (d.status === "0") return { text: "完结", cls: "fqa-cover-tag-gray" }; if (d.status === "1") return { text: "连载", cls: "fqa-cover-tag-gray" }; return null; }); const progressText = vue.computed(() => { const d = detail.value; if (!d) return ""; const total = d.total_chapter_count || 0; const order = Math.max(0, Math.min(d.current_chapter_order || 0, total)); if (!order) return total ? `未读 · 共${total}章` : "未读"; return `${order}章/${total}章`; }); const progressPercent = vue.computed(() => { const d = detail.value; if (!d || !d.total_chapter_count) return 0; const ratio = (d.current_chapter_order || 0) / d.total_chapter_count; return Math.max(0, Math.min(1, ratio)) * 100; }); function onEnter2(event) { emit("hover", { entry: props.entry, el: event.currentTarget }); } function onContextMenu(event) { event.preventDefault(); emit("contextmenu", { entry: props.entry, x: event.clientX, y: event.clientY }); } function open2(event) { emit("open", { entry: props.entry, mods: modifiersOf(event) }); } function onAuxClick(event) { if (!isMiddleButton(event)) return; event.preventDefault(); open2(event); } function onMouseDown(event) { if (isMiddleButton(event)) event.preventDefault(); } const imgLoaded = vue.ref(false); vue.watch( () => { var _a; return (_a = detail.value) == null ? void 0 : _a.cover_url; }, () => { imgLoaded.value = false; } ); const cardRef = vue.ref(null); let io = null; vue.onMounted(() => { if (typeof IntersectionObserver === "undefined") { emit("visible", props.entry); return; } io = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting)) { emit("visible", props.entry); } }, { rootMargin: "200px" } ); if (cardRef.value) io.observe(cardRef.value); }); vue.onBeforeUnmount(() => { io == null ? void 0 : io.disconnect(); io = null; }); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", { ref_key: "cardRef", ref: cardRef, class: "fqa-card", role: "link", tabindex: "0", "aria-label": title.value, onMouseenter: onEnter2, onMouseleave: _cache[2] || (_cache[2] = ($event) => emit("leave")), onContextmenu: onContextMenu, onClick: open2, onAuxclick: onAuxClick, onMousedown: onMouseDown, onKeydown: [ vue.withKeys(vue.withModifiers(open2, ["prevent"]), ["enter"]), vue.withKeys(vue.withModifiers(open2, ["prevent"]), ["space"]) ] }, [ !detail.value ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, [ _cache[3] || (_cache[3] = vue.createElementVNode("div", { class: "fqa-sk-cover fqa-sk-anim" }, null, -1)), _cache[4] || (_cache[4] = vue.createElementVNode("div", { class: "fqa-sk-line fqa-sk-anim", style: { "width": "90%" } }, null, -1)), _cache[5] || (_cache[5] = vue.createElementVNode("div", { class: "fqa-sk-line fqa-sk-anim", style: { "width": "55%" } }, null, -1)) ], 64)) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 1 }, [ vue.createElementVNode("div", _hoisted_2$8, [ vue.createElementVNode("img", { class: vue.normalizeClass(["fqa-cover-img", { "fqa-cover-img-loading": !imgLoaded.value }]), crossorigin: "anonymous", loading: "lazy", referrerpolicy: "no-referrer", src: detail.value.cover_url, alt: title.value, onLoad: _cache[0] || (_cache[0] = ($event) => imgLoaded.value = true), onError: _cache[1] || (_cache[1] = ($event) => imgLoaded.value = true) }, null, 42, _hoisted_3$8), tag.value ? (vue.openBlock(), vue.createElementBlock("span", { key: 0, class: vue.normalizeClass(["fqa-cover-tag", tag.value.cls]) }, vue.toDisplayString(tag.value.text), 3)) : vue.createCommentVNode("", true), progressPercent.value > 0 ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_4$7, [ vue.createElementVNode("span", { class: "fqa-cover-progress-bar", style: vue.normalizeStyle({ width: progressPercent.value + "%" }) }, null, 4) ])) : vue.createCommentVNode("", true) ]), vue.createElementVNode("div", { class: "fqa-card-title", title: title.value }, vue.toDisplayString(title.value), 9, _hoisted_5$7), vue.createElementVNode("div", { class: "fqa-card-sub", title: detail.value.current_chapter_title }, vue.toDisplayString(progressText.value), 9, _hoisted_6$7) ], 64)) ], 40, _hoisted_1$8); }; } }); const _hoisted_1$7 = ["aria-label"]; const _hoisted_2$7 = { class: "fqa-group-cover" }; const _hoisted_3$7 = { class: "fqa-group-grid" }; const _hoisted_4$6 = ["src", "alt"]; const _hoisted_5$6 = ["title"]; const _hoisted_6$6 = { class: "fqa-card-sub" }; const _sfc_main$7 = /* @__PURE__ */ vue.defineComponent({ __name: "BookGroupCard", props: { group: {} }, emits: ["open", "visible"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; const previewBooks = vue.computed(() => props.group.books.slice(0, 4)); const covers = vue.computed( () => previewBooks.value.map((entry) => entry.detail).filter((detail) => !!detail) ); const cardRef = vue.ref(null); let io = null; function request() { const pending = previewBooks.value.filter((entry) => !entry.detail); if (pending.length) emit("visible", pending); } vue.onMounted(() => { if (typeof IntersectionObserver === "undefined") { request(); return; } io = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting)) request(); }, { rootMargin: "200px" } ); if (cardRef.value) io.observe(cardRef.value); }); vue.onBeforeUnmount(() => { io == null ? void 0 : io.disconnect(); io = null; }); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", { ref_key: "cardRef", ref: cardRef, class: "fqa-card", role: "button", tabindex: "0", "aria-label": `分组 ${__props.group.name}`, onClick: _cache[0] || (_cache[0] = ($event) => emit("open", __props.group)), onKeydown: [ _cache[1] || (_cache[1] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.group), ["prevent"]), ["enter"])), _cache[2] || (_cache[2] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.group), ["prevent"]), ["space"])) ] }, [ vue.createElementVNode("div", _hoisted_2$7, [ vue.createElementVNode("div", _hoisted_3$7, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(covers.value, (detail) => { return vue.openBlock(), vue.createElementBlock("div", { key: detail.book_id, class: "fqa-group-cell" }, [ vue.createElementVNode("img", { crossorigin: "anonymous", loading: "lazy", referrerpolicy: "no-referrer", src: detail.cover_url, alt: detail.title }, null, 8, _hoisted_4$6) ]); }), 128)) ]) ]), vue.createElementVNode("div", { class: "fqa-card-title", title: __props.group.name }, vue.toDisplayString(__props.group.name), 9, _hoisted_5$6), vue.createElementVNode("div", _hoisted_6$6, "共" + vue.toDisplayString(__props.group.books.length) + "本书", 1) ], 40, _hoisted_1$7); }; } }); const SECOND = 1e3; const MINUTE = 60 * SECOND; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; const MONTH = 30 * DAY; const YEAR = 365 * DAY; function normalizeTimestamp(ts) { if (!ts || ts <= 0) return 0; return ts < 1e12 ? ts * 1e3 : ts; } function fromNow(ts) { const time = normalizeTimestamp(ts); if (!time) return "从未"; const diff = Date.now() - time; if (diff < MINUTE) return "刚刚"; if (diff < HOUR) return `${Math.floor(diff / MINUTE)}分钟前`; if (diff < DAY) return `${Math.floor(diff / HOUR)}小时前`; if (diff < MONTH) return `${Math.floor(diff / DAY)}天前`; if (diff < YEAR) return `${Math.floor(diff / MONTH)}个月前`; return `${Math.floor(diff / YEAR)}年前`; } function formatDateTime(ts) { const time = normalizeTimestamp(ts); if (!time) return "未知"; const d = new Date(time); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } const _hoisted_1$6 = ["title"]; const _hoisted_2$6 = { key: 0, class: "fqa-hover-author" }; const _hoisted_3$6 = { class: "fqa-hover-stats" }; const _hoisted_4$5 = ["title"]; const _hoisted_5$5 = { class: "fqa-hover-stat-k" }; const _hoisted_6$5 = { class: "fqa-hover-stat" }; const _hoisted_7$5 = { class: "fqa-hover-stat-v" }; const _hoisted_8$5 = { class: "fqa-hover-stat" }; const _hoisted_9$5 = { class: "fqa-hover-stat-v" }; const _hoisted_10$5 = { class: "fqa-hover-seg" }; const _hoisted_11$4 = { class: "fqa-hover-abstract" }; const _hoisted_12$4 = { key: 0, class: "fqa-hover-chapter" }; const _sfc_main$6 = /* @__PURE__ */ vue.defineComponent({ __name: "BookHoverCard", props: { entry: {}, x: {}, y: {}, height: {}, visible: { type: Boolean } }, emits: ["panel-enter", "panel-leave"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; const detail = vue.computed(() => { var _a; return ((_a = props.entry) == null ? void 0 : _a.detail) ?? null; }); const item = vue.computed(() => { var _a; return ((_a = props.entry) == null ? void 0 : _a.item) ?? null; }); const readAt = vue.computed(() => { var _a; return fromNow((_a = item.value) == null ? void 0 : _a.last_read_timestamp); }); const addedAt = vue.computed(() => { var _a; return fromNow((_a = item.value) == null ? void 0 : _a.add_shelf_time); }); const updatedAt = vue.computed(() => { var _a; return fromNow((_a = detail.value) == null ? void 0 : _a.last_chapter_update_time); }); const updatedAtFull = vue.computed(() => { var _a; return formatDateTime((_a = detail.value) == null ? void 0 : _a.last_chapter_update_time); }); const showUpdateTime = vue.ref(false); const latestChapter = vue.computed(() => { const d = detail.value; if (!d) return "—"; return d.total_chapter_count ? `${d.total_chapter_count}章` : "—"; }); const pickedTab = vue.ref(null); const chapterText = vue.computed(() => { var _a, _b; return ((_b = (_a = detail.value) == null ? void 0 : _a.current_chapter_summary) == null ? void 0 : _b.trim()) ?? ""; }); const bookText = vue.computed(() => { var _a, _b; return ((_b = (_a = detail.value) == null ? void 0 : _a.summary) == null ? void 0 : _b.trim()) ?? ""; }); const activeTab = vue.computed(() => { if (pickedTab.value) return pickedTab.value; return chapterText.value ? "chapter" : "book"; }); const abstractText = vue.computed(() => { const text = activeTab.value === "chapter" ? chapterText.value : bookText.value; return text || "暂无数据"; }); vue.watch( () => { var _a; return (_a = props.entry) == null ? void 0 : _a.item.book_id; }, () => { pickedTab.value = null; showUpdateTime.value = false; } ); const root = vue.ref(null); const supportsPopover = typeof HTMLElement !== "undefined" && typeof HTMLElement.prototype.showPopover === "function"; vue.watch( () => props.visible && !!detail.value, (show) => { const el = root.value; if (!el || !supportsPopover) return; try { if (show) el.showPopover(); else el.hidePopover(); } catch { } } ); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", { id: "fqa-bookshelf-hover", ref_key: "root", ref: root, popover: "manual", class: vue.normalizeClass({ "fqa-visible": __props.visible && !!detail.value }), style: vue.normalizeStyle({ left: __props.x + "px", top: __props.y + "px" }), onMouseenter: _cache[4] || (_cache[4] = ($event) => emit("panel-enter")), onMouseleave: _cache[5] || (_cache[5] = ($event) => emit("panel-leave")) }, [ detail.value ? (vue.openBlock(), vue.createElementBlock("div", { key: 0, class: "fqa-hover-inner", style: vue.normalizeStyle(__props.height ? { height: __props.height + "px" } : void 0) }, [ vue.createElementVNode("div", { class: "fqa-hover-title", title: detail.value.title }, vue.toDisplayString(detail.value.title), 9, _hoisted_1$6), detail.value.author ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_2$6, vue.toDisplayString(detail.value.author), 1)) : vue.createCommentVNode("", true), vue.createElementVNode("div", _hoisted_3$6, [ vue.createElementVNode("div", { class: "fqa-hover-stat", onMouseenter: _cache[0] || (_cache[0] = ($event) => showUpdateTime.value = true), onMouseleave: _cache[1] || (_cache[1] = ($event) => showUpdateTime.value = false) }, [ vue.createElementVNode("div", { class: "fqa-hover-stat-v", title: updatedAtFull.value }, vue.toDisplayString(showUpdateTime.value ? updatedAt.value : latestChapter.value), 9, _hoisted_4$5), vue.createElementVNode("div", _hoisted_5$5, vue.toDisplayString(showUpdateTime.value ? "更新于" : "最新章"), 1) ], 32), vue.createElementVNode("div", _hoisted_6$5, [ vue.createElementVNode("div", _hoisted_7$5, vue.toDisplayString(readAt.value), 1), _cache[6] || (_cache[6] = vue.createElementVNode("div", { class: "fqa-hover-stat-k" }, "阅读过", -1)) ]), vue.createElementVNode("div", _hoisted_8$5, [ vue.createElementVNode("div", _hoisted_9$5, vue.toDisplayString(addedAt.value), 1), _cache[7] || (_cache[7] = vue.createElementVNode("div", { class: "fqa-hover-stat-k" }, "已加入书架", -1)) ]) ]), vue.createElementVNode("div", _hoisted_10$5, [ vue.createElementVNode("button", { class: vue.normalizeClass(["fqa-hover-seg-btn", { "fqa-hover-seg-active": activeTab.value === "chapter" }]), onClick: _cache[2] || (_cache[2] = ($event) => pickedTab.value = "chapter") }, " 本章梗概 ", 2), vue.createElementVNode("button", { class: vue.normalizeClass(["fqa-hover-seg-btn", { "fqa-hover-seg-active": activeTab.value === "book" }]), onClick: _cache[3] || (_cache[3] = ($event) => pickedTab.value = "book") }, " 全书简介 ", 2) ]), vue.createElementVNode("div", _hoisted_11$4, [ activeTab.value === "chapter" && detail.value.current_chapter_title ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_12$4, vue.toDisplayString(detail.value.current_chapter_title), 1)) : vue.createCommentVNode("", true), vue.createTextVNode(" " + vue.toDisplayString(abstractText.value), 1) ]) ], 4)) : vue.createCommentVNode("", true) ], 38); }; } }); const _hoisted_1$5 = ["aria-disabled", "onMouseenter", "onClick"]; const _hoisted_2$5 = { key: 0, class: "fqa-menu-arrow" }; const _hoisted_3$5 = ["onClick"]; const MARGIN = 8; const _sfc_main$5 = /* @__PURE__ */ vue.defineComponent({ __name: "ContextMenu", props: { visible: { type: Boolean }, x: {}, y: {}, items: {} }, emits: ["select", "close"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; const rootRef = vue.ref(null); const subRef = vue.ref(null); const openKey = vue.ref(null); const pos = vue.ref({ x: 0, y: 0 }); const subPos = vue.ref({ x: 0, y: 0 }); async function place() { pos.value = { x: props.x, y: props.y }; await vue.nextTick(); const el = rootRef.value; if (!el) return; const { width, height } = el.getBoundingClientRect(); let x = props.x; let y = props.y; if (x + width > window.innerWidth - MARGIN) x = props.x - width; if (y + height > window.innerHeight - MARGIN) y = window.innerHeight - height - MARGIN; pos.value = { x: Math.max(MARGIN, x), y: Math.max(MARGIN, y) }; } vue.watch( () => [props.visible, props.x, props.y], () => { openKey.value = null; if (props.visible) void place(); } ); async function openSub(item, event) { var _a; if (!((_a = item.children) == null ? void 0 : _a.length)) { openKey.value = null; return; } openKey.value = item.key; const row = event.currentTarget; const rect = row.getBoundingClientRect(); subPos.value = { x: rect.right - 2, y: rect.top }; await vue.nextTick(); const el = subRef.value; if (!el) return; const { width, height } = el.getBoundingClientRect(); let x = rect.right - 2; let y = rect.top; if (x + width > window.innerWidth - MARGIN) x = rect.left - width + 2; if (y + height > window.innerHeight - MARGIN) y = window.innerHeight - height - MARGIN; subPos.value = { x: Math.max(MARGIN, x), y: Math.max(MARGIN, y) }; } function choose(item) { var _a; if (item.disabled || ((_a = item.children) == null ? void 0 : _a.length)) return; emit("select", item.key); emit("close"); } function onDocPointer(e) { var _a, _b; const t = e.target; if (((_a = rootRef.value) == null ? void 0 : _a.contains(t)) || ((_b = subRef.value) == null ? void 0 : _b.contains(t))) return; emit("close"); } function onKey(e) { if (e.key === "Escape") emit("close"); } vue.onMounted(() => { document.addEventListener("pointerdown", onDocPointer, true); document.addEventListener("keydown", onKey); window.addEventListener("scroll", () => emit("close"), true); }); vue.onBeforeUnmount(() => { document.removeEventListener("pointerdown", onDocPointer, true); document.removeEventListener("keydown", onKey); }); const style = vue.computed(() => ({ left: `${pos.value.x}px`, top: `${pos.value.y}px` })); const subStyle = vue.computed(() => ({ left: `${subPos.value.x}px`, top: `${subPos.value.y}px` })); const activeChildren = vue.computed( () => { var _a; return ((_a = props.items.find((i2) => i2.key === openKey.value)) == null ? void 0 : _a.children) ?? []; } ); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock(vue.Fragment, null, [ __props.visible ? (vue.openBlock(), vue.createElementBlock("div", { key: 0, ref_key: "rootRef", ref: rootRef, class: "fqa-menu", style: vue.normalizeStyle(style.value), role: "menu" }, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(__props.items, (item) => { var _a; return vue.openBlock(), vue.createElementBlock("div", { key: item.key, class: vue.normalizeClass(["fqa-menu-row", { "fqa-menu-danger": item.danger, "fqa-menu-disabled": item.disabled, "fqa-menu-open": openKey.value === item.key }]), role: "menuitem", "aria-disabled": item.disabled, onMouseenter: ($event) => openSub(item, $event), onClick: ($event) => choose(item) }, [ vue.createElementVNode("span", null, vue.toDisplayString(item.label), 1), ((_a = item.children) == null ? void 0 : _a.length) ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_2$5, "›")) : vue.createCommentVNode("", true) ], 42, _hoisted_1$5); }), 128)) ], 4)) : vue.createCommentVNode("", true), __props.visible && activeChildren.value.length ? (vue.openBlock(), vue.createElementBlock("div", { key: 1, ref_key: "subRef", ref: subRef, class: "fqa-menu fqa-menu-sub", style: vue.normalizeStyle(subStyle.value), role: "menu" }, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(activeChildren.value, (child) => { return vue.openBlock(), vue.createElementBlock("div", { key: child.key, class: vue.normalizeClass(["fqa-menu-row", { "fqa-menu-disabled": child.disabled }]), role: "menuitem", onClick: ($event) => choose(child) }, [ vue.createElementVNode("span", null, vue.toDisplayString(child.label), 1) ], 10, _hoisted_3$5); }), 128)) ], 4)) : vue.createCommentVNode("", true) ], 64); }; } }); const DETAIL_BATCH_SIZE = 20; const PAGE_SIZE = 24; const TABS = [ { key: "all", label: "全部" }, { key: "group", label: "分组" }, { key: "publish", label: "出版" } ]; function useBookshelf() { const entries = vue.ref([]); const loading = vue.ref(false); const detailLoading = vue.ref(false); const error = vue.ref(null); let loadToken = 0; let detailCache = /* @__PURE__ */ new Map(); let inflight = /* @__PURE__ */ new Set(); function applyDetails(details) { if (!details.length) return; const byId = new Map(details.map((d) => [d.book_id, d])); entries.value = entries.value.map((entry) => { const detail = byId.get(entry.item.book_id); return detail ? { ...entry, detail } : entry; }); } async function ensureDetails(items) { const token = loadToken; const pending = items.filter( (item) => !detailCache.has(item.book_id) && !inflight.has(item.book_id) ); if (!pending.length) return; pending.forEach((item) => inflight.add(item.book_id)); detailLoading.value = true; try { for (const batch of chunk(pending, DETAIL_BATCH_SIZE)) { if (token !== loadToken) return; try { const details = await multidetail(batch); if (token !== loadToken) return; details.forEach((d) => detailCache.set(d.book_id, d)); applyDetails(details); } catch (err) { console.error("[fqa:bookshelf] 加载书籍详情失败:", err); } } } finally { pending.forEach((item) => inflight.delete(item.book_id)); if (token === loadToken && inflight.size === 0) detailLoading.value = false; } } async function load2(force = false) { const token = ++loadToken; inflight.clear(); if (force) detailCache = /* @__PURE__ */ new Map(); loading.value = true; error.value = null; try { const items = await getBookshelf(); if (token !== loadToken) return; items.sort((a, b) => b.last_operate_time - a.last_operate_time); entries.value = items.map((item) => ({ item, detail: detailCache.get(item.book_id) ?? null })); loading.value = false; } catch (err) { if (token !== loadToken) return; console.error("[fqa:bookshelf] 加载书架失败:", err); error.value = err instanceof Error ? err.message : String(err); loading.value = false; } } const groups = vue.computed(() => { const map = /* @__PURE__ */ new Map(); for (const entry of entries.value) { const name2 = entry.item.group_name; if (!name2) continue; let group = map.get(name2); if (!group) { group = { name: name2, books: [], last_operate_time: 0 }; map.set(name2, group); } group.books.push(entry); group.last_operate_time = Math.max(group.last_operate_time, entry.item.last_operate_time); } return [...map.values()].sort((a, b) => b.last_operate_time - a.last_operate_time); }); const allCells = vue.computed(() => { const sortable = []; for (const entry of entries.value) { if (entry.item.group_name) continue; sortable.push({ time: entry.item.last_operate_time, cell: { kind: "book", key: `book:${entry.item.book_id}`, entry } }); } for (const group of groups.value) { sortable.push({ time: group.last_operate_time, cell: { kind: "group", key: `group:${group.name}`, group } }); } return sortable.sort((a, b) => b.time - a.time).map((s) => s.cell); }); const groupCells = vue.computed( () => groups.value.map((group) => ({ kind: "group", key: `group:${group.name}`, group })) ); const publishCells = vue.computed( () => entries.value.filter((entry) => entry.item.is_publish).map((entry) => ({ kind: "book", key: `book:${entry.item.book_id}`, entry })) ); const counts = vue.computed(() => ({ all: entries.value.length, group: groups.value.length, publish: entries.value.filter((entry) => entry.item.is_publish).length })); function cellsOf(tab) { if (tab === "group") return groupCells.value; if (tab === "publish") return publishCells.value; return allCells.value; } function findGroup(name2) { return groups.value.find((group) => group.name === name2) ?? null; } return { entries, loading, detailLoading, error, groups, counts, load: load2, ensureDetails, cellsOf, findGroup, PAGE_SIZE }; } class CancelledError extends Error { constructor() { super("已取消"); this.name = "CancelledError"; } } class DownloadTask { constructor() { __publicField(this, "state", { title: "准备中…", current: 0, total: 0, subtitle: "", percentOnly: false, done: false, error: null, cancelled: false }); __publicField(this, "listeners", /* @__PURE__ */ new Set()); } get snapshot() { return this.state; } get isCancelled() { return this.state.cancelled; } subscribe(listener) { this.listeners.add(listener); listener(this.state); return () => this.listeners.delete(listener); } emit(patch) { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) { try { listener(this.state); } catch (err) { console.error("[fqa:download] 进度回调异常:", err); } } } /** 进入新阶段,重置计数 */ stage(title, total = 0) { this.emit({ title, total, current: 0, subtitle: "", percentOnly: false }); } /** 进入一个按百分比汇报的阶段(如 zip 压缩) */ stagePercent(title) { this.emit({ title, total: 100, current: 0, subtitle: "", percentOnly: true }); } update(current, subtitle) { this.emit(subtitle === void 0 ? { current } : { current, subtitle }); } /** 在当前计数上累加,供并发/分批场景使用 */ advance(delta = 1, subtitle) { this.update(this.state.current + delta, subtitle); } setTotal(total) { this.emit({ total }); } setSubtitle(subtitle) { this.emit({ subtitle }); } cancel() { if (this.state.done) return; this.emit({ cancelled: true, title: "已取消", subtitle: "" }); } finish(title = "完成") { this.emit({ done: true, title, subtitle: "" }); } fail(error) { const message = error instanceof Error ? error.message : String(error); this.emit({ done: true, error: message, title: "下载失败" }); } /** 取消时抛出,让调用栈直接退出 */ throwIfCancelled() { if (this.state.cancelled) throw new CancelledError(); } } const MAX_BATCH_SIZE = 30; function hasContent(chapter) { return Boolean(chapter == null ? void 0 : chapter.content) && chapter.content !== "Invalid"; } async function fetchChapters(itemIds, bookId2, task, options = {}) { var _a; const batchSize = Math.min( Math.max(1, options.batchSize ?? settings$1.downloadBatchSize), MAX_BATCH_SIZE ); const interval = Math.max(0, options.interval ?? settings$1.downloadInterval); const retries = Math.max(0, options.retries ?? settings$1.downloadRetries); const chapters = {}; let pending = [...itemIds]; task.stage("缓存章节…", itemIds.length); for (let round = 0; round <= retries && pending.length > 0; round++) { if (round > 0) { console.warn(`[fqa:download] 第 ${round} 轮重试,剩余 ${pending.length} 章`); task.setSubtitle(`重试 ${pending.length} 章(第 ${round} 轮)`); await sleep(Math.max(interval, 1e3)); } const missed = []; const batches = chunk(pending, batchSize); for (let i2 = 0; i2 < batches.length; i2++) { task.throwIfCancelled(); const batch = batches[i2]; try { const result = await getChapters(batch, bookId2); for (const itemId2 of batch) { const chapter = result[itemId2]; if (hasContent(chapter)) { chapters[itemId2] = chapter; } else { missed.push(itemId2); } } } catch (err) { if (err instanceof CancelledError) throw err; console.error("[fqa:download] 批量获取失败:", err); missed.push(...batch); } const got = Object.keys(chapters).length; const lastTitle = ((_a = options.titleOf) == null ? void 0 : _a.call(options, batch[batch.length - 1])) ?? ""; task.update(got, lastTitle); if (i2 < batches.length - 1 && interval > 0) await sleep(interval); } pending = missed; } if (pending.length > 0) { console.warn(`[fqa:download] ${pending.length} 章最终失败:`, pending.slice(0, 20)); } return { chapters, failed: pending }; } const FLAG = "__fqaSetImmediate"; function installSetImmediate(target) { if (target[FLAG]) return false; const tasks = /* @__PURE__ */ new Map(); let nextId = 1; const run = (id) => { const task = tasks.get(id); if (!task) return; tasks.delete(id); try { task.fn(...task.args); } catch (err) { console.error("[fqa:zipfix] setImmediate 任务异常:", err); } }; let schedule; if (typeof MessageChannel === "function") { const channel = new MessageChannel(); channel.port1.onmessage = (event) => run(event.data); schedule = (id) => channel.port2.postMessage(id); } else { schedule = (id) => void setTimeout(run, 0, id); } target["setImmediate"] = (fn, ...args) => { const id = nextId++; tasks.set(id, { fn, args }); schedule(id); return id; }; target["clearImmediate"] = (id) => void tasks.delete(id); target[FLAG] = true; return true; } function fixZipScheduler() { const patched = installSetImmediate(globalThis); try { const real = unsafeWindow; if (real && real !== globalThis) { installSetImmediate(real); } } catch { } if (patched) console.debug("[fqa:zipfix] 已替换 setImmediate,修复 JSZip 异步调度"); } /*! * llepub-saver - v1.0.3 (TypeScript port) * An EPUB ebook saving library for browser. * License: GPLv3 (https://www.gnu.org/licenses/gpl-3.0.html) * * 依赖: * - Moment.js (https://momentjs.com/) - MIT License * - JSZip (https://stuk.github.io/jszip/) - Dual-licensed under MIT and GPLv3. * * 相对原 JS 版的改动: * - 补齐类型,改成 ES module import(JSZip / moment 不再依赖全局变量); * - 网络请求可注入(见 EpubSaverOptions),默认用页面 fetch。番茄的部分图床 * 不给 CORS 头,调用方可以传入走 GM_xmlhttpRequest 的实现; * - 图片扩展名改为嗅探文件头,注入式 fetch 拿不到响应头也能判断; * - 写入 XML 的文本(标题、作者、简介等)统一转义,否则含 & < 的书名会 * 生成非法 XML,阅读器直接打不开。 */ fixZipScheduler(); function yieldFrame() { return new Promise((resolve) => { let settled = false; const done = () => { if (settled) return; settled = true; resolve(); }; const raf = globalThis.requestAnimationFrame; if (typeof raf === "function") raf(done); setTimeout(done, 32); }); } const DEFAULT_I18N = { en: { cover: "Cover", tableOfContents: "Table of Contents", chapters: "Chapters" }, "zh-CN": { cover: "封面", tableOfContents: "目录", chapters: "章节" }, "zh-TW": { cover: "封面", tableOfContents: "目錄", chapters: "章節" }, es: { cover: "Portada", tableOfContents: "Índice", chapters: "Capítulos" }, fr: { cover: "Couverture", tableOfContents: "Table des matières", chapters: "Chapitres" }, de: { cover: "Cover", tableOfContents: "Inhaltsverzeichnis", chapters: "Kapitel" }, ja: { cover: "表紙", tableOfContents: "目次", chapters: "章" }, ko: { cover: "표지", tableOfContents: "목차", chapters: "장" }, ru: { cover: "Обложка", tableOfContents: "Содержание", chapters: "Главы" }, pt: { cover: "Capa", tableOfContents: "Índice", chapters: "Capítulos" }, it: { cover: "Copertina", tableOfContents: "Indice", chapters: "Capitoli" } }; const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp", "svg"]; const DC_ELEMENTS = /* @__PURE__ */ new Set([ "title", "creator", "subject", "description", "publisher", "contributor", "date", "type", "format", "identifier", "source", "language", "relation", "coverage", "rights" ]); function escapeXml(value) { return String(value ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } function sniffImageExtension(buffer, url) { var _a, _b; const b = new Uint8Array(buffer.slice(0, 16)); if (b[0] === 255 && b[1] === 216) return "jpg"; if (b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71) return "png"; if (b[0] === 71 && b[1] === 73 && b[2] === 70) return "gif"; if (b[0] === 82 && b[1] === 73 && b[2] === 70 && b[3] === 70 && b[8] === 87 && b[9] === 69 && b[10] === 66 && b[11] === 80) return "webp"; const head = new TextDecoder().decode(b).trimStart(); if (head.startsWith(" 内容。idx 0 是全局样式 */ __publicField(this, "cssFiles", /* @__PURE__ */ new Map()); /** addCSSMap 的样式:EPUB 内文件名 -> 内容 */ __publicField(this, "cssMap", /* @__PURE__ */ new Map()); /** 正文里的原始 CSS 路径 -> EPUB 内相对路径 */ __publicField(this, "cssPathMapping", /* @__PURE__ */ new Map()); /** 正文插图:文件名 -> 数据 */ __publicField(this, "images", /* @__PURE__ */ new Map()); __publicField(this, "coverBuffer", null); __publicField(this, "coverExtension", null); __publicField(this, "i18n", { ...DEFAULT_I18N }); __publicField(this, "fetchBinary"); __publicField(this, "fetchText"); __publicField(this, "imageMode"); this.fetchBinary = options.fetchBinary ?? defaultFetchBinary; this.fetchText = options.fetchText ?? defaultFetchText; this.imageMode = options.images ?? "download"; this.setInfo("identifier", getCrypto().randomUUID(), { scheme: "uuid" }); this.setInfo("date", moment().format("YYYY-MM-DDTHH:mm:ss[Z]"), { "opf:event": "modification" }); this.setInfo("language", "en"); this.setInfo("title", "Untitled Book"); this.setInfo("creator", "Unknown Author"); } setI18n(language, translations) { this.i18n[language] = { ...this.i18n[language] ?? this.i18n["en"], ...translations }; } t(key) { var _a, _b; const language = ((_a = this.metadata.get("language")) == null ? void 0 : _a.value) ?? "en"; return ((_b = this.i18n[language]) == null ? void 0 : _b[key]) ?? this.i18n["en"][key] ?? key; } meta(key, fallback) { var _a; return ((_a = this.metadata.get(key)) == null ? void 0 : _a.value) || fallback; } setInfo(key, value, options = {}) { this.metadata.set(key, { value, options }); } /** 设置封面。传 URL 会去下载,也可以直接给字节 */ async cover(input) { if (typeof input !== "string") { this.coverBuffer = input; this.coverExtension = sniffImageExtension(input, ""); return; } try { const buffer = await this.fetchBinary(input); this.coverBuffer = buffer; this.coverExtension = sniffImageExtension(buffer, input); } catch (error) { throw new Error(`Failed to fetch cover image: ${error.message}`); } } async addVolume(idx, title, options = {}) { const volume = new EpubVolume(idx, title, this, options); this.volumes.set(idx, volume); return volume; } /** 注册一份样式表。idx 0 会作为全局样式被章节引用 */ async addCSS(idx, content, mappath) { const existing = this.cssFiles.get(idx); if (existing && mappath && existing.mappath !== mappath) { throw new Error(`CSS index ${idx} already exists with different mappath`); } this.cssFiles.set(idx, { content, mappath }); } /** * 注册正文 引用的样式表。 * 传 { 'Styles/main.css': 'https://…' | 'css 文本' }, * 值以 http 开头时会去下载。 */ async addCSSMap(pathMap) { for (const [originalPath, urlOrContent] of Object.entries(pathMap)) { const normalizedOriginal = originalPath.startsWith("Styles/") ? originalPath.substring(7) : originalPath; let finalPath = normalizedOriginal; if (/^style\d+\.css$/.test(finalPath)) { let counter = 1e3; while (this.cssFiles.has(counter) || this.cssMap.has(`style${counter}.css`)) { counter++; } finalPath = `style${counter}.css`; console.warn(`CSS conflict detected, renamed ${normalizedOriginal} to ${finalPath}`); } this.cssPathMapping.set(originalPath, `../Styles/${finalPath}`); if (urlOrContent.startsWith("http")) { try { this.cssMap.set(finalPath, await this.fetchText(urlOrContent)); } catch (error) { throw new Error(`Failed to fetch CSS: ${error.message}`); } } else { this.cssMap.set(finalPath, urlOrContent); } } } /** 内容里的 Unicode 转义序列还原。失败时原样返回 */ static decodeEscapes(content) { try { return JSON.parse('"' + content.replace(/"/g, '\\"') + '"'); } catch { return content; } } static parseFragment(content) { const parser = new DOMParser(); const whole = /]/i.test(content) || content.includes("${content}`; return { doc: parser.parseFromString(source, "text/html"), whole }; } static serializeFragment(doc, whole) { if (whole) return new XMLSerializer().serializeToString(doc.documentElement); return doc.body.innerHTML; } /** 把正文里的 CSS 引用重写到 EPUB 内的路径,map 里没有的直接删掉 */ async processCSSLinksInContent(content) { const decoded = _EpubSaver.decodeEscapes(content); try { const { doc, whole } = _EpubSaver.parseFragment(decoded); const cssLinks = doc.querySelectorAll('link[rel="stylesheet"], link[type="text/css"]'); for (const link of cssLinks) { const href = link.getAttribute("href"); if (!href) continue; const mapped = this.cssPathMapping.get(href); if (mapped) { link.setAttribute("href", mapped); console.debug(`Updated CSS reference: ${href} -> ${mapped}`); } else { console.warn(`CSS reference not found in CSSMap, removing: ${href}`); link.remove(); } } return _EpubSaver.serializeFragment(doc, whole); } catch (error) { console.warn("Error processing CSS links in content:", error); return decoded; } } /** 下载正文里的外链图片并改写成 EPUB 内相对路径,下载失败的删掉 img */ async downloadImagesFromContent(content) { if (this.imageMode === "keep") return content; const decoded = content.includes("\\u") ? _EpubSaver.decodeEscapes(content) : content; try { const { doc, whole } = _EpubSaver.parseFragment(decoded); const images = doc.querySelectorAll("img[src]"); let imageCounter = this.images.size; for (const img of images) { const src = img.getAttribute("src"); if (!src || !/^https?:\/\//.test(src)) continue; if (this.imageMode === "remove") { img.remove(); continue; } let finalUrl = src; if (src.startsWith("http://")) finalUrl = src.replace("http://", "https://"); let stored = null; for (const url of finalUrl === src ? [src] : [finalUrl, src]) { try { const buffer = await this.fetchBinary(url); const extension = sniffImageExtension(buffer, url); const filename = `image_${imageCounter++}.${extension}`; this.images.set(filename, { buffer, extension, originalUrl: src }); stored = filename; break; } catch (error) { console.warn(`Failed to download image ${url}:`, error.message); } } if (stored) { img.setAttribute("src", `../Images/${stored}`); } else { console.warn(`Removing failed image tag: ${src}`); img.remove(); } } return _EpubSaver.serializeFragment(doc, whole); } catch (error) { console.warn("Error processing images in content:", error); return decoded; } } generateContainer() { return ` `; } generateContentOpf() { let metadata = ""; for (const [key, data] of this.metadata) { const optionsStr = Object.entries(data.options).map(([k, v]) => `${k}="${escapeXml(v)}"`).join(" "); const attrs = optionsStr ? " " + optionsStr : ""; if (DC_ELEMENTS.has(key)) { metadata += ` ${escapeXml(data.value)} `; } else { metadata += ` ${escapeXml(data.value)} `; } } metadata += ` ${escapeXml( this.meta("date", moment().format("YYYY-MM-DDTHH:mm:ss[Z]")) )} `; if (this.coverBuffer) { metadata += ` `; } let manifest = ""; const spineItems = []; manifest += ` `; manifest += ` `; if (this.coverBuffer) { manifest += ` `; manifest += ` `; spineItems.push("cover"); } for (const [filename, imageData] of this.images) { const imageId = `img-${filename.replace(/[^a-zA-Z0-9]/g, "-")}`; manifest += ` `; } for (const idx of this.cssFiles.keys()) { manifest += ` `; } for (const path of this.cssMap.keys()) { const id = `css-map-${path.replace(/[^a-zA-Z0-9]/g, "-")}`; manifest += ` `; } for (const [volIdx, volume] of this.sortedVolumes()) { if (volume.options.createVolumePage) { const volumeId = `volume-page-${volIdx}`; manifest += ` `; spineItems.push(volumeId); } for (const [chapIdx] of volume.sortedChapters()) { const id = `chapter-${volIdx}-${chapIdx}`; manifest += ` `; spineItems.push(id); } } const spine = spineItems.map((id) => ` `).join("\n"); return ` ${metadata} ${manifest} ${spine} `; } sortedVolumes() { return Array.from(this.volumes.entries()).sort(([a], [b]) => a - b); } /** 卷在目录里的链接:有卷页指向卷页,否则指向首章 */ volumeLink(volIdx, volume) { var _a; if (volume.options.createVolumePage) return `Text/volume${volIdx}_index.xhtml`; const first = ((_a = volume.sortedChapters()[0]) == null ? void 0 : _a[0]) ?? 0; return `Text/volume${volIdx}_chapter${first}.xhtml`; } generateTocNcx() { const title = this.meta("title", "Untitled Book"); const uuid2 = this.meta("identifier", getCrypto().randomUUID()); let navPoints = ""; let playOrder = 1; if (this.coverBuffer) { navPoints += ` ${escapeXml(this.t("cover"))} `; } for (const [volIdx, volume] of this.sortedVolumes()) { const link = this.volumeLink(volIdx, volume); const chapters = volume.sortedChapters(); const expand = chapters.length > 1 || volume.options.alwaysShowVolumeTitle || volume.options.createVolumePage; navPoints += ` ${escapeXml(volume.title)} `; if (expand) { for (const [chapIdx, chapter] of chapters) { navPoints += ` ${escapeXml(chapter.title)} `; } } navPoints += ` `; } return ` ${escapeXml(title)} ${navPoints} `; } generateNavXhtml() { const title = this.meta("title", "Untitled Book"); let navItems = ""; if (this.coverBuffer) { navItems += ` ${escapeXml(this.t("cover"))} `; } for (const [volIdx, volume] of this.sortedVolumes()) { const link = this.volumeLink(volIdx, volume); const chapters = volume.sortedChapters(); const expand = chapters.length > 1 || volume.options.alwaysShowVolumeTitle || volume.options.createVolumePage; if (!expand) { navItems += ` ${escapeXml(volume.title)} `; continue; } navItems += ` ${escapeXml(volume.title)} `; for (const [chapIdx, chapter] of chapters) { navItems += ` ${escapeXml(chapter.title)} `; } navItems += ` `; } return ` ${escapeXml(title)} - ${escapeXml(this.t("tableOfContents"))} ${escapeXml(this.t("tableOfContents"))} ${navItems} `; } generateCoverXhtml() { if (!this.coverBuffer) return ""; const title = this.meta("title", "Untitled Book"); return ` ${escapeXml(title)} - ${escapeXml(this.t("cover"))} `; } generateVolumePageXhtml(volume, volIdx) { let cssLinks = ""; if (this.cssFiles.has(0)) { cssLinks += ` `; } let bodyContent = ` ${escapeXml(volume.title)} `; if (volume.options.volumePageType === "navigator") { const chapters = volume.sortedChapters(); if (chapters.length > 0) { bodyContent += ` ${escapeXml(this.t("chapters"))} `; for (const [chapIdx, chapter] of chapters) { bodyContent += ` ${escapeXml(chapter.title)} `; } bodyContent += ` `; } } return ` ${escapeXml(volume.title)} ${cssLinks} ${bodyContent} `; } /** 给 head 补 title / viewport / 样式引用,并按需插入正文标题 */ decorateChapterDoc(doc, chapter) { let head = doc.querySelector("head"); if (!head) { head = doc.createElement("head"); doc.documentElement.insertBefore(head, doc.body); } if (!head.querySelector("title")) { const titleElement = doc.createElement("title"); titleElement.textContent = chapter.title; head.insertBefore(titleElement, head.firstChild); } if (!head.querySelector('meta[name="viewport"]')) { const viewportMeta = doc.createElement("meta"); viewportMeta.setAttribute("name", "viewport"); viewportMeta.setAttribute("content", "width=device-width, height=device-height, initial-scale=1.0"); head.appendChild(viewportMeta); } const linkCss = (idx) => { const href = `../Styles/style${idx}.css`; if (!this.cssFiles.has(idx) || head.querySelector(`link[href="${href}"]`)) return; const cssLink = doc.createElement("link"); cssLink.setAttribute("rel", "stylesheet"); cssLink.setAttribute("type", "text/css"); cssLink.setAttribute("href", href); head.appendChild(cssLink); }; if (chapter.useGlobalCSS) linkCss(0); for (const cssIdx of chapter.cssIdxs) linkCss(cssIdx); const body = doc.querySelector("body"); if (body && chapter.insertTitle !== false) { const hasHeading = Boolean(body.querySelector("h1, h2, h3, h4, h5, h6")); if (chapter.insertTitle === true || !hasHeading) { const chapterHeading = doc.createElement("h2"); chapterHeading.textContent = chapter.title; body.insertBefore(chapterHeading, body.firstChild); } } } static finalizeXhtml(serialized) { let result = serialized; if (!result.startsWith("\n' + result; } result = result.replace(/]*>/i, (tag) => { let seen = false; const deduped = tag.replace(/\s+xmlns="[^"]*"/g, (match) => { if (seen) return ""; seen = true; return match; }); return seen ? deduped : deduped.replace(/^ 或 * 开头,症状就是所有段落跑进标题里)。 */ generateHtmlChapter(chapter) { const isDocument = /]/i.test(chapter.content); const source = isDocument ? chapter.content : `${chapter.content}`; try { const parser = new DOMParser(); const xdoc = parser.parseFromString(source, "application/xhtml+xml"); const doc = xdoc.querySelector("parsererror") ? parser.parseFromString(source, "text/html") : xdoc; this.decorateChapterDoc(doc, chapter); const serialized = new XMLSerializer().serializeToString(doc.documentElement); return _EpubSaver.finalizeXhtml(serialized); } catch (error) { console.warn("Failed to parse XHTML content:", error); return chapter.content; } } generateChapterXhtml(chapter) { if (chapter.type === "text") { let cssLinks = ""; if (chapter.useGlobalCSS && this.cssFiles.has(0)) { cssLinks += ` `; } for (const cssIdx of chapter.cssIdxs) { if (cssIdx !== 0 && this.cssFiles.has(cssIdx)) { cssLinks += ` `; } } const bodyContent = ` ${escapeXml(chapter.content).replace(/\n\n/g, "\n ").replace(/\n/g, "")}`; return ` ${escapeXml(chapter.title)} ${cssLinks} ${escapeXml(chapter.title)} ${bodyContent} `; } return this.generateHtmlChapter(chapter); } /** * 打包,返回 EPUB 字节。 * * 章节 XHTML 是在这里生成的(每章一次 DOMParser + XMLSerializer), * 长篇小说上千章就是几十秒的同步计算。全部一口气跑完的话主线程被占死: * 界面停在 0% 不动,取消按钮的点击事件也排不进事件循环。 * 所以按批生成、批间让出主线程,并在批之间检查取消。 */ async save(options = {}) { const { onWrite, onCompress, checkCancel } = options; const WRITE_BATCH = 20; this.zip.file("mimetype", "application/epub+zip", { compression: "STORE" }); this.zip.folder("META-INF"); this.zip.folder("OEBPS"); this.zip.folder("OEBPS/Text"); this.zip.folder("OEBPS/Styles"); this.zip.folder("OEBPS/Images"); const opts = { compression: "DEFLATE", compressionOptions: { level: 6 } }; this.zip.file("META-INF/container.xml", this.generateContainer(), opts); const tMeta = Date.now(); this.zip.file("OEBPS/content.opf", this.generateContentOpf(), opts); this.zip.file("OEBPS/toc.ncx", this.generateTocNcx(), opts); this.zip.file("OEBPS/nav.xhtml", this.generateNavXhtml(), opts); console.log(`[epub] 目录/清单生成完毕,用时 ${Date.now() - tMeta}ms`); if (this.coverBuffer) { this.zip.file(`OEBPS/Images/cover.${this.coverExtension}`, this.coverBuffer, opts); this.zip.file("OEBPS/Text/cover.xhtml", this.generateCoverXhtml(), opts); } for (const [filename, imageData] of this.images) { this.zip.file(`OEBPS/Images/${filename}`, imageData.buffer, opts); } for (const [idx, cssData] of this.cssFiles) { this.zip.file(`OEBPS/Styles/style${idx}.css`, cssData.content, opts); } for (const [path, content] of this.cssMap) { this.zip.file(`OEBPS/Styles/${path}`, content, opts); } const total = Array.from(this.volumes.values()).reduce((sum, v) => sum + v.size + (v.options.createVolumePage ? 1 : 0), 0); let written = 0; onWrite == null ? void 0 : onWrite(0, total); console.log(`[epub] 开始写入 ${total} 个章节文件`); for (const [volIdx, volume] of this.sortedVolumes()) { if (volume.options.createVolumePage) { this.zip.file( `OEBPS/Text/volume${volIdx}_index.xhtml`, this.generateVolumePageXhtml(volume, volIdx), opts ); written++; } for (const [chapIdx, chapter] of volume.sortedChapters()) { try { this.zip.file( `OEBPS/Text/volume${volIdx}_chapter${chapIdx}.xhtml`, this.generateChapterXhtml(chapter), opts ); } catch (err) { console.error( `[epub] 第 ${chapIdx} 章「${chapter.title}」生成失败,用占位内容替代:`, err ); this.zip.file( `OEBPS/Text/volume${volIdx}_chapter${chapIdx}.xhtml`, ` ${escapeXml(chapter.title)}${escapeXml(chapter.title)}本章生成失败`, opts ); } written++; if (written % WRITE_BATCH === 0) { onWrite == null ? void 0 : onWrite(written, total); await yieldFrame(); checkCancel == null ? void 0 : checkCancel(); } } } onWrite == null ? void 0 : onWrite(written, total); checkCancel == null ? void 0 : checkCancel(); console.log(`[epub] 章节写入完成 ${written}/${total},条目总数 ${Object.keys(this.zip.files).length},开始压缩`); const COMPRESS_TIMEOUT = 12e4; const t0 = Date.now(); let lastPercent = -1; let cancelled = null; const compressing = this.zip.generateAsync({ type: "uint8array" }, (metadata) => { if (lastPercent < 0) { console.log(`[epub] 压缩开始,首次回调用时 ${Date.now() - t0}ms`); } lastPercent = metadata.percent; try { checkCancel == null ? void 0 : checkCancel(); } catch (err) { cancelled = err; } try { onCompress == null ? void 0 : onCompress(metadata.percent, metadata.currentFile); } catch (err) { console.warn("[epub] 压缩进度回调异常:", err); } }); let timer; const guard = new Promise((_, reject) => { timer = setTimeout(() => { reject(new Error( `压缩超时(${COMPRESS_TIMEOUT / 1e3}s 无进展,最后进度 ${lastPercent < 0 ? "未开始" : lastPercent.toFixed(1) + "%"})。JSZip 的异步调度可能被页面环境干扰,请把控制台日志反馈给作者。` )); }, COMPRESS_TIMEOUT); }); try { const out = await Promise.race([compressing, guard]); if (cancelled) throw cancelled; console.log(`[epub] 压缩完成:${out.length} 字节,用时 ${Date.now() - t0}ms`); return out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength); } finally { if (timer) clearTimeout(timer); } } }; __publicField(_EpubSaver, "version", "1.0.3"); let EpubSaver = _EpubSaver; class EpubVolume { constructor(idx, title, saver, options = {}) { __publicField(this, "idx"); __publicField(this, "title"); __publicField(this, "options"); __publicField(this, "saver"); __publicField(this, "chapters", /* @__PURE__ */ new Map()); this.idx = idx; this.title = title; this.saver = saver; this.options = { alwaysShowVolumeTitle: false, createVolumePage: false, volumePageType: "navigator", ...options }; } get size() { return this.chapters.size; } sortedChapters() { return Array.from(this.chapters.entries()).sort(([a], [b]) => a - b); } async addChapter(idx, title, content, type = "text", useGlobalCSS = false, cssIdxs = [], insertTitle) { let processedContent = content; if (type === "html" || type === "xhtml") { processedContent = await this.saver.processCSSLinksInContent(content); processedContent = await this.saver.downloadImagesFromContent(processedContent); } this.chapters.set(idx, { title, content: processedContent, type, useGlobalCSS, cssIdxs, insertTitle }); } } const appcss = "/* \n * 番茄小说APP内置CSS\n * 版权归属为番茄小说APP官方\n * 修改以兼容网页环境\n *\n * line-space / theme-color 是 APP 排版引擎的私有属性,浏览器和 EPUB 阅读器都不认,\n * 已注释保留以便和原始样式对照。theme-color 的值 color1#0.7 还会让 CSS 解析器跑偏,\n * 把后面几条声明一起吃掉,必须注释而不能留着。\n */\n\nhtml{\n display:block;\n}\n\nbody{\n display:block;\n}\n\np{\n font-size:1em;\n text-align:justify;\n display:block;\n text-indent:2em;\n margin:0.6em 0em 0.6em 0em;\n}\n\ndiv{\n display:block;\n}\n\nh1{\n display:block;\n font-size:1.42em;\n font-weight:bold;\n margin:22px 0em 3em 0em;\n text-align:left;\n /* line-space:0.5em; */\n}\n\nh2{\n display:block;\n font-size:1.2em;\n font-weight:bold;\n margin:1em 0em 0.6em 0em;\n text-align:left;\n}\n\nh3{\n display:block;\n font-size:1em;\n font-weight:bold;\n margin:0em 0em 1em 0em;\n text-align:left;\n}\n\nsup{\n font-size:smaller;\n}\n\n.picture{\n text-indent:0em;\n text-align:center;\n margin:0em 0em 0em 0em;\n /* line-space:0em; */\n}\n\n.pictureDesc{\n font-size:0.73em;\n text-indent:0em;\n margin:0.4em 0em 1em 0em;\n /* theme-color:color1#0.7; */\n /* line-space:0.3em; */\n text-align:left;\n}\n\n.pictureTitle{\n font-size:0.73em;\n text-indent:0em;\n margin:1em 0em 0.3em 0em;\n /* theme-color:color1#0.7; */\n text-align:left;\n /* line-space:0em; */\n}\n\n.collectTitle{\n margin:80px 68px 0em 0px;\n font-size:1.42em;\n text-indent:0em;\n text-align:left;\n}\n\n.collectAuthor{\n margin:16px 0em 0em 0em;\n font-size:1em;\n text-indent:0em;\n text-align:left;\n}\n\n.collectPicture{\n margin:24px 0em 0em 0em;\n text-indent:0em;\n text-align:right;\n}\n\n.collectDetail{\n margin:3em 44px 0em 10px;\n text-indent:0em;\n}\n\n.quoteDefault{\n font-size:1em;\n margin:1.5em 0em 1.5em 2em;\n font-family:'FZShengShiKaiShuS-M-GB';\n /* line-space:0.5em; */\n}\n\n.quoteStyle1{\n text-indent:0em;\n text-align:left;\n}\n\n.alignRight{\n text-align:right;\n}\n\n.quoteDefaultAlignRight{\n font-size:1em;\n margin:0.6em 0em 1.5em 32px;\n font-family:'FZShengShiKaiShuS-M-GB';\n text-align:right;\n /* line-space:0.5em; */\n}\n.chapterTitle1{\n display:block;\n font-size:1.42em;\n font-weight:bold;\n margin:22px 0em 3em 0em;\n text-align:left;\n /* line-space:0.5em; */\n}\n\n.chapterTitle2{\n display:block;\n font-size:1.2em;\n font-weight:bold;\n margin:1em 0em 0.6em 0em;\n text-align:left;\n}\n\n.chapterTitle3{\n display:block;\n font-size:1em;\n font-weight:bold;\n margin:0em 0em 1em 0em;\n text-align:left;\n}\n\n.bdFootnote{\n width:0.69em;\n height:0.84em;\n margin-top:-0.23em;\n vertical-align:top;\n}\n\n.bdPicturebg{\n break-before:always;\n}"; const TITLE_CLASS = /(^|\s)chapterTitle\d?(\s|$)/; function normalizeTitle(text) { return text.replace(/[\s]+/g, ""); } function parseChapterDoc(xhtml) { const parser = new DOMParser(); const xdoc = parser.parseFromString(xhtml, "application/xhtml+xml"); if (!xdoc.querySelector("parsererror") && xdoc.body) return xdoc; const hdoc = parser.parseFromString(xhtml, "text/html"); return hdoc.body ? hdoc : null; } function contentRoot(doc) { return doc.querySelector("article") ?? doc.body; } function removeLeadingTitle(root, title) { const headings = root.querySelectorAll("h1, h2, h3, h4, h5, h6"); const wanted = normalizeTitle(title); let index = 0; for (const heading of headings) { if (index++ >= 2) break; const cls = heading.getAttribute("class") ?? ""; if (TITLE_CLASS.test(cls) || normalizeTitle(heading.textContent ?? "") === wanted) { heading.remove(); return; } } } function takeStyles(doc) { const styles = []; doc.querySelectorAll("style").forEach((el) => { var _a; const text = (_a = el.textContent) == null ? void 0 : _a.trim(); if (text) styles.push(text); el.remove(); }); return styles; } function chapterBody(raw) { var _a; if (typeof raw !== "string" || !raw.trim()) { return { html: "正文内容为空", styles: [] }; } const doc = parseChapterDoc(raw); if (!doc) return { html: raw, styles: [] }; const styles = takeStyles(doc); doc.querySelectorAll("link").forEach((el) => el.remove()); const root = contentRoot(doc); const html = (_a = root == null ? void 0 : root.innerHTML) == null ? void 0 : _a.trim(); return { html: html || "正文内容为空", styles }; } function collectText(node, out) { var _a; for (const child of node.childNodes) { if (child.nodeType === Node.TEXT_NODE) { const text = (_a = child.textContent) == null ? void 0 : _a.trim(); if (text) out.push(text); } else if (child.nodeType === Node.ELEMENT_NODE) { collectText(child, out); } } } function chapterParagraphs(raw, title = "") { if (typeof raw !== "string" || !raw.trim()) return []; const doc = parseChapterDoc(raw); if (!doc) return []; takeStyles(doc); doc.querySelectorAll("script, link").forEach((el) => el.remove()); const root = contentRoot(doc); if (!root) return []; if (title) removeLeadingTitle(root, title); const out = []; collectText(root, out); return out; } const FEEDBACK_HINT = /,如有任何疑问,请通过[“"]?我的-意见反馈[”"]?告知我们/; function parseJsonField(raw, fallback) { if (raw == null) return fallback; if (typeof raw !== "string") return raw ?? fallback; try { const parsed = JSON.parse(raw); return parsed == null ? fallback : parsed; } catch { return fallback; } } function hdCover(url) { if (!url) return ""; if (url.includes("novel-pic-r")) return url; let u = url; if (u.startsWith("https://")) u = u.substring(8); else if (u.startsWith("http://")) u = u.substring(7); const parts = u.split("/"); parts[0] = "https://p6-novel.byteimg.com/origin"; return parts.map((part) => part.includes("?") || part.includes("~") ? part.split("~")[0] : part).join("/"); } function toBookMeta(raw) { const categories = parseJsonField(raw == null ? void 0 : raw.category_v2, []).map((item) => String((item == null ? void 0 : item.Name) ?? "")).filter(Boolean); return { book_id: String((raw == null ? void 0 : raw.book_id) ?? ""), title: String((raw == null ? void 0 : raw.book_name) || (raw == null ? void 0 : raw.original_book_name) || "未命名"), author: String((raw == null ? void 0 : raw.author) ?? "未知作者"), categories: categories.length ? categories : [String((raw == null ? void 0 : raw.category) ?? "")].filter(Boolean), roles: parseJsonField(raw == null ? void 0 : raw.roles, []).map(String).filter(Boolean), abstract: String((raw == null ? void 0 : raw.abstract) ?? ""), copyright: String((raw == null ? void 0 : raw.copyright_info) ?? "").replace(FEEDBACK_HINT, ""), cover_url: String((raw == null ? void 0 : raw.thumb_url) ?? ""), word_number: Number(raw == null ? void 0 : raw.word_number) || 0, creation_status: String((raw == null ? void 0 : raw.creation_status) ?? "") }; } function describeBook(meta) { const lines = []; if (meta.categories.length) lines.push(`分类:${meta.categories.join("、")}`); if (meta.roles.length) lines.push(`主角:${meta.roles.join("、")}`); if (meta.abstract) lines.push(`简介:${meta.abstract}`); if (meta.copyright) lines.push(`${meta.copyright}。`); return lines.join("\n"); } const CSS_CDN_PREFIX = "https://p3-novel.byteimg.com/origin/"; const INLINE_CSS_BASE = 1; async function buildEpub({ meta, catalog, chapters, cssMap, task }) { var _a; const saver = new EpubSaver({ // 部分图床不给 CORS 头,走项目的双通道实现 fetchBinary: fetchArrayBuffer, fetchText: async (url) => { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.text(); }, // 关掉插图下载时保留原地址,联网仍能看,比直接删掉损失小 images: settings$1.downloadImages ? "download" : "keep" }); saver.setInfo("title", meta.title); saver.setInfo("language", "zh-CN"); saver.setInfo("creator", meta.author); saver.setInfo("description", describeBook(meta)); saver.setInfo("publisher", "番茄小说"); task.stage("准备 EPUB…", 1); if (meta.cover_url) { try { await saver.cover(hdCover(meta.cover_url)); } catch (err) { console.warn("[fqa:download] 封面下载失败,跳过:", err); } } task.update(1); await saver.addCSS(0, appcss, "Styles/dragon-common.css"); if (settings$1.downloadBookCss) { const map = parseCssMap(cssMap); const absolute = {}; for (const [path, uri] of Object.entries(map)) { if (uri) absolute[path] = CSS_CDN_PREFIX + uri; } if (Object.keys(absolute).length) { try { await saver.addCSSMap(absolute); } catch (err) { console.warn("[fqa:download] 书籍样式获取失败,跳过:", err); } } } task.stage("组装章节…", catalog.length); const styleIndex = /* @__PURE__ */ new Map(); let nextCssIdx = INLINE_CSS_BASE; let volumeName = ""; let volumeIdx = 0; let currentVolume = null; let cursor = 0; for (const item of catalog) { task.throwIfCancelled(); const chapter = chapters[item.item_id]; const name2 = ((_a = chapter == null ? void 0 : chapter.novel_data) == null ? void 0 : _a.volume_name) || item.volume_title || "默认卷"; if (!currentVolume || name2 !== volumeName) { volumeName = name2; currentVolume = await saver.addVolume(volumeIdx++, name2, { createVolumePage: settings$1.downloadVolumePage, volumePageType: "blank" }); } const { html, styles } = chapterBody(chapter == null ? void 0 : chapter.content); const cssIdxs = []; for (const style of styles) { let idx = styleIndex.get(style); if (idx === void 0) { idx = nextCssIdx++; styleIndex.set(style, idx); await saver.addCSS(idx, style); } cssIdxs.push(idx); } await currentVolume.addChapter( cursor++, item.title, html, "html", true, cssIdxs, // 正文自带标题元素,不要再插一个 false ); task.update(cursor, item.title); if (cursor % 20 === 0) await nextFrame(); } console.log(`[fqa:download] 章节内联样式去重后 ${styleIndex.size} 份`); task.stage("写入章节…", catalog.length); let lastPercent = -1; const buffer = await saver.save({ onWrite: (done, total) => { if (total !== task.snapshot.total) task.setTotal(total); task.update(done); }, onCompress: (percent, currentFile) => { const rounded = Math.floor(percent); if (rounded === lastPercent) return; if (lastPercent < 0) task.stagePercent("压缩 EPUB…"); lastPercent = rounded; task.update(rounded, currentFile ?? "正在压缩…"); }, checkCancel: () => task.throwIfCancelled() }); task.update(100, ""); return new Blob([buffer], { type: "application/epub+zip" }); } const ILLEGAL = /[\\/:*?"<>|\r\n\t]/g; function sanitizeFilename(name2, fallback = "download") { const cleaned = name2.replace(ILLEGAL, "_").replace(/[. ]+$/, "").trim(); if (!cleaned) return fallback; return cleaned.length > 120 ? cleaned.slice(0, 120) : cleaned; } function saveBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.rel = "noopener"; a.style.display = "none"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 3e4); } const GBK_RANGES = [ [161, 169, 161, 254], [176, 247, 161, 254], [129, 160, 64, 254], [170, 254, 64, 160], [168, 169, 64, 160], [170, 175, 161, 254], [248, 254, 161, 254], [161, 167, 64, 160] ]; let table = null; function initTable() { const pairs = new Uint16Array(23940); let count = 0; for (const [hiStart, hiEnd, loStart, loEnd] of GBK_RANGES) { for (let lo = loStart; lo <= loEnd; lo++) { if (lo === 127) continue; for (let hi = hiStart; hi <= hiEnd; hi++) { pairs[count++] = lo << 8 | hi; } } } const next = new Uint16Array(65536).fill(65535); const decoded = new TextDecoder("gbk").decode(pairs); for (let i2 = 0; i2 < decoded.length; i2++) { next[decoded.charCodeAt(i2)] = pairs[i2]; } table = next; return next; } function encodeGBK(text, options = {}) { const map = table ?? initTable(); const onError = options.onError ?? (() => 63); const out = new Uint8Array(text.length * 2); let n = 0; for (let i2 = 0; i2 < text.length; i2++) { const code = text.charCodeAt(i2); if (code < 128) { out[n++] = code; continue; } const gbk = map[code]; if (gbk !== 65535) { out[n++] = gbk & 255; out[n++] = gbk >> 8; continue; } if (code === 8364) { out[n++] = 128; continue; } const replacement = onError(i2, text); if (replacement === -1) break; if (replacement > 255) { out[n++] = replacement & 255; out[n++] = replacement >> 8; } else { out[n++] = replacement; } } return out.subarray(0, n); } const INDENT = " "; async function buildTxtContent({ meta, catalog, chapters, task }) { var _a; task.stage("处理章节…", catalog.length); const parts = [meta.title, `作者:${meta.author}`, describeBook(meta), "", ""]; let volumeName = ""; let cursor = 0; for (const item of catalog) { task.throwIfCancelled(); const chapter = chapters[item.item_id]; const name2 = ((_a = chapter == null ? void 0 : chapter.novel_data) == null ? void 0 : _a.volume_name) || item.volume_title || ""; if (name2 && name2 !== volumeName) { volumeName = name2; parts.push(` ${name2.replace(":默认", "")} `); } const paragraphs = chapterParagraphs(chapter == null ? void 0 : chapter.content, item.title); const body = paragraphs.length ? INDENT + paragraphs.join(` ${INDENT}`) : `${INDENT}(本章内容缺失)`; parts.push(`${item.title} ${body} `); task.update(++cursor, item.title); if (cursor % 50 === 0) await nextFrame(); } return parts.join("\n"); } function txtToBlob(content) { const charset = settings$1.downloadCharset; const encoded = charset === "gbk" ? encodeGBK(content) : new TextEncoder().encode(content); return new Blob([encoded], { type: `text/plain;charset=${charset}` }); } let activeTask = null; let running = false; const taskListeners = /* @__PURE__ */ new Set(); function onTaskChange(listener) { taskListeners.add(listener); listener(activeTask); return () => taskListeners.delete(listener); } function setActiveTask(task) { activeTask = task; for (const listener of taskListeners) { try { listener(task); } catch (err) { console.error("[fqa:download] 任务监听异常:", err); } } } function clearFinishedTask() { if (!activeTask) return; if (!activeTask.snapshot.done) activeTask.cancel(); setActiveTask(null); } function findCssMap(chapters) { var _a; for (const chapter of Object.values(chapters)) { const cssMap = (_a = chapter.novel_data) == null ? void 0 : _a.css_map; if (cssMap) return cssMap; } return void 0; } async function resolveMeta(bookId2, chapters) { var _a; try { const raw = await getBookInfoRaw(bookId2); if (raw == null ? void 0 : raw.book_id) return toBookMeta(raw); } catch (err) { console.warn("[fqa:download] 获取书籍详情失败,改用章节里的信息:", err); } const fallback = (_a = Object.values(chapters).find((c) => { var _a2; return (_a2 = c.novel_data) == null ? void 0 : _a2.book_id; })) == null ? void 0 : _a.novel_data; return toBookMeta(fallback ?? { book_id: bookId2 }); } async function startDownload(bookId2, options = {}) { if (running) { console.warn("[fqa:download] 已有下载任务在进行"); return; } const format = options.format ?? settings$1.downloadFormat; const task = new DownloadTask(); running = true; setActiveTask(task); try { task.stage("获取目录…"); const catalogResult = await getCatalog(bookId2); const catalog = catalogResult.chapter_list; if (catalog.length === 0) throw new Error("目录为空"); task.throwIfCancelled(); const titleMap = new Map(catalog.map((item) => [item.item_id, item.title])); const { chapters, failed } = await fetchChapters( catalog.map((item) => item.item_id), bookId2, task, { titleOf: (itemId2) => titleMap.get(itemId2) } ); task.throwIfCancelled(); if (Object.keys(chapters).length === 0) { throw new Error("没有获取到任何章节正文"); } if (failed.length > 0) { console.warn(`[fqa:download] ${failed.length} 章缺失,仍然继续导出`); } const meta = await resolveMeta(bookId2, chapters); task.throwIfCancelled(); const base = sanitizeFilename(`${meta.title}_${meta.author}`, bookId2); if (format === "epub") { const blob = await buildEpub({ meta, catalog, chapters, cssMap: findCssMap(chapters), task }); task.stage("保存中…", 1); saveBlob(blob, `${base}.epub`); } else { const content = await buildTxtContent({ meta, catalog, chapters, task }); task.stage("保存中…", 1); saveBlob(txtToBlob(content), `${base}.txt`); } task.update(1); task.finish(failed.length > 0 ? `完成(${failed.length} 章缺失)` : "下载完成"); } catch (err) { if (err instanceof CancelledError) { console.log("[fqa:download] 任务已取消"); task.finish("已取消"); return; } console.error("[fqa:download] 下载失败:", err); task.fail(err); } finally { running = false; } } const _hoisted_1$4 = { id: "fqa-bookshelf" }; const _hoisted_2$4 = { class: "fqa-bs-header" }; const _hoisted_3$4 = { class: "fqa-bs-actions" }; const _hoisted_4$4 = { key: 0 }; const _hoisted_5$4 = ["disabled"]; const _hoisted_6$4 = ["aria-selected", "onClick", "onKeydown"]; const _hoisted_7$4 = { class: "fqa-tab-count" }; const _hoisted_8$4 = { key: 0, class: "fqa-groupbar" }; const _hoisted_9$4 = { class: "fqa-groupbar-name" }; const _hoisted_10$4 = { class: "fqa-groupbar-count" }; const _hoisted_11$3 = { key: 1, class: "fqa-status" }; const _hoisted_12$3 = { key: 2, class: "fqa-status" }; const _hoisted_13$3 = { class: "fqa-status-title" }; const _hoisted_14$3 = { class: "fqa-grid" }; const _hoisted_15$1 = { key: 0, class: "fqa-toast" }; const HOVER_DELAY = 300; const HIDE_DELAY = 160; const HOVER_WIDTH = 280; const HOVER_GAP = 12; const VIEWPORT_MARGIN = 8; const MOVE_PREFIX = "move:"; const DOWNLOAD_PREFIX$1 = "download:"; const _sfc_main$4 = /* @__PURE__ */ vue.defineComponent({ __name: "BookshelfView", setup(__props) { const { loading, detailLoading, error, counts, groups, load: load2, ensureDetails, cellsOf, findGroup, PAGE_SIZE: PAGE_SIZE2 } = useBookshelf(); const activeTab = vue.ref("all"); const openedGroupName = vue.ref(null); const tabsRef = vue.ref(null); const inkStyle = vue.ref({ left: "0px", width: "0px" }); const hoverEntry = vue.ref(null); const hoverVisible = vue.ref(false); const hoverPos = vue.ref({ x: 0, y: 0 }); const hoverHeight = vue.ref(0); let hoverTimer; let hideTimer; let hoverAnchor = null; const pointerInPanel = vue.ref(false); const openedGroup = vue.computed( () => openedGroupName.value ? findGroup(openedGroupName.value) : null ); const allCells = vue.computed(() => { const group = openedGroup.value; if (group) { return group.books.map((entry) => ({ kind: "book", key: `book:${entry.item.book_id}`, entry })); } return cellsOf(activeTab.value); }); const visibleCount = vue.ref(PAGE_SIZE2); const cells = vue.computed(() => allCells.value.slice(0, visibleCount.value)); const hasMore = vue.computed(() => visibleCount.value < allCells.value.length); const sentinel = vue.ref(null); let pageObserver = null; function resetPaging() { visibleCount.value = PAGE_SIZE2; } async function refresh() { resetPaging(); await load2(true); } vue.watch([activeTab, openedGroupName], resetPaging); const isEmpty = vue.computed(() => !loading.value && !error.value && allCells.value.length === 0); const emptyText = vue.computed(() => { if (openedGroup.value) return "这个分组还没有书"; if (activeTab.value === "group") return "还没有创建任何分组"; if (activeTab.value === "publish") return "书架里还没有出版物"; return "书架空空如也,去首页找几本书看看吧"; }); function updateInk() { const wrap = tabsRef.value; if (!wrap) return; const index = TABS.findIndex((tab) => tab.key === activeTab.value); const el = wrap.querySelectorAll(".fqa-tab")[index]; if (!el) return; inkStyle.value = { left: `${el.offsetLeft}px`, width: `${el.offsetWidth}px` }; } function selectTab(key) { openedGroupName.value = null; activeTab.value = key; hideHover(true); } vue.watch(activeTab, () => vue.nextTick(updateInk)); vue.watch(counts, () => vue.nextTick(updateInk), { deep: true }); function computePosition(el) { const cover = el.querySelector(".fqa-cover") ?? el; const rect = cover.getBoundingClientRect(); let x = rect.right + HOVER_GAP; if (x + HOVER_WIDTH > window.innerWidth - VIEWPORT_MARGIN) { x = rect.left - HOVER_WIDTH - HOVER_GAP; } x = Math.max(VIEWPORT_MARGIN, Math.min(x, window.innerWidth - HOVER_WIDTH - VIEWPORT_MARGIN)); const topLimit = VIEWPORT_MARGIN; const bottomLimit = window.innerHeight - VIEWPORT_MARGIN; const height = Math.min(rect.height, bottomLimit - topLimit); const y = Math.max(topLimit, Math.min(rect.top, bottomLimit - height)); hoverHeight.value = height; hoverPos.value = { x, y }; } function onCardHover({ entry, el }) { if (hoverTimer) clearTimeout(hoverTimer); if (hideTimer) { clearTimeout(hideTimer); hideTimer = void 0; } if (!entry.detail) return; hoverAnchor = el; if (hoverVisible.value && hoverEntry.value !== entry) { computePosition(el); hoverEntry.value = entry; return; } hoverTimer = setTimeout(() => { hoverTimer = void 0; computePosition(el); hoverEntry.value = entry; hoverVisible.value = true; }, HOVER_DELAY); } function scheduleHide() { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = void 0; } if (hideTimer) clearTimeout(hideTimer); hideTimer = setTimeout(() => { hideTimer = void 0; if (!pointerInPanel.value) hideHover(true); }, HIDE_DELAY); } function hideHover(immediate = false) { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = void 0; } if (hideTimer) { clearTimeout(hideTimer); hideTimer = void 0; } pointerInPanel.value = false; hoverAnchor = null; hoverVisible.value = false; if (immediate) hoverEntry.value = null; } function onPanelEnter() { if (hideTimer) { clearTimeout(hideTimer); hideTimer = void 0; } pointerInPanel.value = true; } function onPanelLeave() { pointerInPanel.value = false; scheduleHide(); } let pendingItems = []; let flushTimer; function queueDetails(entries) { const pending = entries.filter((entry) => !entry.detail); if (!pending.length) return; pendingItems.push(...pending.map((entry) => entry.item)); if (flushTimer) return; flushTimer = setTimeout(() => { flushTimer = void 0; const batch = pendingItems; pendingItems = []; void ensureDetails(batch); }, 50); } function onCardVisible(entry) { queueDetails([entry]); } function onGroupVisible(entries) { queueDetails(entries); } function continueReadingUrl(entry) { var _a; const chapterId = ((_a = entry.detail) == null ? void 0 : _a.current_chapter_id) || entry.item.last_read_chapter_id; return chapterId && chapterId !== "0" ? readerUrl(chapterId) : null; } function openBook({ entry, mods }) { hideHover(true); const wantRead = settings$1.bookshelfClickAction === "read" !== mods.flip; const url = wantRead && continueReadingUrl(entry) || bookPageUrl(entry.item.book_id); openUrl(url, mods.newTab); } function openGroup(group) { hideHover(true); openedGroupName.value = group.name; } const menuVisible = vue.ref(false); const menuPos = vue.ref({ x: 0, y: 0 }); const menuEntry = vue.ref(null); const toast = vue.ref(null); let toastTimer; function showToast(msg) { toast.value = msg; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { toast.value = null; toastTimer = void 0; }, 2600); } const NO_GROUP_KEY = `${MOVE_PREFIX}`; const menuItems = vue.computed(() => { const entry = menuEntry.value; if (!entry) return []; const current = entry.item.group_name ?? ""; const targets = groups.value.filter((g2) => g2.name !== current).map((g2) => ({ key: `${MOVE_PREFIX}${g2.name}`, label: g2.name })); if (current) targets.push({ key: NO_GROUP_KEY, label: "无分组" }); const items = [ { key: "read", label: "继续阅读" }, { key: "detail", label: "查看详情" }, { key: "move", label: "移动到分组", disabled: targets.length === 0, children: targets } ]; if (settings$1.enableDownload) { items.push({ key: "download", label: "下载", children: [ { key: `${DOWNLOAD_PREFIX$1}epub`, label: "EPUB" }, { key: `${DOWNLOAD_PREFIX$1}txt`, label: "TXT" } ] }); } items.push({ key: "remove", label: "从书架删除", danger: true }); return items; }); function onCardContextMenu({ entry, x, y }) { hideHover(true); menuEntry.value = entry; menuPos.value = { x, y }; menuVisible.value = true; } async function onMenuSelect(key) { var _a; const entry = menuEntry.value; if (!entry) return; const bookId2 = entry.item.book_id; if (key === "read") { openUrl(continueReadingUrl(entry) ?? bookPageUrl(bookId2)); return; } if (key === "detail") { openUrl(bookPageUrl(bookId2)); return; } if (key.startsWith(DOWNLOAD_PREFIX$1)) { const format = key.slice(DOWNLOAD_PREFIX$1.length); void startDownload(bookId2, { format }); return; } if (key === "remove") { if (!unsafeWindow.confirm(`确定要把《${((_a = entry.detail) == null ? void 0 : _a.title) ?? bookId2}》从书架删除吗?`)) return; try { await removeFromBookshelf(bookId2); showToast("已从书架删除"); await refresh(); } catch (err) { console.error("[fqa:bookshelf] 删除失败:", err); showToast(err instanceof Error ? err.message : "删除失败"); } return; } if (key.startsWith(MOVE_PREFIX)) { const groupName = key.slice(MOVE_PREFIX.length); try { await moveToGroup(bookId2, groupName); showToast(groupName ? `已移动到「${groupName}」` : "已移出分组"); await refresh(); } catch (err) { console.error("[fqa:bookshelf] 移动分组失败:", err); showToast(err instanceof Error ? err.message : "移动分组失败"); } } } function backToList() { openedGroupName.value = null; hideHover(true); } function onScrollOrResize() { if (hoverAnchor && (hoverVisible.value || hoverTimer)) { if (hoverAnchor.isConnected) { computePosition(hoverAnchor); } else { hideHover(true); } } updateInk(); } vue.onMounted(async () => { await load2(); await vue.nextTick(); updateInk(); window.addEventListener("scroll", onScrollOrResize, true); window.addEventListener("resize", onScrollOrResize); if (typeof IntersectionObserver !== "undefined") { pageObserver = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting) && hasMore.value) { visibleCount.value += PAGE_SIZE2; } }, { rootMargin: "400px" } ); vue.watch( sentinel, (el) => { pageObserver == null ? void 0 : pageObserver.disconnect(); if (el) pageObserver == null ? void 0 : pageObserver.observe(el); }, { immediate: true } ); } }); vue.onBeforeUnmount(() => { if (hoverTimer) clearTimeout(hoverTimer); if (hideTimer) clearTimeout(hideTimer); if (flushTimer) clearTimeout(flushTimer); pageObserver == null ? void 0 : pageObserver.disconnect(); pageObserver = null; window.removeEventListener("scroll", onScrollOrResize, true); window.removeEventListener("resize", onScrollOrResize); }); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", _hoisted_1$4, [ vue.createElementVNode("div", _hoisted_2$4, [ _cache[1] || (_cache[1] = vue.createElementVNode("div", { class: "fqa-bs-title" }, "我的书架", -1)), vue.createElementVNode("div", _hoisted_3$4, [ vue.unref(detailLoading) ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_4$4, "正在补全详情…")) : vue.createCommentVNode("", true), vue.createElementVNode("button", { class: "fqa-btn", disabled: vue.unref(loading), onClick: refresh }, vue.toDisplayString(vue.unref(loading) ? "刷新中…" : "刷新"), 9, _hoisted_5$4) ]) ]), vue.createElementVNode("div", { ref_key: "tabsRef", ref: tabsRef, class: "fqa-tabs", role: "tablist" }, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(vue.unref(TABS), (tab) => { return vue.openBlock(), vue.createElementBlock("div", { key: tab.key, class: vue.normalizeClass(["fqa-tab", { "fqa-tab-active": activeTab.value === tab.key }]), role: "tab", tabindex: "0", "aria-selected": activeTab.value === tab.key, onClick: ($event) => selectTab(tab.key), onKeydown: vue.withKeys(vue.withModifiers(($event) => selectTab(tab.key), ["prevent"]), ["enter"]) }, [ vue.createTextVNode(vue.toDisplayString(tab.label), 1), vue.createElementVNode("span", _hoisted_7$4, vue.toDisplayString(vue.unref(counts)[tab.key]), 1) ], 42, _hoisted_6$4); }), 128)), vue.createElementVNode("span", { class: "fqa-tab-ink", style: vue.normalizeStyle(inkStyle.value) }, null, 4) ], 512), openedGroup.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_8$4, [ vue.createElementVNode("button", { class: "fqa-btn", onClick: backToList }, "← 返回"), vue.createElementVNode("span", _hoisted_9$4, vue.toDisplayString(openedGroup.value.name), 1), vue.createElementVNode("span", _hoisted_10$4, "共" + vue.toDisplayString(openedGroup.value.books.length) + "本书", 1) ])) : vue.createCommentVNode("", true), vue.unref(error) ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_11$3, [ _cache[2] || (_cache[2] = vue.createElementVNode("div", { class: "fqa-status-title" }, "书架加载失败", -1)), vue.createElementVNode("div", null, vue.toDisplayString(vue.unref(error)), 1), vue.createElementVNode("button", { class: "fqa-btn", onClick: refresh }, "重试") ])) : isEmpty.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_12$3, [ vue.createElementVNode("div", _hoisted_13$3, vue.toDisplayString(emptyText.value), 1) ])) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 3 }, [ vue.createElementVNode("div", _hoisted_14$3, [ vue.unref(loading) ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, vue.renderList(8, (n) => { return vue.createElementVNode("div", { key: `sk-${n}`, class: "fqa-card" }, [..._cache[3] || (_cache[3] = [ vue.createElementVNode("div", { class: "fqa-sk-cover fqa-sk-anim" }, null, -1), vue.createElementVNode("div", { class: "fqa-sk-line fqa-sk-anim", style: { "width": "90%" } }, null, -1), vue.createElementVNode("div", { class: "fqa-sk-line fqa-sk-anim", style: { "width": "55%" } }, null, -1) ])]); }), 64)) : (vue.openBlock(true), vue.createElementBlock(vue.Fragment, { key: 1 }, vue.renderList(cells.value, (cell) => { return vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: cell.key }, [ cell.kind === "book" ? (vue.openBlock(), vue.createBlock(_sfc_main$8, { key: 0, entry: cell.entry, onHover: onCardHover, onLeave: scheduleHide, onOpen: openBook, onVisible: onCardVisible, onContextmenu: onCardContextMenu }, null, 8, ["entry"])) : (vue.openBlock(), vue.createBlock(_sfc_main$7, { key: 1, group: cell.group, onOpen: openGroup, onVisible: onGroupVisible }, null, 8, ["group"])) ], 64); }), 128)) ]), !vue.unref(loading) && hasMore.value ? (vue.openBlock(), vue.createElementBlock("div", { key: 0, ref_key: "sentinel", ref: sentinel, class: "fqa-loadmore" }, "加载中…", 512)) : vue.createCommentVNode("", true) ], 64)), (vue.openBlock(), vue.createBlock(vue.Teleport, { to: "body" }, [ vue.createVNode(_sfc_main$6, { entry: hoverEntry.value, x: hoverPos.value.x, y: hoverPos.value.y, height: hoverHeight.value, visible: hoverVisible.value, onPanelEnter, onPanelLeave }, null, 8, ["entry", "x", "y", "height", "visible"]), vue.createVNode(_sfc_main$5, { visible: menuVisible.value, x: menuPos.value.x, y: menuPos.value.y, items: menuItems.value, onSelect: onMenuSelect, onClose: _cache[0] || (_cache[0] = ($event) => menuVisible.value = false) }, null, 8, ["visible", "x", "y", "items"]), toast.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_15$1, vue.toDisplayString(toast.value), 1)) : vue.createCommentVNode("", true) ])) ]); }; } }); const bookshelfcss = "#fqa-bookshelf {\n --fqa-text: #1f2329;\n --fqa-text-sub: #646a73;\n --fqa-text-weak: #8f959e;\n --fqa-border: rgba(31, 35, 41, 0.08);\n --fqa-hover: rgba(31, 35, 41, 0.04);\n --fqa-accent: #ff6f3d;\n --fqa-skeleton: rgba(31, 35, 41, 0.06);\n --fqa-skeleton-hl: rgba(31, 35, 41, 0.12);\n --fqa-shadow: 0 4px 16px rgba(31, 35, 41, 0.08);\n\n display: block;\n box-sizing: border-box;\n width: 100%;\n max-width: 1100px;\n margin: 0 auto;\n /* 顶部留出原站 fixed 顶栏(80px)的高度,否则标题和 tab 会被压在下面 */\n padding: calc(80px + 24px) 16px 64px;\n color: var(--fqa-text);\n font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,\n sans-serif;\n}\n\n#fqa-bookshelf *,\n#fqa-bookshelf *::before,\n#fqa-bookshelf *::after {\n box-sizing: border-box;\n}\n\n#fqa-bookshelf div,\n#fqa-bookshelf span,\n#fqa-bookshelf h1,\n#fqa-bookshelf ul,\n#fqa-bookshelf li {\n margin: 0;\n padding: 0;\n border: 0;\n list-style: none;\n float: none;\n position: static;\n}\n\n/* ------------------------------ 顶部 / Tabs ------------------------------ */\n\n#fqa-bookshelf .fqa-bs-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n margin-bottom: 8px;\n}\n\n#fqa-bookshelf .fqa-bs-title {\n font-size: 24px;\n font-weight: 600;\n line-height: 1.4;\n}\n\n#fqa-bookshelf .fqa-bs-actions {\n display: flex;\n align-items: center;\n gap: 12px;\n font-size: 13px;\n color: var(--fqa-text-weak);\n}\n\n#fqa-bookshelf .fqa-btn {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 6px 14px;\n border: 1px solid var(--fqa-border);\n border-radius: 999px;\n background: transparent;\n color: var(--fqa-text-sub);\n font-size: 13px;\n font-family: inherit;\n line-height: 1.4;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;\n}\n\n#fqa-bookshelf .fqa-btn:hover:not(:disabled) {\n border-color: var(--fqa-accent);\n color: var(--fqa-accent);\n background: rgba(255, 111, 61, 0.06);\n}\n\n#fqa-bookshelf .fqa-btn:disabled {\n opacity: 0.5;\n cursor: default;\n}\n\n#fqa-bookshelf .fqa-tabs {\n position: relative;\n display: flex;\n align-items: center;\n gap: 4px;\n margin-bottom: 24px;\n border-bottom: 1px solid var(--fqa-border);\n}\n\n#fqa-bookshelf .fqa-tab {\n padding: 10px 16px;\n color: var(--fqa-text-sub);\n font-size: 15px;\n line-height: 22px;\n cursor: pointer;\n user-select: none;\n transition: color 0.15s ease;\n}\n\n#fqa-bookshelf .fqa-tab:hover {\n color: var(--fqa-text);\n}\n\n#fqa-bookshelf .fqa-tab-active {\n color: var(--fqa-accent);\n font-weight: 600;\n}\n\n#fqa-bookshelf .fqa-tab-count {\n margin-left: 4px;\n font-size: 12px;\n font-weight: 400;\n color: var(--fqa-text-weak);\n}\n\n#fqa-bookshelf .fqa-tab-ink {\n position: absolute;\n bottom: -1px;\n left: 0;\n width: 0;\n height: 2px;\n border-radius: 2px;\n background: var(--fqa-accent);\n transition: left 0.25s ease, width 0.25s ease;\n}\n\n/* ------------------------------- 书架网格 ------------------------------- */\n\n/* 原站一排最多 4 本;窄屏逐级降到 3 / 2 */\n#fqa-bookshelf .fqa-grid {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: 28px 24px;\n align-items: start;\n}\n\n@media (max-width: 900px) {\n #fqa-bookshelf .fqa-grid {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n }\n}\n\n@media (max-width: 600px) {\n #fqa-bookshelf .fqa-grid {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n}\n\n#fqa-bookshelf .fqa-card {\n display: block;\n border-radius: 8px;\n cursor: pointer;\n outline: none;\n}\n\n#fqa-bookshelf .fqa-card:focus-visible {\n box-shadow: 0 0 0 2px var(--fqa-accent);\n}\n\n/* 封面:3:4,靠 aspect-ratio 定高,内部元素绝对定位 */\n#fqa-bookshelf .fqa-cover {\n position: relative;\n display: block;\n width: 100%;\n aspect-ratio: 3 / 4;\n border-radius: 6px;\n overflow: hidden;\n background: var(--fqa-skeleton);\n transition: transform 0.2s ease, box-shadow 0.2s ease;\n}\n\n#fqa-bookshelf .fqa-card:hover .fqa-cover {\n transform: translateY(-4px);\n box-shadow: var(--fqa-shadow);\n}\n\n#fqa-bookshelf .fqa-cover-img {\n position: absolute;\n inset: 0;\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n transition: opacity 0.25s ease;\n}\n\n#fqa-bookshelf .fqa-cover-img-loading {\n opacity: 0;\n}\n\n#fqa-bookshelf .fqa-cover-tag {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n padding: 2px 6px;\n border-radius: 0 6px 0 6px;\n background: var(--fqa-accent);\n color: #fff;\n font-size: 11px;\n line-height: 16px;\n font-weight: 500;\n white-space: nowrap;\n}\n\n/* 连载 / 完结 / 断更共用:灰底,弱化于“更新”角标 */\n#fqa-bookshelf .fqa-cover-tag-gray {\n background: rgba(31, 35, 41, 0.55);\n}\n\n@media (prefers-color-scheme: dark) {\n #fqa-bookshelf .fqa-cover-tag-gray {\n background: rgba(0, 0, 0, 0.6);\n }\n}\n\n#fqa-bookshelf .fqa-cover-progress {\n position: absolute;\n left: 0;\n right: 0;\n bottom: 0;\n z-index: 2;\n height: 3px;\n background: rgba(255, 255, 255, 0.35);\n}\n\n#fqa-bookshelf .fqa-cover-progress-bar {\n display: block;\n height: 100%;\n background: var(--fqa-accent);\n transition: width 0.3s ease;\n}\n\n/* 文字区:与封面同为普通流元素,不会重叠 */\n#fqa-bookshelf .fqa-card-title {\n display: -webkit-box;\n margin-top: 8px;\n color: var(--fqa-text);\n font-size: 14px;\n line-height: 20px;\n font-weight: 500;\n -webkit-line-clamp: 2;\n line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n word-break: break-all;\n}\n\n#fqa-bookshelf .fqa-card-sub {\n margin-top: 4px;\n color: var(--fqa-text-weak);\n font-size: 12px;\n line-height: 18px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* ------------------------------- 分组卡片 ------------------------------- */\n\n#fqa-bookshelf .fqa-group-cover {\n position: relative;\n display: block;\n width: 100%;\n aspect-ratio: 3 / 4;\n border-radius: 6px;\n overflow: hidden;\n background: linear-gradient(135deg, rgba(255, 111, 61, 0.12), rgba(78, 131, 253, 0.12));\n transition: transform 0.2s ease, box-shadow 0.2s ease;\n}\n\n#fqa-bookshelf .fqa-card:hover .fqa-group-cover {\n transform: translateY(-4px);\n box-shadow: var(--fqa-shadow);\n}\n\n#fqa-bookshelf .fqa-group-grid {\n position: absolute;\n inset: 0;\n display: grid;\n grid-template-columns: 1fr 1fr;\n grid-template-rows: 1fr 1fr;\n gap: 4px;\n padding: 6px;\n}\n\n#fqa-bookshelf .fqa-group-cell {\n position: relative;\n border-radius: 3px;\n overflow: hidden;\n background: rgba(31, 35, 41, 0.06);\n}\n\n#fqa-bookshelf .fqa-group-cell img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n\n/* 分组详情返回条 */\n\n#fqa-bookshelf .fqa-groupbar {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 16px;\n}\n\n#fqa-bookshelf .fqa-groupbar-name {\n font-size: 16px;\n font-weight: 600;\n}\n\n#fqa-bookshelf .fqa-groupbar-count {\n color: var(--fqa-text-weak);\n font-size: 13px;\n}\n\n/* --------------------------- hover 详情浮层 --------------------------- */\n\n/*\n * 用 popover 进入浏览器顶层,不参与页面 z-index 竞争,\n * 因此不会被相邻卡片或原站的层叠上下文盖住。z-index 仅作降级保险。\n */\n#fqa-bookshelf-hover {\n position: fixed;\n z-index: 2147483000;\n /* 容器本身透传,只有内部卡片接收事件,避免空白区挡住下层 */\n pointer-events: none;\n opacity: 0;\n transform: translateY(4px);\n /* allow-discrete:顶层元素从 display:none 切入时也能播放淡入 */\n transition: opacity 0.16s ease, transform 0.16s ease, display 0.16s allow-discrete;\n font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,\n sans-serif;\n}\n\n/* popover 默认带边框/内边距/居中定位,全部清掉,改由 left/top 控制 */\n#fqa-bookshelf-hover:popover-open,\n#fqa-bookshelf-hover[popover] {\n margin: 0;\n padding: 0;\n border: 0;\n background: transparent;\n overflow: visible;\n inset: auto;\n width: auto;\n height: auto;\n max-width: none;\n max-height: none;\n color: inherit;\n}\n\n#fqa-bookshelf-hover::backdrop {\n background: transparent;\n}\n\n#fqa-bookshelf-hover.fqa-visible {\n opacity: 1;\n transform: translateY(0);\n}\n\n@starting-style {\n #fqa-bookshelf-hover.fqa-visible {\n opacity: 0;\n transform: translateY(4px);\n }\n}\n\n/* 高度由 JS 按封面尺寸设定;纵向 flex 让简介吃掉剩余空间 */\n#fqa-bookshelf-hover .fqa-hover-inner {\n display: flex;\n flex-direction: column;\n box-sizing: border-box;\n width: 280px;\n padding: 12px 14px;\n border-radius: 10px;\n background: #fff;\n box-shadow: 0 8px 32px rgba(31, 35, 41, 0.16);\n color: #1f2329;\n overflow: hidden;\n /* 卡片可交互:鼠标可以移进来而不触发收起 */\n pointer-events: auto;\n}\n\n#fqa-bookshelf-hover .fqa-hover-title {\n flex: none;\n margin: 0;\n font-size: 14px;\n line-height: 20px;\n font-weight: 600;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n#fqa-bookshelf-hover .fqa-hover-author {\n flex: none;\n margin-top: 2px;\n color: #8f959e;\n font-size: 12px;\n line-height: 17px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stats {\n display: flex;\n flex: none;\n margin-top: 10px;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat {\n flex: 1 1 0;\n min-width: 0;\n padding: 0 6px;\n text-align: center;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat:first-child {\n padding-left: 0;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat:last-child {\n padding-right: 0;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat + .fqa-hover-stat {\n border-left: 1px solid rgba(31, 35, 41, 0.08);\n}\n\n/* 第一栏可悬停切换为更新时间,给个可交互提示 */\n#fqa-bookshelf-hover .fqa-hover-stat:first-child {\n border-radius: 4px;\n cursor: default;\n transition: background 0.15s ease;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat:first-child:hover {\n background: rgba(31, 35, 41, 0.05);\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat-v {\n font-size: 13px;\n line-height: 18px;\n font-weight: 600;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n#fqa-bookshelf-hover .fqa-hover-stat-k {\n margin-top: 1px;\n color: #8f959e;\n font-size: 11px;\n line-height: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* 梗概 / 简介 双栏切换 */\n#fqa-bookshelf-hover .fqa-hover-seg {\n display: flex;\n flex: none;\n gap: 4px;\n margin-top: 10px;\n padding-top: 10px;\n border-top: 1px solid rgba(31, 35, 41, 0.08);\n}\n\n#fqa-bookshelf-hover .fqa-hover-seg-btn {\n flex: 1 1 0;\n padding: 4px 0;\n border: 0;\n border-radius: 5px;\n background: rgba(31, 35, 41, 0.05);\n color: #646a73;\n font-family: inherit;\n font-size: 12px;\n line-height: 18px;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n}\n\n#fqa-bookshelf-hover .fqa-hover-seg-btn:hover {\n color: #1f2329;\n}\n\n#fqa-bookshelf-hover .fqa-hover-seg-active {\n background: rgba(255, 111, 61, 0.12);\n color: #ff6f3d;\n font-weight: 500;\n}\n\n#fqa-bookshelf-hover .fqa-hover-seg-active:hover {\n color: #ff6f3d;\n}\n\n/*\n * 撑满剩余高度。行数不再写死,由容器高度自然裁切;\n * min-height:0 让 flex 子项允许被压缩,否则 overflow 不生效。\n */\n#fqa-bookshelf-hover .fqa-hover-abstract {\n flex: 1 1 auto;\n min-height: 0;\n margin-top: 8px;\n color: #646a73;\n font-size: 12px;\n line-height: 18px;\n overflow-y: auto;\n overscroll-behavior: contain;\n}\n\n#fqa-bookshelf-hover .fqa-hover-abstract::-webkit-scrollbar {\n width: 4px;\n}\n\n#fqa-bookshelf-hover .fqa-hover-abstract::-webkit-scrollbar-thumb {\n border-radius: 2px;\n background: rgba(31, 35, 41, 0.18);\n}\n\n\n#fqa-bookshelf-hover .fqa-hover-chapter {\n display: block;\n margin-bottom: 1px;\n color: #1f2329;\n font-weight: 500;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* ------------------------------- 骨架屏 ------------------------------- */\n\n#fqa-bookshelf .fqa-sk-cover {\n display: block;\n width: 100%;\n aspect-ratio: 3 / 4;\n border-radius: 6px;\n background: var(--fqa-skeleton);\n}\n\n#fqa-bookshelf .fqa-sk-line {\n height: 12px;\n margin-top: 8px;\n border-radius: 4px;\n background: var(--fqa-skeleton);\n}\n\n#fqa-bookshelf .fqa-sk-anim {\n position: relative;\n overflow: hidden;\n}\n\n/* keyframes fqa-shimmer 在 script.css 里全局声明 */\n#fqa-bookshelf .fqa-sk-anim::after {\n content: '';\n position: absolute;\n inset: 0;\n transform: translateX(-100%);\n background: linear-gradient(90deg, transparent, var(--fqa-skeleton-hl), transparent);\n animation: fqa-shimmer 1.4s infinite;\n}\n\n/* --------------------------- 空态 / 错误态 --------------------------- */\n\n#fqa-bookshelf .fqa-loadmore {\n padding: 24px 0;\n text-align: center;\n color: var(--fqa-text-weak);\n font-size: 13px;\n}\n\n#fqa-bookshelf .fqa-status {\n padding: 80px 16px;\n text-align: center;\n color: var(--fqa-text-weak);\n font-size: 14px;\n line-height: 22px;\n}\n\n#fqa-bookshelf .fqa-status-title {\n margin-bottom: 8px;\n color: var(--fqa-text);\n font-size: 16px;\n font-weight: 500;\n}\n\n#fqa-bookshelf .fqa-status .fqa-btn {\n margin-top: 16px;\n}\n\n/* ------------------------------- 深色模式 ------------------------------- */\n\n@media (prefers-color-scheme: dark) {\n #fqa-bookshelf {\n --fqa-text: #e6e6e6;\n --fqa-text-sub: #a6a6a6;\n --fqa-text-weak: #7a7a7a;\n --fqa-border: rgba(255, 255, 255, 0.1);\n --fqa-hover: rgba(255, 255, 255, 0.06);\n --fqa-skeleton: rgba(255, 255, 255, 0.08);\n --fqa-skeleton-hl: rgba(255, 255, 255, 0.14);\n --fqa-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);\n }\n\n #fqa-bookshelf-hover .fqa-hover-inner {\n background: #212125;\n color: #e6e6e6;\n box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);\n }\n\n #fqa-bookshelf-hover .fqa-hover-chapter {\n color: #e6e6e6;\n }\n\n #fqa-bookshelf-hover .fqa-hover-abstract {\n color: #a6a6a6;\n border-top-color: rgba(255, 255, 255, 0.1);\n }\n\n #fqa-bookshelf-hover .fqa-hover-stat + .fqa-hover-stat {\n border-left-color: rgba(255, 255, 255, 0.1);\n }\n}\n\n/* 右键菜单与 toast 样式已移到 script.css,书架和搜索共用 */\n"; const CONTAINER_ID$2 = "fqa-bookshelf-root"; const STYLE_ID$3 = "fqa-bookshelf-style"; const ORIGIN_SELECTOR$1 = ".muye-bookshelf, .muye-bookshelf-home-page, .bookshelf-tabs"; let app$2 = null; let container$2 = null; let observer$2 = null; function injectStyle$3() { if (document.getElementById(STYLE_ID$3)) return; const style = document.createElement("style"); style.id = STYLE_ID$3; style.textContent = bookshelfcss; document.head.appendChild(style); } function hideOrigin$1(root = document) { root.querySelectorAll(ORIGIN_SELECTOR$1).forEach((el) => { if (el.id === CONTAINER_ID$2 || el.closest(`#${CONTAINER_ID$2}`)) return; el.classList.add("fqa-hide"); }); } function isBookshelfPath(path) { return path.startsWith("/bookshelf"); } function unmount$1() { var _a; observer$2 == null ? void 0 : observer$2.disconnect(); observer$2 = null; app$2 == null ? void 0 : app$2.unmount(); app$2 = null; container$2 == null ? void 0 : container$2.remove(); container$2 = null; (_a = document.getElementById("fqa-bookshelf-hover")) == null ? void 0 : _a.remove(); document.querySelectorAll(ORIGIN_SELECTOR$1).forEach((el) => { el.classList.remove("fqa-hide"); }); } async function mainHook$2(_previous) { if (!isBookshelfPath(window.location.pathname)) { unmount$1(); return; } if (app$2) { hideOrigin$1(); return; } injectStyle$3(); const origin = await waitForElement(ORIGIN_SELECTOR$1); if (!isBookshelfPath(window.location.pathname)) return; if (app$2) return; hideOrigin$1(); container$2 = document.createElement("div"); container$2.id = CONTAINER_ID$2; const anchor = origin ?? document.querySelector("#root") ?? document.body; if (origin == null ? void 0 : origin.parentElement) { origin.insertAdjacentElement("beforebegin", container$2); } else { anchor.appendChild(container$2); } app$2 = vue.createApp(_sfc_main$4); app$2.config.errorHandler = (err, _instance, info) => { console.error(`[fqa:bookshelf] Vue error (${info}):`, err); }; app$2.mount(container$2); console.log("[fqa:bookshelf] 书架视图已挂载"); document.title = "我的书架 - 番茄小说"; observer$2 = new MutationObserver((mutations) => { var _a; for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (!(node instanceof HTMLElement)) continue; if (node.id === CONTAINER_ID$2 || node.closest(`#${CONTAINER_ID$2}`)) continue; if ((_a = node.matches) == null ? void 0 : _a.call(node, ORIGIN_SELECTOR$1)) { node.classList.add("fqa-hide"); } else { hideOrigin$1(node); } } } }); observer$2.observe(document.body, { childList: true, subtree: true }); } function filter$2(path, _query, _hash) { return (isBookshelfPath(path) || !!app$2) && userState.isLogin; } const _exports$2 = [ { id: "bookshelfHook_onload", event: "load", filter: filter$2, handler: mainHook$2 }, { id: "bookshelfHook_onurlchange", event: "onUrlChange", filter: filter$2, handler: mainHook$2 } ]; const _hoisted_1$3 = ["aria-label"]; const _hoisted_2$3 = { class: "fqa-sr-cover" }; const _hoisted_3$3 = ["src", "alt"]; const _hoisted_4$3 = { key: 0, class: "fqa-sr-badge" }; const _hoisted_5$3 = { class: "fqa-sr-body" }; const _hoisted_6$3 = ["innerHTML"]; const _hoisted_7$3 = { key: 1, class: "fqa-sr-title" }; const _hoisted_8$3 = { class: "fqa-sr-author" }; const _hoisted_9$3 = { key: 2, class: "fqa-sr-summary" }; const _hoisted_10$3 = { class: "fqa-sr-meta" }; const _hoisted_11$2 = { key: 0, class: "fqa-sr-read" }; const _hoisted_12$2 = { key: 3, class: "fqa-sr-update" }; const _hoisted_13$2 = { key: 0, class: "fqa-sr-chapter" }; const _hoisted_14$2 = { key: 1, class: "fqa-sr-time" }; const _sfc_main$3 = /* @__PURE__ */ vue.defineComponent({ __name: "SearchBookCard", props: { book: {} }, emits: ["open", "contextmenu"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; const titleHtml = vue.computed(() => props.book.highlight_title ?? null); const statusText = vue.computed(() => mappingCreationStatus(props.book.status)); const wordText = vue.computed(() => { const n = props.book.word_count; if (!n) return ""; if (n >= 1e4) return `${(n / 1e4).toFixed(1).replace(/\.0$/, "")}万字`; return `${n}字`; }); const metaParts = vue.computed( () => [statusText.value, wordText.value, props.book.category].filter(Boolean) ); const readText = vue.computed(() => { if (props.book.sub_info) return props.book.sub_info; const n = props.book.read_count; if (!n) return ""; return n >= 1e4 ? `${(n / 1e4).toFixed(1).replace(/\.0$/, "")}万人在读` : `${n}人在读`; }); const updateText = vue.computed(() => { const t = props.book.last_publish_time; return t ? `${fromNow(t)}更新` : ""; }); const imgLoaded = vue.ref(false); vue.watch( () => props.book.cover_url, () => { imgLoaded.value = false; } ); function onContextMenu(event) { event.preventDefault(); emit("contextmenu", { book: props.book, x: event.clientX, y: event.clientY }); } return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", { class: "fqa-sr-card", role: "link", tabindex: "0", "aria-label": __props.book.title, onClick: _cache[2] || (_cache[2] = ($event) => emit("open", __props.book)), onKeydown: _cache[3] || (_cache[3] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.book), ["prevent"]), ["enter"])), onContextmenu: onContextMenu }, [ vue.createElementVNode("div", _hoisted_2$3, [ vue.createElementVNode("img", { class: vue.normalizeClass(["fqa-sr-cover-img", { "fqa-sr-cover-loading": !imgLoaded.value }]), crossorigin: "anonymous", loading: "lazy", referrerpolicy: "no-referrer", src: __props.book.cover_url, alt: __props.book.title, onLoad: _cache[0] || (_cache[0] = ($event) => imgLoaded.value = true), onError: _cache[1] || (_cache[1] = ($event) => imgLoaded.value = true) }, null, 42, _hoisted_3$3), __props.book.in_bookshelf ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_4$3, "在书架")) : vue.createCommentVNode("", true) ]), vue.createElementVNode("div", _hoisted_5$3, [ titleHtml.value ? (vue.openBlock(), vue.createElementBlock("h3", { key: 0, class: "fqa-sr-title", innerHTML: titleHtml.value }, null, 8, _hoisted_6$3)) : (vue.openBlock(), vue.createElementBlock("h3", _hoisted_7$3, vue.toDisplayString(__props.book.title), 1)), vue.createElementVNode("div", _hoisted_8$3, [ vue.createElementVNode("span", null, vue.toDisplayString(__props.book.author), 1), vue.createElementVNode("span", { class: vue.normalizeClass(["fqa-sr-score", { "fqa-sr-score-none": !__props.book.score }]) }, vue.toDisplayString(__props.book.score ? `${__props.book.score}分` : "暂无评分"), 3) ]), __props.book.summary ? (vue.openBlock(), vue.createElementBlock("p", _hoisted_9$3, vue.toDisplayString(__props.book.summary), 1)) : vue.createCommentVNode("", true), vue.createElementVNode("div", _hoisted_10$3, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(metaParts.value, (part) => { return vue.openBlock(), vue.createElementBlock("span", { key: part, class: "fqa-sr-tag" }, vue.toDisplayString(part), 1); }), 128)), readText.value ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_11$2, vue.toDisplayString(readText.value), 1)) : vue.createCommentVNode("", true) ]), updateText.value || __props.book.last_chapter_title ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_12$2, [ __props.book.last_chapter_title ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_13$2, vue.toDisplayString(__props.book.last_chapter_title), 1)) : vue.createCommentVNode("", true), updateText.value ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_14$2, vue.toDisplayString(updateText.value), 1)) : vue.createCommentVNode("", true) ])) : vue.createCommentVNode("", true) ]) ], 40, _hoisted_1$3); }; } }); const BOOK_TAB_TYPE = 1; const RESULT_SHOW_TYPE = 110; const RENDERABLE_TABS = /* @__PURE__ */ new Set([1, 2, 3, 5, 8]); function credentials() { return settings$1.searchPersonalized ? "include" : "omit"; } function num(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; } function bool(v) { if (typeof v === "string") return v !== "" && v !== "0" && v !== "false"; return Boolean(v); } function sanitizeHighlight(html) { if (typeof html !== "string" || !html) return void 0; return html.replace(/<(?!\/?em\s*\/?>)[^>]*>/gi, ""); } function normalizeBook(raw, cell) { var _a; const bookId2 = String((raw == null ? void 0 : raw.book_id) ?? ""); if (!bookId2 || bookId2 === "0") return null; const hl = cell == null ? void 0 : cell.search_high_light; return { book_id: bookId2, title: raw.book_name || raw.original_book_name || "", author: raw.author || "", cover_url: raw.thumb_url || raw.audio_thumb_url_hd || "", summary: raw.abstract || "", status: String(raw.creation_status ?? ""), word_count: num(raw.word_number), // sub_info 是接口给的展示文案,如「404章」「10.5万人在读」 sub_info: String(raw.sub_info ?? ""), read_count: num(raw.read_count), // 未评分的书下发 "0",视为没有评分 score: num(raw.score) > 0 ? String(raw.score) : "", category: raw.category || "", chapter_count: num(raw.serial_count), last_chapter_title: raw.last_chapter_title || "", last_publish_time: num(raw.last_publish_time) * 1e3, highlight_title: sanitizeHighlight((_a = hl == null ? void 0 : hl.title) == null ? void 0 : _a.rich_text), genre: raw.genre != null ? String(raw.genre) : void 0, in_bookshelf: bool(raw.in_bookshelf) }; } function collectBooks(cells) { const books = []; const seen = /* @__PURE__ */ new Set(); const take = (cell) => { for (const raw of (cell == null ? void 0 : cell.book_data) ?? []) { const book = normalizeBook(raw, cell); if (!book || seen.has(book.book_id)) continue; seen.add(book.book_id); books.push(book); } }; const results = cells.filter((c) => Number(c == null ? void 0 : c.show_type) === RESULT_SHOW_TYPE); if (results.length) { results.forEach(take); return books; } cells.filter((c) => Array.isArray(c == null ? void 0 : c.book_data) && c.book_data.length).forEach(take); return books; } function collectTabs(tabs) { return tabs.map((t) => { var _a; return { tab_type: num(t == null ? void 0 : t.tab_type), // 字段名是 title,不是 tab_name tab_name: String((t == null ? void 0 : t.title) ?? ""), has_selector: Array.isArray((_a = t == null ? void 0 : t.selector) == null ? void 0 : _a.rows) && t.selector.rows.length > 0 }; }).filter((t) => t.tab_type > 0 && t.tab_name && RENDERABLE_TABS.has(t.tab_type)); } function collectSelectorRows(tabs) { var _a, _b; const rows = (_b = (_a = tabs.find((t) => { var _a2; return Array.isArray((_a2 = t == null ? void 0 : t.selector) == null ? void 0 : _a2.rows); })) == null ? void 0 : _a.selector) == null ? void 0 : _b.rows; if (!Array.isArray(rows)) return []; return rows.map((row) => ({ name: String((row == null ? void 0 : row.row_name) ?? ""), items: (Array.isArray(row == null ? void 0 : row.items) ? row.items : []).map((item) => ({ id: String((item == null ? void 0 : item.selector_item_id) ?? ""), name: String((item == null ? void 0 : item.show_name) ?? ""), default: Boolean(item == null ? void 0 : item.is_default_selected) })).filter((item) => item.id && item.name) })).filter((row) => row.name && row.items.length > 0); } async function search({ query, passback = 0, selectedItems = [], tabType = BOOK_TAB_TYPE }) { const selected = tabType === BOOK_TAB_TYPE ? selectedItems.filter(Boolean).join(",") : ""; const j = await webGet( "/bookapi/search/tab/v", { query, passback: String(passback), selected_items: selected, tab_type: String(tabType) }, credentials() ); if ((j == null ? void 0 : j.code) !== void 0 && j.code !== 0) { throw new Error(j.message || `搜索失败(code=${j.code})`); } const tabs = Array.isArray(j == null ? void 0 : j.search_tabs) ? j.search_tabs : []; const current = tabs.find((t) => num(t == null ? void 0 : t.tab_type) === tabType); const cells = Array.isArray(current == null ? void 0 : current.data) ? current.data : []; const books = collectBooks(cells); return { books, // 回填服务端游标;没给就按已取回的书数往前推,至少不会原地打转 next_passback: num(current == null ? void 0 : current.passback) || passback + Math.max(books.length, 1), has_more: Boolean(current == null ? void 0 : current.has_more) && books.length > 0, tabs: collectTabs(tabs), selector_rows: collectSelectorRows(tabs) }; } function collectWords(list) { if (!Array.isArray(list)) return []; return list.map((t) => ({ word: String((t == null ? void 0 : t.tag_title) ?? ""), // 「877405热搜值」之类的附加说明 tag: (t == null ? void 0 : t.tag_attached) ? String(t.tag_attached) : void 0, label: (t == null ? void 0 : t.label) ? String(t.label) : void 0 })).filter((w) => w.word); } function collectSugBooks(list) { if (!Array.isArray(list)) return []; return list.map((b) => ({ book_id: String((b == null ? void 0 : b.book_id) ?? ""), title: String((b == null ? void 0 : b.book_name) ?? ""), cover_url: String((b == null ? void 0 : b.thumb_url) ?? ""), author: (b == null ? void 0 : b.author) ? String(b.author) : void 0, desc: (b == null ? void 0 : b.sub_info) ? String(b.sub_info) : void 0 })).filter((b) => b.book_id && b.title); } function pushSection(out, title, words, books) { if (!words.length && !books.length) return; out.push({ title: title || (words.length ? "热搜" : "推荐"), kind: words.length ? "hotword" : "book", words, books }); } async function getSearchLanding() { const j = await webGet( "/bookapi/plan/v", { search_source: "1", scene: "10", new_search_middle_page: "true", search_middle_page_version: "2", from: "search_input_page", tab_name: "store", bookstore_tab: "2", bookstore_tab_type: "2", hot_word_exchange: "false", query_history_removed: "false", user_is_login: settings$1.searchPersonalized ? "1" : "0" }, credentials() ); if ((j == null ? void 0 : j.code) !== void 0 && j.code !== 0) { throw new Error(j.message || `加载推荐失败(code=${j.code})`); } const sections = []; for (const cell of Array.isArray(j == null ? void 0 : j.data) ? j.data : []) { if (Array.isArray(cell == null ? void 0 : cell.cell_data)) { for (const sub of cell.cell_data) { pushSection( sections, String((sub == null ? void 0 : sub.cell_name) ?? ""), collectWords(sub == null ? void 0 : sub.search_tag_data), collectSugBooks(sub == null ? void 0 : sub.book_data) ); } continue; } pushSection( sections, String((cell == null ? void 0 : cell.cell_name) ?? "") || "猜你想搜", collectWords(cell == null ? void 0 : cell.search_tag_data), collectSugBooks(cell == null ? void 0 : cell.book_data) ); } return sections; } const _hoisted_1$2 = { class: "fqa-s-landing" }; const _hoisted_2$2 = { key: 0, class: "fqa-s-landing-sk" }; const _hoisted_3$2 = { key: 1, class: "fqa-s-status" }; const _hoisted_4$2 = { key: 2, class: "fqa-s-status" }; const _hoisted_5$2 = { class: "fqa-s-sec-title" }; const _hoisted_6$2 = { key: 0, class: "fqa-s-words" }; const _hoisted_7$2 = ["onClick"]; const _hoisted_8$2 = { key: 0, class: "fqa-s-word-label" }; const _hoisted_9$2 = { key: 1, class: "fqa-s-word-tag" }; const _hoisted_10$2 = { key: 1, class: "fqa-s-sugs" }; const _hoisted_11$1 = ["onClick", "onKeydown"]; const _hoisted_12$1 = ["src", "alt"]; const _hoisted_13$1 = ["title"]; const _hoisted_14$1 = { key: 0, class: "fqa-s-sug-sub" }; const _sfc_main$2 = /* @__PURE__ */ vue.defineComponent({ __name: "SearchLanding", emits: ["word"], setup(__props, { emit: __emit }) { const emit = __emit; const sections = vue.ref([]); const loading = vue.ref(true); const error = vue.ref(null); async function load2() { loading.value = true; error.value = null; try { sections.value = await getSearchLanding(); } catch (err) { console.error("[fqa:search] 推荐加载失败:", err); error.value = err instanceof Error ? err.message : String(err); } finally { loading.value = false; } } function openBook(bookId2) { unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId2}`; } vue.onMounted(load2); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", _hoisted_1$2, [ loading.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_2$2, [ (vue.openBlock(), vue.createElementBlock(vue.Fragment, null, vue.renderList(12, (n) => { return vue.createElementVNode("div", { key: n, class: "fqa-sk-chip fqa-sk-anim" }); }), 64)) ])) : error.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_3$2, [ _cache[0] || (_cache[0] = vue.createElementVNode("div", { class: "fqa-s-status-title" }, "推荐内容加载失败", -1)), _cache[1] || (_cache[1] = vue.createElementVNode("div", null, "直接在上方输入关键词也可以搜索", -1)), vue.createElementVNode("button", { class: "fqa-s-submit", onClick: load2 }, "重试") ])) : !sections.value.length ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_4$2, [..._cache[2] || (_cache[2] = [ vue.createElementVNode("div", { class: "fqa-s-status-title" }, "输入关键词开始搜索", -1) ])])) : (vue.openBlock(true), vue.createElementBlock(vue.Fragment, { key: 3 }, vue.renderList(sections.value, (section, i2) => { return vue.openBlock(), vue.createElementBlock("section", { key: `${section.title}-${i2}`, class: "fqa-s-sec" }, [ vue.createElementVNode("h3", _hoisted_5$2, vue.toDisplayString(section.title), 1), section.words.length ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_6$2, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(section.words, (w, wi) => { return vue.openBlock(), vue.createElementBlock("button", { key: `${w.word}-${wi}`, class: "fqa-s-word", onClick: ($event) => emit("word", w.word) }, [ w.label ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_8$2, vue.toDisplayString(w.label), 1)) : vue.createCommentVNode("", true), vue.createTextVNode(" " + vue.toDisplayString(w.word) + " ", 1), w.tag ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_9$2, vue.toDisplayString(w.tag), 1)) : vue.createCommentVNode("", true) ], 8, _hoisted_7$2); }), 128)) ])) : vue.createCommentVNode("", true), section.books.length ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_10$2, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(section.books, (b) => { return vue.openBlock(), vue.createElementBlock("div", { key: b.book_id, class: "fqa-s-sug", role: "link", tabindex: "0", onClick: ($event) => openBook(b.book_id), onKeydown: vue.withKeys(vue.withModifiers(($event) => openBook(b.book_id), ["prevent"]), ["enter"]) }, [ vue.createElementVNode("img", { class: "fqa-s-sug-cover", crossorigin: "anonymous", loading: "lazy", referrerpolicy: "no-referrer", src: b.cover_url, alt: b.title }, null, 8, _hoisted_12$1), vue.createElementVNode("div", { class: "fqa-s-sug-title", title: b.title }, vue.toDisplayString(b.title), 9, _hoisted_13$1), b.desc || b.author ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_14$1, vue.toDisplayString(b.desc || b.author), 1)) : vue.createCommentVNode("", true) ], 40, _hoisted_11$1); }), 128)) ])) : vue.createCommentVNode("", true) ]); }), 128)) ]); }; } }); const FALLBACK_TABS = [{ tab_type: BOOK_TAB_TYPE, tab_name: "综合", has_selector: true }]; function useSearch() { const query = vue.ref(""); const tabType = vue.ref(BOOK_TAB_TYPE); const tabs = vue.ref([]); const selectorRows = vue.ref([]); const selected = vue.ref({}); const books = vue.ref([]); const passback = vue.ref(0); const hasMore = vue.ref(false); const loading = vue.ref(false); const loadingMore = vue.ref(false); const error = vue.ref(null); const searched = vue.ref(false); let token = 0; const selectedItems = vue.computed( () => Object.values(selected.value).filter(Boolean) ); const availableTabs = vue.computed(() => tabs.value.length ? tabs.value : FALLBACK_TABS); const canFilter = vue.computed(() => tabType.value === BOOK_TAB_TYPE && selectorRows.value.length > 0); const filterCount = vue.computed(() => selectedItems.value.length); async function run(reset2) { const q = query.value.trim(); if (!q) return; const current = ++token; if (reset2) { passback.value = 0; loading.value = true; error.value = null; } else { if (loadingMore.value || !hasMore.value) return; loadingMore.value = true; } try { const result = await search({ query: q, passback: passback.value, selectedItems: selectedItems.value, tabType: tabType.value }); if (current !== token) return; if (reset2) { books.value = result.books; } else { const seen = new Set(books.value.map((b) => b.book_id)); books.value = [...books.value, ...result.books.filter((b) => !seen.has(b.book_id))]; } passback.value = result.next_passback; hasMore.value = result.has_more; if (result.tabs.length) tabs.value = result.tabs; if (result.selector_rows.length) selectorRows.value = result.selector_rows; searched.value = true; } catch (err) { if (current !== token) return; console.error("[fqa:search] 搜索失败:", err); error.value = err instanceof Error ? err.message : String(err); if (reset2) books.value = []; searched.value = true; } finally { if (current === token) { loading.value = false; loadingMore.value = false; } } } async function submit(q) { if (q !== void 0) query.value = q; await run(true); } function reset() { token++; query.value = ""; books.value = []; passback.value = 0; hasMore.value = false; searched.value = false; error.value = null; loading.value = false; loadingMore.value = false; selected.value = {}; tabType.value = BOOK_TAB_TYPE; } async function loadMore() { if (!hasMore.value || loading.value || loadingMore.value) return; await run(false); } function selectTab(next) { if (tabType.value === next) return; tabType.value = next; if (next !== BOOK_TAB_TYPE) selected.value = {}; void run(true); } function toggleFilter(rowName, itemId2) { const cur = selected.value[rowName]; if (cur === itemId2) { const next = { ...selected.value }; delete next[rowName]; selected.value = next; } else { selected.value = { ...selected.value, [rowName]: itemId2 }; } void run(true); } function clearFilters() { if (!filterCount.value) return; selected.value = {}; void run(true); } const isEmpty = vue.computed( () => searched.value && !loading.value && !error.value && books.value.length === 0 ); return { query, tabType, availableTabs, selectorRows, selected, canFilter, filterCount, books, hasMore, loading, loadingMore, error, searched, isEmpty, submit, reset, loadMore, selectTab, toggleFilter, clearFilters }; } const routeQuery = vue.ref(""); function parseQuery(path) { const rest = path.replace(/^\/search\/?/, ""); if (!rest) return ""; try { return decodeURIComponent(rest); } catch { return rest; } } function syncFromUrl(path) { const q = parseQuery(path); if (routeQuery.value !== q) routeQuery.value = q; } function pushQuery(q) { routeQuery.value = q; const next = q ? `/search/${encodeURIComponent(q)}` : "/search"; if (unsafeWindow.location.pathname === next) return; unsafeWindow.history.pushState(null, "", next); } const _hoisted_1$1 = { id: "fqa-search" }; const _hoisted_2$1 = { class: "fqa-s-bar" }; const _hoisted_3$1 = { class: "fqa-s-inputwrap" }; const _hoisted_4$1 = ["disabled"]; const _hoisted_5$1 = ["aria-selected", "onClick", "onKeydown"]; const _hoisted_6$1 = { key: 0, class: "fqa-s-filterbar" }; const _hoisted_7$1 = { key: 0, class: "fqa-s-fcount" }; const _hoisted_8$1 = { class: "fqa-s-hint" }; const _hoisted_9$1 = { key: 1, class: "fqa-s-filters" }; const _hoisted_10$1 = { class: "fqa-s-frow-name" }; const _hoisted_11 = { class: "fqa-s-fitems" }; const _hoisted_12 = ["onClick"]; const _hoisted_13 = { key: 2, class: "fqa-s-status" }; const _hoisted_14 = { key: 3, class: "fqa-s-status" }; const _hoisted_15 = { class: "fqa-s-list" }; const _hoisted_16 = { key: 1, class: "fqa-s-loadmore" }; const _hoisted_17 = { key: 2, class: "fqa-s-inline-error" }; const _hoisted_18 = { key: 2, class: "fqa-s-privacy" }; const _hoisted_19 = { key: 0, class: "fqa-toast" }; const DOWNLOAD_PREFIX = "download:"; const _sfc_main$1 = /* @__PURE__ */ vue.defineComponent({ __name: "SearchView", setup(__props) { const { query, tabType, availableTabs, selectorRows, selected, canFilter, filterCount, books, hasMore, loading, loadingMore, error, isEmpty, submit, reset, loadMore, selectTab, toggleFilter, clearFilters } = useSearch(); const input = vue.ref(routeQuery.value); const filterOpen = vue.ref(false); const showLanding = vue.computed(() => !query.value.trim()); async function doSearch(q = input.value) { const trimmed = q.trim(); if (!trimmed) return; input.value = trimmed; filterOpen.value = false; pushQuery(trimmed); await submit(trimmed); } function onWord(word) { void doSearch(word); } const sentinel = vue.ref(null); let io = null; vue.onMounted(async () => { if (routeQuery.value) await doSearch(routeQuery.value); if (typeof IntersectionObserver !== "undefined") { io = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting)) void loadMore(); }, { rootMargin: "400px" } ); vue.watch( sentinel, (el) => { io == null ? void 0 : io.disconnect(); if (el) io == null ? void 0 : io.observe(el); }, { immediate: true, flush: "post" } ); } }); vue.onBeforeUnmount(() => { io == null ? void 0 : io.disconnect(); io = null; }); vue.watch( routeQuery, (q) => { input.value = q; if (q === query.value.trim()) return; if (q) { void submit(q); } else { filterOpen.value = false; reset(); } } ); const menuVisible = vue.ref(false); const menuPos = vue.ref({ x: 0, y: 0 }); const menuBook = vue.ref(null); const toast = vue.ref(null); let toastTimer; function showToast(msg) { toast.value = msg; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { toast.value = null; toastTimer = void 0; }, 2600); } const menuItems = vue.computed(() => { var _a, _b, _c; const items = [ { key: "detail", label: "查看详情" }, { key: "shelf", label: ((_a = menuBook.value) == null ? void 0 : _a.in_bookshelf) ? "已在书架" : "加入书架", disabled: !userState.isLogin || Boolean((_b = menuBook.value) == null ? void 0 : _b.in_bookshelf) }, { key: "author", label: "搜索该作者", disabled: !((_c = menuBook.value) == null ? void 0 : _c.author) } ]; if (settings$1.enableDownload) { items.push({ key: "download", label: "下载", children: [ { key: `${DOWNLOAD_PREFIX}epub`, label: "EPUB" }, { key: `${DOWNLOAD_PREFIX}txt`, label: "TXT" } ] }); } return items; }); function onCardContextMenu({ book, x, y }) { menuBook.value = book; menuPos.value = { x, y }; menuVisible.value = true; } function openBook(book) { unsafeWindow.location.href = `https://fanqienovel.com/page/${book.book_id}`; } async function onMenuSelect(key) { const book = menuBook.value; if (!book) return; if (key === "detail") { openBook(book); return; } if (key === "author") { void doSearch(book.author); return; } if (key.startsWith(DOWNLOAD_PREFIX)) { const format = key.slice(DOWNLOAD_PREFIX.length); void startDownload(book.book_id, { format }); return; } if (key === "shelf") { try { await addToBookshelf(book.book_id); book.in_bookshelf = true; showToast(`已把《${book.title}》加入书架`); } catch (err) { console.error("[fqa:search] 加入书架失败:", err); showToast(err instanceof Error ? err.message : "加入书架失败"); } } } const tabsRef = vue.ref(null); const inkStyle = vue.ref({ left: "0px", width: "0px" }); function updateInk() { const wrap = tabsRef.value; if (!wrap) return; const index = availableTabs.value.findIndex((t) => t.tab_type === tabType.value); const el = wrap.querySelectorAll(".fqa-s-tab")[index]; if (!el) return; inkStyle.value = { left: `${el.offsetLeft}px`, width: `${el.offsetWidth}px` }; } vue.watch([tabType, availableTabs], () => vue.nextTick(updateInk), { deep: true }); vue.watch(showLanding, () => vue.nextTick(updateInk)); vue.onMounted(() => { window.addEventListener("resize", updateInk); }); vue.onBeforeUnmount(() => { window.removeEventListener("resize", updateInk); if (toastTimer) clearTimeout(toastTimer); }); const resultHint = vue.computed(() => { if (loading.value) return "搜索中…"; if (books.value.length) return `已加载 ${books.value.length} 条`; return ""; }); return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", _hoisted_1$1, [ vue.createElementVNode("div", _hoisted_2$1, [ vue.createElementVNode("div", _hoisted_3$1, [ vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => input.value = $event), class: "fqa-s-input", type: "search", placeholder: "搜索书名、作者", "aria-label": "搜索", onKeydown: _cache[1] || (_cache[1] = vue.withKeys(vue.withModifiers(($event) => doSearch(), ["prevent"]), ["enter"])) }, null, 544), [ [vue.vModelText, input.value] ]), input.value ? (vue.openBlock(), vue.createElementBlock("button", { key: 0, class: "fqa-s-clear", "aria-label": "清空", onClick: _cache[2] || (_cache[2] = ($event) => input.value = "") }, " ✕ ")) : vue.createCommentVNode("", true) ]), vue.createElementVNode("button", { class: "fqa-s-submit", disabled: !input.value.trim() || vue.unref(loading), onClick: _cache[3] || (_cache[3] = ($event) => doSearch()) }, " 搜索 ", 8, _hoisted_4$1) ]), showLanding.value ? (vue.openBlock(), vue.createBlock(_sfc_main$2, { key: 0, onWord })) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 1 }, [ vue.createElementVNode("div", { ref_key: "tabsRef", ref: tabsRef, class: "fqa-s-tabs", role: "tablist" }, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(vue.unref(availableTabs), (tab) => { return vue.openBlock(), vue.createElementBlock("div", { key: tab.tab_type, class: vue.normalizeClass(["fqa-s-tab", { "fqa-s-tab-active": vue.unref(tabType) === tab.tab_type }]), role: "tab", tabindex: "0", "aria-selected": vue.unref(tabType) === tab.tab_type, onClick: ($event) => vue.unref(selectTab)(tab.tab_type), onKeydown: vue.withKeys(vue.withModifiers(($event) => vue.unref(selectTab)(tab.tab_type), ["prevent"]), ["enter"]) }, vue.toDisplayString(tab.tab_name), 43, _hoisted_5$1); }), 128)), vue.createElementVNode("span", { class: "fqa-s-tab-ink", style: vue.normalizeStyle(inkStyle.value) }, null, 4) ], 512), vue.unref(canFilter) ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_6$1, [ vue.createElementVNode("button", { class: vue.normalizeClass(["fqa-s-fbtn", { "fqa-s-fbtn-on": filterOpen.value || vue.unref(filterCount) > 0 }]), onClick: _cache[4] || (_cache[4] = ($event) => filterOpen.value = !filterOpen.value) }, [ _cache[8] || (_cache[8] = vue.createTextVNode(" 筛选", -1)), vue.unref(filterCount) ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_7$1, vue.toDisplayString(vue.unref(filterCount)), 1)) : vue.createCommentVNode("", true) ], 2), vue.unref(filterCount) ? (vue.openBlock(), vue.createElementBlock("button", { key: 0, class: "fqa-s-fclear", onClick: _cache[5] || (_cache[5] = //@ts-ignore (...args) => vue.unref(clearFilters) && vue.unref(clearFilters)(...args)) }, "清除")) : vue.createCommentVNode("", true), vue.createElementVNode("span", _hoisted_8$1, vue.toDisplayString(resultHint.value), 1) ])) : vue.createCommentVNode("", true), vue.unref(canFilter) && filterOpen.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_9$1, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(vue.unref(selectorRows), (row) => { return vue.openBlock(), vue.createElementBlock("div", { key: row.name, class: "fqa-s-frow" }, [ vue.createElementVNode("span", _hoisted_10$1, vue.toDisplayString(row.name), 1), vue.createElementVNode("div", _hoisted_11, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(row.items, (item) => { return vue.openBlock(), vue.createElementBlock("button", { key: item.id, class: vue.normalizeClass(["fqa-s-fitem", { "fqa-s-fitem-on": vue.unref(selected)[row.name] === item.id }]), onClick: ($event) => vue.unref(toggleFilter)(row.name, item.id) }, vue.toDisplayString(item.name), 11, _hoisted_12); }), 128)) ]) ]); }), 128)) ])) : vue.createCommentVNode("", true), vue.unref(error) && !vue.unref(books).length ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_13, [ _cache[9] || (_cache[9] = vue.createElementVNode("div", { class: "fqa-s-status-title" }, "搜索失败", -1)), vue.createElementVNode("div", null, vue.toDisplayString(vue.unref(error)), 1), vue.createElementVNode("button", { class: "fqa-s-submit", onClick: _cache[6] || (_cache[6] = ($event) => doSearch()) }, "重试") ])) : vue.unref(isEmpty) ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_14, [..._cache[10] || (_cache[10] = [ vue.createElementVNode("div", { class: "fqa-s-status-title" }, "没有找到相关内容", -1), vue.createElementVNode("div", null, "换个关键词或者放宽筛选条件试试", -1) ])])) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 4 }, [ vue.createElementVNode("div", _hoisted_15, [ vue.unref(loading) ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, vue.renderList(5, (n) => { return vue.createElementVNode("div", { key: `sk-${n}`, class: "fqa-sr-card fqa-sr-skeleton" }, [..._cache[11] || (_cache[11] = [ vue.createStaticVNode('', 2) ])]); }), 64)) : (vue.openBlock(true), vue.createElementBlock(vue.Fragment, { key: 1 }, vue.renderList(vue.unref(books), (book) => { return vue.openBlock(), vue.createBlock(_sfc_main$3, { key: book.book_id, book, onOpen: openBook, onContextmenu: onCardContextMenu }, null, 8, ["book"]); }), 128)) ]), !vue.unref(loading) && vue.unref(hasMore) ? (vue.openBlock(), vue.createElementBlock("div", { key: 0, ref_key: "sentinel", ref: sentinel, class: "fqa-s-loadmore" }, vue.toDisplayString(vue.unref(loadingMore) ? "加载中…" : "滚动加载更多"), 513)) : !vue.unref(loading) && vue.unref(books).length ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_16, "没有更多了")) : vue.createCommentVNode("", true), vue.unref(error) && vue.unref(books).length ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_17, vue.toDisplayString(vue.unref(error)), 1)) : vue.createCommentVNode("", true) ], 64)) ], 64)), !vue.unref(settings$1).searchPersonalized ? (vue.openBlock(), vue.createElementBlock("p", _hoisted_18, " 当前使用匿名搜索。如需按你的阅读偏好排序,可在助手设置里开启个人化推荐。 ")) : vue.createCommentVNode("", true), (vue.openBlock(), vue.createBlock(vue.Teleport, { to: "body" }, [ vue.createVNode(_sfc_main$5, { visible: menuVisible.value, x: menuPos.value.x, y: menuPos.value.y, items: menuItems.value, onSelect: onMenuSelect, onClose: _cache[7] || (_cache[7] = ($event) => menuVisible.value = false) }, null, 8, ["visible", "x", "y", "items"]), toast.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_19, vue.toDisplayString(toast.value), 1)) : vue.createCommentVNode("", true) ])) ]); }; } }); const searchcss = "#fqa-search {\n --fqa-text: #1f2329;\n --fqa-text-sub: #646a73;\n --fqa-text-weak: #8f959e;\n --fqa-border: rgba(31, 35, 41, 0.08);\n --fqa-hover: rgba(31, 35, 41, 0.04);\n --fqa-accent: #ff6f3d;\n --fqa-skeleton: rgba(31, 35, 41, 0.06);\n --fqa-skeleton-hl: rgba(31, 35, 41, 0.12);\n --fqa-shadow: 0 4px 16px rgba(31, 35, 41, 0.08);\n --fqa-card-bg: #fff;\n\n display: block;\n box-sizing: border-box;\n width: 100%;\n max-width: 1000px;\n margin: 0 auto;\n /* 顶部让开原站 fixed 顶栏 */\n padding: calc(80px + 24px) 16px 64px;\n color: var(--fqa-text);\n font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,\n sans-serif;\n}\n\n#fqa-search *,\n#fqa-search *::before,\n#fqa-search *::after {\n box-sizing: border-box;\n}\n\n#fqa-search div,\n#fqa-search span,\n#fqa-search h3,\n#fqa-search p {\n margin: 0;\n padding: 0;\n border: 0;\n float: none;\n position: static;\n}\n\n/* -------------------------------- 搜索栏 -------------------------------- */\n\n#fqa-search .fqa-s-bar {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 20px;\n}\n\n#fqa-search .fqa-s-inputwrap {\n position: relative;\n flex: 1 1 auto;\n min-width: 0;\n}\n\n#fqa-search .fqa-s-input {\n width: 100%;\n padding: 11px 36px 11px 16px;\n box-sizing: border-box;\n border: 1px solid var(--fqa-border);\n border-radius: 999px;\n background: var(--fqa-card-bg);\n color: var(--fqa-text);\n font-size: 15px;\n font-family: inherit;\n line-height: 22px;\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\n}\n\n#fqa-search .fqa-s-input::placeholder {\n color: var(--fqa-text-weak);\n}\n\n#fqa-search .fqa-s-input:focus {\n outline: none;\n border-color: var(--fqa-accent);\n box-shadow: 0 0 0 3px rgba(255, 111, 61, 0.12);\n}\n\n/* 隐藏浏览器自带的 search 清除按钮,用自己的 */\n#fqa-search .fqa-s-input::-webkit-search-cancel-button {\n appearance: none;\n}\n\n#fqa-search .fqa-s-clear {\n position: absolute;\n top: 50%;\n right: 10px;\n transform: translateY(-50%);\n width: 22px;\n height: 22px;\n padding: 0;\n border: 0;\n border-radius: 50%;\n background: var(--fqa-hover);\n color: var(--fqa-text-weak);\n font-size: 11px;\n line-height: 1;\n cursor: pointer;\n}\n\n#fqa-search .fqa-s-clear:hover {\n color: var(--fqa-text);\n}\n\n#fqa-search .fqa-s-submit {\n flex: 0 0 auto;\n padding: 11px 24px;\n border: 0;\n border-radius: 999px;\n background: var(--fqa-accent);\n color: #fff;\n font-size: 15px;\n font-family: inherit;\n line-height: 22px;\n cursor: pointer;\n transition: opacity 0.15s ease;\n}\n\n#fqa-search .fqa-s-submit:hover:not(:disabled) {\n opacity: 0.88;\n}\n\n#fqa-search .fqa-s-submit:disabled {\n opacity: 0.45;\n cursor: default;\n}\n\n/* --------------------------------- tab --------------------------------- */\n\n#fqa-search .fqa-s-tabs {\n position: relative;\n display: flex;\n align-items: center;\n gap: 4px;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--fqa-border);\n overflow-x: auto;\n scrollbar-width: none;\n}\n\n#fqa-search .fqa-s-tabs::-webkit-scrollbar {\n display: none;\n}\n\n#fqa-search .fqa-s-tab {\n flex: 0 0 auto;\n padding: 10px 16px;\n color: var(--fqa-text-sub);\n font-size: 15px;\n line-height: 22px;\n white-space: nowrap;\n cursor: pointer;\n user-select: none;\n transition: color 0.15s ease;\n}\n\n#fqa-search .fqa-s-tab:hover {\n color: var(--fqa-text);\n}\n\n#fqa-search .fqa-s-tab-active {\n color: var(--fqa-accent);\n font-weight: 600;\n}\n\n#fqa-search .fqa-s-tab-ink {\n position: absolute;\n bottom: -1px;\n left: 0;\n width: 0;\n height: 2px;\n border-radius: 2px;\n background: var(--fqa-accent);\n transition: left 0.25s ease, width 0.25s ease;\n}\n\n/* -------------------------------- 筛选器 -------------------------------- */\n\n#fqa-search .fqa-s-filterbar {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 12px;\n}\n\n#fqa-search .fqa-s-fbtn,\n#fqa-search .fqa-s-fclear {\n padding: 5px 14px;\n border: 1px solid var(--fqa-border);\n border-radius: 999px;\n background: transparent;\n color: var(--fqa-text-sub);\n font-size: 13px;\n font-family: inherit;\n line-height: 20px;\n cursor: pointer;\n transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;\n}\n\n#fqa-search .fqa-s-fbtn:hover,\n#fqa-search .fqa-s-fclear:hover {\n border-color: var(--fqa-accent);\n color: var(--fqa-accent);\n}\n\n#fqa-search .fqa-s-fbtn-on {\n border-color: var(--fqa-accent);\n color: var(--fqa-accent);\n background: rgba(255, 111, 61, 0.08);\n}\n\n#fqa-search .fqa-s-fcount {\n display: inline-block;\n min-width: 16px;\n margin-left: 5px;\n padding: 0 4px;\n border-radius: 8px;\n background: var(--fqa-accent);\n color: #fff;\n font-size: 11px;\n line-height: 16px;\n text-align: center;\n}\n\n#fqa-search .fqa-s-hint {\n margin-left: auto;\n color: var(--fqa-text-weak);\n font-size: 13px;\n}\n\n#fqa-search .fqa-s-filters {\n margin-bottom: 16px;\n padding: 14px 16px;\n border: 1px solid var(--fqa-border);\n border-radius: 10px;\n background: var(--fqa-card-bg);\n}\n\n#fqa-search .fqa-s-frow {\n display: flex;\n align-items: flex-start;\n gap: 12px;\n padding: 7px 0;\n}\n\n#fqa-search .fqa-s-frow-name {\n flex: 0 0 58px;\n padding-top: 5px;\n color: var(--fqa-text-weak);\n font-size: 13px;\n line-height: 20px;\n}\n\n#fqa-search .fqa-s-fitems {\n display: flex;\n flex: 1 1 auto;\n flex-wrap: wrap;\n gap: 8px;\n min-width: 0;\n}\n\n#fqa-search .fqa-s-fitem {\n padding: 4px 12px;\n border: 1px solid transparent;\n border-radius: 6px;\n background: var(--fqa-hover);\n color: var(--fqa-text-sub);\n font-size: 13px;\n font-family: inherit;\n line-height: 20px;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n}\n\n#fqa-search .fqa-s-fitem:hover {\n color: var(--fqa-text);\n}\n\n#fqa-search .fqa-s-fitem-on {\n background: rgba(255, 111, 61, 0.12);\n border-color: rgba(255, 111, 61, 0.4);\n color: var(--fqa-accent);\n font-weight: 500;\n}\n\n/* ------------------------------- 结果列表 ------------------------------- */\n\n#fqa-search .fqa-s-list {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n#fqa-search .fqa-sr-card {\n display: flex;\n gap: 16px;\n padding: 16px 12px;\n border-radius: 10px;\n cursor: pointer;\n outline: none;\n transition: background 0.15s ease;\n}\n\n#fqa-search .fqa-sr-card:hover {\n background: var(--fqa-hover);\n}\n\n#fqa-search .fqa-sr-card:focus-visible {\n box-shadow: 0 0 0 2px var(--fqa-accent);\n}\n\n#fqa-search .fqa-sr-skeleton {\n cursor: default;\n}\n\n#fqa-search .fqa-sr-skeleton:hover {\n background: transparent;\n}\n\n#fqa-search .fqa-sr-cover {\n position: relative;\n flex: 0 0 96px;\n width: 96px;\n aspect-ratio: 3 / 4;\n border-radius: 6px;\n overflow: hidden;\n background: var(--fqa-skeleton);\n}\n\n#fqa-search .fqa-sr-cover-img {\n position: absolute;\n inset: 0;\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n transition: opacity 0.25s ease;\n}\n\n#fqa-search .fqa-sr-cover-loading {\n opacity: 0;\n}\n\n#fqa-search .fqa-sr-badge {\n position: absolute;\n left: 0;\n right: 0;\n bottom: 0;\n padding: 2px 4px;\n background: rgba(31, 35, 41, 0.7);\n color: #fff;\n font-size: 10px;\n line-height: 14px;\n text-align: center;\n}\n\n#fqa-search .fqa-sr-body {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n min-width: 0;\n}\n\n#fqa-search .fqa-sr-title {\n color: var(--fqa-text);\n font-size: 16px;\n font-weight: 600;\n line-height: 24px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* 接口下发的高亮片段 */\n#fqa-search .fqa-sr-title em {\n color: var(--fqa-accent);\n font-style: normal;\n}\n\n#fqa-search .fqa-sr-author {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 3px;\n color: var(--fqa-text-sub);\n font-size: 13px;\n line-height: 20px;\n}\n\n#fqa-search .fqa-sr-author > span:first-child {\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n}\n\n#fqa-search .fqa-sr-score {\n flex: 0 0 auto;\n color: var(--fqa-accent);\n font-weight: 500;\n}\n\n/* 无评分时弱化,不跟真实分数抢注意力 */\n#fqa-search .fqa-sr-score-none {\n color: var(--fqa-text-weak);\n font-weight: 400;\n}\n\n#fqa-search .fqa-sr-summary {\n display: -webkit-box;\n margin-top: 6px;\n color: var(--fqa-text-sub);\n font-size: 13px;\n line-height: 20px;\n -webkit-line-clamp: 2;\n line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n}\n\n#fqa-search .fqa-sr-meta {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 6px;\n margin-top: 8px;\n}\n\n#fqa-search .fqa-sr-tag {\n padding: 1px 7px;\n border-radius: 4px;\n background: var(--fqa-hover);\n color: var(--fqa-text-weak);\n font-size: 12px;\n line-height: 18px;\n}\n\n#fqa-search .fqa-sr-read {\n color: var(--fqa-text-weak);\n font-size: 12px;\n line-height: 18px;\n}\n\n#fqa-search .fqa-sr-update {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 6px;\n color: var(--fqa-text-weak);\n font-size: 12px;\n line-height: 18px;\n}\n\n#fqa-search .fqa-sr-chapter {\n min-width: 0;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n}\n\n#fqa-search .fqa-sr-time {\n flex: 0 0 auto;\n}\n\n@media (max-width: 600px) {\n #fqa-search .fqa-sr-cover {\n flex-basis: 72px;\n width: 72px;\n }\n\n #fqa-search .fqa-sr-summary {\n -webkit-line-clamp: 3;\n line-clamp: 3;\n }\n}\n\n/* -------------------------------- 落地页 -------------------------------- */\n\n#fqa-search .fqa-s-sec {\n margin-bottom: 28px;\n}\n\n#fqa-search .fqa-s-sec-title {\n margin-bottom: 12px;\n font-size: 16px;\n font-weight: 600;\n line-height: 24px;\n}\n\n#fqa-search .fqa-s-words {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n#fqa-search .fqa-s-word {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n padding: 6px 14px;\n border: 0;\n border-radius: 999px;\n background: var(--fqa-hover);\n color: var(--fqa-text-sub);\n font-size: 13px;\n font-family: inherit;\n line-height: 20px;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n}\n\n#fqa-search .fqa-s-word:hover {\n background: rgba(255, 111, 61, 0.1);\n color: var(--fqa-accent);\n}\n\n#fqa-search .fqa-s-word-tag {\n color: var(--fqa-text-weak);\n font-size: 11px;\n}\n\n/* 「荐」「热」这类角标 */\n#fqa-search .fqa-s-word-label {\n padding: 0 4px;\n border-radius: 3px;\n background: var(--fqa-accent);\n color: #fff;\n font-size: 10px;\n line-height: 15px;\n}\n\n#fqa-search .fqa-s-sugs {\n display: grid;\n grid-template-columns: repeat(6, minmax(0, 1fr));\n gap: 20px 16px;\n}\n\n@media (max-width: 900px) {\n #fqa-search .fqa-s-sugs {\n grid-template-columns: repeat(4, minmax(0, 1fr));\n }\n}\n\n@media (max-width: 600px) {\n #fqa-search .fqa-s-sugs {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n }\n}\n\n#fqa-search .fqa-s-sug {\n cursor: pointer;\n outline: none;\n}\n\n#fqa-search .fqa-s-sug-cover {\n display: block;\n width: 100%;\n aspect-ratio: 3 / 4;\n border-radius: 6px;\n object-fit: cover;\n background: var(--fqa-skeleton);\n transition: transform 0.2s ease, box-shadow 0.2s ease;\n}\n\n#fqa-search .fqa-s-sug:hover .fqa-s-sug-cover {\n transform: translateY(-4px);\n box-shadow: var(--fqa-shadow);\n}\n\n#fqa-search .fqa-s-sug-title {\n margin-top: 7px;\n font-size: 13px;\n line-height: 19px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n#fqa-search .fqa-s-sug-sub {\n margin-top: 2px;\n color: var(--fqa-text-weak);\n font-size: 12px;\n line-height: 18px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n#fqa-search .fqa-s-landing-sk {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n\n#fqa-search .fqa-sk-chip {\n width: 84px;\n height: 32px;\n border-radius: 999px;\n background: var(--fqa-skeleton);\n}\n\n/* ------------------------------ 骨架 / 状态 ------------------------------ */\n\n#fqa-search .fqa-sk-cover {\n flex: 0 0 96px;\n width: 96px;\n aspect-ratio: 3 / 4;\n border-radius: 6px;\n background: var(--fqa-skeleton);\n}\n\n#fqa-search .fqa-sk-line {\n height: 12px;\n margin-bottom: 9px;\n border-radius: 4px;\n background: var(--fqa-skeleton);\n}\n\n#fqa-search .fqa-sk-anim {\n position: relative;\n overflow: hidden;\n}\n\n#fqa-search .fqa-sk-anim::after {\n content: '';\n position: absolute;\n inset: 0;\n transform: translateX(-100%);\n background: linear-gradient(90deg, transparent, var(--fqa-skeleton-hl), transparent);\n animation: fqa-shimmer 1.4s infinite;\n}\n\n#fqa-search .fqa-s-status {\n padding: 72px 16px;\n text-align: center;\n color: var(--fqa-text-weak);\n font-size: 14px;\n line-height: 22px;\n}\n\n#fqa-search .fqa-s-status-title {\n margin-bottom: 8px;\n color: var(--fqa-text);\n font-size: 16px;\n font-weight: 500;\n}\n\n#fqa-search .fqa-s-status .fqa-s-submit {\n margin-top: 16px;\n}\n\n#fqa-search .fqa-s-loadmore {\n padding: 24px 0;\n text-align: center;\n color: var(--fqa-text-weak);\n font-size: 13px;\n}\n\n#fqa-search .fqa-s-inline-error {\n padding: 12px 0;\n text-align: center;\n color: #f5222d;\n font-size: 13px;\n}\n\n#fqa-search .fqa-s-privacy {\n margin-top: 24px;\n padding-top: 16px;\n border-top: 1px solid var(--fqa-border);\n color: var(--fqa-text-weak);\n font-size: 12px;\n line-height: 18px;\n text-align: center;\n}\n\n/* ------------------------------- 深色模式 ------------------------------- */\n\n@media (prefers-color-scheme: dark) {\n #fqa-search {\n --fqa-text: #e6e6e6;\n --fqa-text-sub: #a6a6a6;\n --fqa-text-weak: #7a7a7a;\n --fqa-border: rgba(255, 255, 255, 0.1);\n --fqa-hover: rgba(255, 255, 255, 0.06);\n --fqa-skeleton: rgba(255, 255, 255, 0.08);\n --fqa-skeleton-hl: rgba(255, 255, 255, 0.14);\n --fqa-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);\n --fqa-card-bg: #212125;\n }\n}\n"; const CONTAINER_ID$1 = "fqa-search-root"; const STYLE_ID$2 = "fqa-search-style"; const RESULT_SELECTOR = ".muye-search"; const NOTFOUND_SELECTOR = ".muye-undefined"; const ORIGIN_SELECTOR = `${RESULT_SELECTOR}, ${NOTFOUND_SELECTOR}`; let app$1 = null; let container$1 = null; let observer$1 = null; let stopTitleWatch = null; function injectStyle$2() { if (document.getElementById(STYLE_ID$2)) return; const style = document.createElement("style"); style.id = STYLE_ID$2; style.textContent = searchcss; document.head.appendChild(style); } function isSearchPath(path) { return path === "/search" || path === "/search/" || path.startsWith("/search/"); } function hideOrigin(root = document) { root.querySelectorAll(ORIGIN_SELECTOR).forEach((el) => { if (el.id === CONTAINER_ID$1 || el.closest(`#${CONTAINER_ID$1}`)) return; el.classList.add("fqa-hide"); }); } function unmount() { observer$1 == null ? void 0 : observer$1.disconnect(); observer$1 = null; stopTitleWatch == null ? void 0 : stopTitleWatch(); stopTitleWatch = null; app$1 == null ? void 0 : app$1.unmount(); app$1 = null; container$1 == null ? void 0 : container$1.remove(); container$1 = null; document.querySelectorAll(ORIGIN_SELECTOR).forEach((el) => { el.classList.remove("fqa-hide"); }); } async function mainHook$1(_previous) { const path = window.location.pathname; if (!isSearchPath(path) || !settings$1.enhanceSearch) { unmount(); return; } syncFromUrl(path); if (app$1) { hideOrigin(); return; } injectStyle$2(); const origin = await waitForElement(ORIGIN_SELECTOR, 8e3); if (!isSearchPath(window.location.pathname)) return; if (app$1) return; hideOrigin(); mount(origin); observer$1 = new MutationObserver((mutations) => { var _a; for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (!(node instanceof HTMLElement)) continue; if (node.id === CONTAINER_ID$1 || node.closest(`#${CONTAINER_ID$1}`)) continue; if ((_a = node.matches) == null ? void 0 : _a.call(node, ORIGIN_SELECTOR)) { node.classList.add("fqa-hide"); } else { hideOrigin(node); } } } }); observer$1.observe(document.body, { childList: true, subtree: true }); } function mount(origin) { container$1 = document.createElement("div"); container$1.id = CONTAINER_ID$1; if (origin == null ? void 0 : origin.parentElement) { origin.insertAdjacentElement("beforebegin", container$1); } else { const anchor = document.querySelector("#root") ?? document.body; anchor.appendChild(container$1); } app$1 = vue.createApp(_sfc_main$1); app$1.config.errorHandler = (err, _instance, info) => { console.error(`[fqa:search] Vue error (${info}):`, err); }; app$1.mount(container$1); stopTitleWatch = vue.watch( routeQuery, (q) => { document.title = q ? `${q} - 搜索 - 番茄小说` : "搜索 - 番茄小说"; }, { immediate: true } ); console.log("[fqa:search] 搜索视图已挂载:", routeQuery.value || "(落地页)"); } function filter$1(path, _query, _hash) { return isSearchPath(path) || !!app$1; } async function overloadTitle(_previous) { const searchKey = routeQuery.value || "搜索"; document.title = `${searchKey} - 番茄小说`; } const _exports$1 = [ { id: "searchHook_onload", event: "load", filter: filter$1, handler: mainHook$1 }, { id: "searchHook_onurlchange", event: "onUrlChange", filter: filter$1, handler: mainHook$1 }, { id: "searchHook_e", event: "enter", filter: filter$1, handler: overloadTitle } ]; const downloadcss = "/* 下载进度弹窗 */\n\n.fqa-dl-mask {\n position: fixed;\n inset: 0;\n z-index: 2147483300;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.45);\n font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial,\n sans-serif;\n font-size: 14px;\n line-height: 1.6;\n}\n\n.fqa-dl-box {\n --fqa-dl-bg: #fff;\n --fqa-dl-text: #1f2329;\n --fqa-dl-sub: #8f959e;\n --fqa-dl-border: rgba(31, 35, 41, 0.1);\n --fqa-dl-accent: #ff6f3d;\n --fqa-dl-track: rgba(31, 35, 41, 0.08);\n --fqa-dl-danger: #f53f3f;\n\n box-sizing: border-box;\n width: min(420px, 90vw);\n padding: 22px 24px 18px;\n background: var(--fqa-dl-bg);\n color: var(--fqa-dl-text);\n border-radius: 12px;\n box-shadow: 0 12px 48px rgba(0, 0, 0, 0.24);\n}\n\n.fqa-dl-title {\n margin: 0 0 4px;\n font-size: 16px;\n font-weight: 600;\n}\n\n/* 正在处理的章节名,长标题截断 */\n.fqa-dl-sub {\n margin: 0 0 14px;\n min-height: 20px;\n color: var(--fqa-dl-sub);\n font-size: 12px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.fqa-dl-bar {\n height: 8px;\n border-radius: 4px;\n background: var(--fqa-dl-track);\n overflow: hidden;\n}\n\n.fqa-dl-fill {\n height: 100%;\n border-radius: 4px;\n background: var(--fqa-dl-accent);\n transition: width 0.2s ease;\n}\n\n/* 总量未知时的循环动画 */\n.fqa-dl-fill-indeterminate {\n width: 35% !important;\n animation: fqa-dl-slide 1.1s ease-in-out infinite;\n}\n\n@keyframes fqa-dl-slide {\n 0% {\n margin-left: -35%;\n }\n 100% {\n margin-left: 100%;\n }\n}\n\n.fqa-dl-meta {\n display: flex;\n justify-content: space-between;\n margin-top: 8px;\n color: var(--fqa-dl-sub);\n font-size: 12px;\n font-variant-numeric: tabular-nums;\n}\n\n.fqa-dl-error {\n margin: 12px 0 0;\n padding: 8px 10px;\n border-radius: 6px;\n background: rgba(245, 63, 63, 0.1);\n color: var(--fqa-dl-danger);\n font-size: 12px;\n word-break: break-all;\n}\n\n.fqa-dl-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n margin-top: 18px;\n}\n\n.fqa-dl-btn {\n padding: 7px 18px;\n border: 1px solid var(--fqa-dl-border);\n border-radius: 6px;\n background: var(--fqa-dl-bg);\n color: var(--fqa-dl-text);\n font-size: 13px;\n font-family: inherit;\n cursor: pointer;\n}\n\n.fqa-dl-btn:hover {\n border-color: var(--fqa-dl-accent);\n color: var(--fqa-dl-accent);\n}\n\n.fqa-dl-btn:disabled {\n color: var(--fqa-dl-sub);\n border-color: var(--fqa-dl-border);\n cursor: not-allowed;\n}\n\n.fqa-dl-btn-primary {\n border-color: var(--fqa-dl-accent);\n background: var(--fqa-dl-accent);\n color: #fff;\n}\n\n.fqa-dl-btn-primary:hover {\n background: #ff8a5f;\n border-color: #ff8a5f;\n color: #fff;\n}\n\n/**\n * 详情页注入的下载按钮。\n *\n * 原站 .info-btn / .add-bookshelf-btn 自带 `position:absolute; bottom:0` 和固定\n * 尺寸,但这两个 class 同时带页面行为(会被站点脚本当成自己的按钮),所以不能照抄。\n * 这里复刻它们的尺寸,水平位置由脚本按前面按钮的实际宽度算出来。\n */\n.fqa-dl-entry {\n position: absolute;\n bottom: 0;\n width: 150px;\n height: 40px;\n line-height: 40px;\n padding: 0;\n border-radius: 4px;\n font-size: 16px;\n text-align: center;\n}\n\n@media (prefers-color-scheme: dark) {\n .fqa-dl-box {\n --fqa-dl-bg: #23272e;\n --fqa-dl-text: #e5e6eb;\n --fqa-dl-border: rgba(255, 255, 255, 0.12);\n --fqa-dl-track: rgba(255, 255, 255, 0.12);\n }\n}\n"; const STYLE_ID$1 = "fqa-download-style"; const ENTRY_CLASS = "fqa-dl-entry"; const ANCHOR_SELECTOR = `.add-bookshelf-btn:not(.${ENTRY_CLASS}), .info-btn:not(.${ENTRY_CLASS})`; let observer = null; function injectStyle$1() { if (document.getElementById(STYLE_ID$1)) return; const style = document.createElement("style"); style.id = STYLE_ID$1; style.textContent = downloadcss; document.head.appendChild(style); } function parseBookId(path) { var _a; return ((_a = path.match(/^\/page\/(\d+)/)) == null ? void 0 : _a[1]) ?? ""; } function isPagePath(path) { return /^\/page\/\d+/.test(path); } const FUNCTIONAL_CLASSES = ["add-bookshelf-btn", "info-btn"]; const BUTTON_GAP = 10; function createButton(anchor, bookId2) { const btn = document.createElement("button"); btn.type = "button"; const classes = Array.from(anchor.classList).filter((c) => !FUNCTIONAL_CLASSES.includes(c)); btn.className = [...classes, ENTRY_CLASS].join(" "); const span = document.createElement("span"); span.textContent = "下载"; btn.appendChild(span); btn.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); void startDownload(bookId2, { format: settings$1.downloadFormat }); }); return btn; } function placeButton(btn, siblings) { let right = 0; for (const el of siblings) { if (el === btn) continue; right = Math.max(right, el.offsetLeft + el.offsetWidth); } const left = `${right + BUTTON_GAP}px`; if (btn.style.left === left) return; btn.style.position = "absolute"; btn.style.bottom = "0"; btn.style.left = left; } function inject(bookId2) { const anchors = Array.from(document.querySelectorAll(ANCHOR_SELECTOR)); if (anchors.length === 0) return; const existing = document.querySelector(`.${ENTRY_CLASS}`); if (existing) { placeButton(existing, anchors); return; } const anchor = anchors[anchors.length - 1]; if (!anchor.parentElement) return; const btn = createButton(anchor, bookId2); anchor.insertAdjacentElement("afterend", btn); placeButton(btn, anchors); } function teardown() { observer == null ? void 0 : observer.disconnect(); observer = null; document.querySelectorAll(`.${ENTRY_CLASS}`).forEach((el) => el.remove()); } async function mainHook(_previous) { const path = window.location.pathname; if (!isPagePath(path) || !settings$1.enableDownload) { teardown(); return; } const bookId2 = parseBookId(path); if (!bookId2) return; injectStyle$1(); teardown(); await waitForElement(ANCHOR_SELECTOR, 8e3); if (window.location.pathname !== path) return; inject(bookId2); observer = new MutationObserver(() => { if (window.location.pathname !== path) return; inject(bookId2); }); observer.observe(document.body, { childList: true, subtree: true }); } function filter(path, _query, _hash) { return isPagePath(path) || Boolean(observer); } const _exports = [ { id: "downloadHook_onload", event: "load", filter, handler: mainHook }, { id: "downloadHook_onurlchange", event: "onUrlChange", filter, handler: mainHook } ]; const hooks = [ ..._exports$4, ..._exports$5, ..._exports$3, ..._exports$2, ..._exports$1, ..._exports ]; async function onEvent(event, previous) { console.log(`onEvent, event: ${event}, previous: ${previous}`); const path = window.location.pathname; const hash2 = window.location.hash; const params = new URLSearchParams(window.location.search); const tasks = []; for (const hook of hooks) { if (hook.event === event && hook.filter(path, params, hash2)) { tasks.push(async () => { try { await hook.handler(previous); } catch (err) { console.error(`[hook:${hook.id}] handler failed:`, err); } }); } } console.log(`tasks: `, tasks); if (tasks.length > 0) { await Promise.allSettled(tasks.map((task) => task())); } } async function onUrlChange(previous) { return await onEvent("onUrlChange", previous); } async function onHashChange(previous) { return await onEvent("onHashChange", previous); } async function onLoad() { return await onEvent("load"); } async function onEnter() { return await onEvent("enter"); } const _hoisted_1 = { class: "fqa-dl-mask" }; const _hoisted_2 = { class: "fqa-dl-box", role: "dialog", "aria-modal": "true", "aria-label": "下载进度" }; const _hoisted_3 = { class: "fqa-dl-title" }; const _hoisted_4 = { class: "fqa-dl-sub" }; const _hoisted_5 = { class: "fqa-dl-bar" }; const _hoisted_6 = { class: "fqa-dl-meta" }; const _hoisted_7 = { key: 0 }; const _hoisted_8 = { key: 0, class: "fqa-dl-error" }; const _hoisted_9 = { class: "fqa-dl-actions" }; const _hoisted_10 = ["disabled"]; const _sfc_main = /* @__PURE__ */ vue.defineComponent({ __name: "DownloadProgress", props: { task: {} }, emits: ["close"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; const progress = vue.ref(props.task.snapshot); const stop = props.task.subscribe((p) => progress.value = p); vue.onBeforeUnmount(stop); const percent = vue.computed(() => { const { current, total } = progress.value; if (total <= 0) return 0; return Math.min(100, Math.round(current / total * 100)); }); const indeterminate = vue.computed(() => progress.value.total <= 0 && !progress.value.done); function cancel() { props.task.cancel(); } return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", _hoisted_1, [ vue.createElementVNode("div", _hoisted_2, [ vue.createElementVNode("h3", _hoisted_3, vue.toDisplayString(progress.value.title), 1), vue.createElementVNode("p", _hoisted_4, vue.toDisplayString(progress.value.subtitle), 1), vue.createElementVNode("div", _hoisted_5, [ vue.createElementVNode("div", { class: vue.normalizeClass(["fqa-dl-fill", { "fqa-dl-fill-indeterminate": indeterminate.value }]), style: vue.normalizeStyle({ width: `${percent.value}%` }) }, null, 6) ]), vue.createElementVNode("div", _hoisted_6, [ vue.createElementVNode("span", null, vue.toDisplayString(indeterminate.value ? "" : `${percent.value}%`), 1), progress.value.total > 0 && !progress.value.percentOnly ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_7, vue.toDisplayString(progress.value.current) + "/" + vue.toDisplayString(progress.value.total), 1)) : vue.createCommentVNode("", true) ]), progress.value.error ? (vue.openBlock(), vue.createElementBlock("p", _hoisted_8, vue.toDisplayString(progress.value.error), 1)) : vue.createCommentVNode("", true), vue.createElementVNode("div", _hoisted_9, [ !progress.value.done ? (vue.openBlock(), vue.createElementBlock("button", { key: 0, class: "fqa-dl-btn", disabled: progress.value.cancelled, onClick: cancel }, vue.toDisplayString(progress.value.cancelled ? "正在停止…" : "取消"), 9, _hoisted_10)) : vue.createCommentVNode("", true), progress.value.cancelled && !progress.value.done ? (vue.openBlock(), vue.createElementBlock("button", { key: 1, class: "fqa-dl-btn", onClick: _cache[0] || (_cache[0] = ($event) => emit("close")) }, " 关闭 ")) : vue.createCommentVNode("", true), progress.value.done ? (vue.openBlock(), vue.createElementBlock("button", { key: 2, class: "fqa-dl-btn fqa-dl-btn-primary", onClick: _cache[1] || (_cache[1] = ($event) => emit("close")) }, " 确定 ")) : vue.createCommentVNode("", true) ]) ]) ]); }; } }); const CONTAINER_ID = "fqa-download-root"; const STYLE_ID = "fqa-download-style"; let app = null; let container = null; function injectStyle() { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = downloadcss; document.head.appendChild(style); } function close() { app == null ? void 0 : app.unmount(); app = null; container == null ? void 0 : container.remove(); container = null; } function dismiss() { close(); clearFinishedTask(); } function open(task) { if (app) return; injectStyle(); container = document.createElement("div"); container.id = CONTAINER_ID; document.body.appendChild(container); app = vue.createApp({ render: () => vue.h(_sfc_main, { task, onClose: dismiss }) }); app.config.errorHandler = (err, _instance, info) => { console.error(`[fqa:download] Vue error (${info}):`, err); }; app.mount(container); } function initDownloadPanel() { onTaskChange((task) => { if (task) open(task); else close(); }); } const win = unsafeWindow; let previousUrl = win.location.href; let previousHash = win.location.hash; function installNavigationHooks() { for (const method of ["pushState", "replaceState"]) { const original = win.history[method]; win.history[method] = function(...args) { const result = original.apply(this, args); console.debug(`history.${method} called with args:`, args); void onUrlChange(previousUrl); previousUrl = win.location.href; return result; }; } win.addEventListener("popstate", () => { void onUrlChange(previousUrl); previousUrl = win.location.href; }); win.addEventListener("hashchange", () => { void onHashChange(previousHash); previousHash = win.location.hash; }); } async function mainInit() { console.log(`================================================`); console.log(`== ${name} - ${version} ==`); console.log(`================================================`); installNavigationHooks(); void onEnter(); initFontDecrypt(); await inject$1(); initUserStyle(); initDownloadPanel(); await ensureDevice(); await init(); void onLoad(); } mainInit(); })(Vue, moment, JSZip);
${escapeXml(chapter.content).replace(/\n\n/g, "
").replace(/\n/g, "")}
本章生成失败
正文内容为空