From 5348fc0c5acb1b868ccae9ebab47cc278770e493 Mon Sep 17 00:00:00 2001 From: naiy Date: Mon, 3 Aug 2026 16:18:42 +0800 Subject: [PATCH] build: bump 0.0.4 --- dist/fanqie-assistant.user.js | 2444 ++++++++++++++++++++++++++++++--- 1 file changed, 2230 insertions(+), 214 deletions(-) diff --git a/dist/fanqie-assistant.user.js b/dist/fanqie-assistant.user.js index 637c4df..ef0002b 100644 --- a/dist/fanqie-assistant.user.js +++ b/dist/fanqie-assistant.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name 番茄小说助手 // @namespace https://github.com/naiyQAQ/fanqie-assistant -// @version 0.0.3 +// @version 0.0.4 // @author naiyQAQ // @description 番茄小说助手,去广告、去推广、解锁章节、优化体验。 // @license GPLv3 @@ -13,6 +13,7 @@ // @connect api5-sinfonlinec.jxbhmy.com // @connect api3-sinfonlinec.jxbhmy.com // @connect reading.snssdk.com +// @connect i.snssdk.com // @grant GM_addStyle // @grant GM_deleteValue // @grant GM_getValue @@ -22,14 +23,14 @@ // @run-at document-start // ==/UserScript== -(function (moment, vue) { +(function (vue, moment) { '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, 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 = unsafeWindow.fetch; + const fetch$1 = unsafeWindow.fetch; unsafeWindow.XMLHttpRequest; const defaultConfig = { install_id: "2187355326270644", @@ -39,7 +40,7 @@ const _config = { currentConfig: defaultConfig }; - const scriptcss = "/* 移除章节锁定图标 */\r\n.muyeicon-lock {\r\n display: none;\r\n}\r\n/* 移除APP推广图标 */\r\n.muye-to-fanqie {\r\n display: none!important;\r\n}\r\n.reader-toolbar-item-download {\r\n display: none!important;\r\n}\r\n.download-btn {\r\n display: none!important;\r\n}\r\n.download-icon {\r\n display: none!important;\r\n}\r\n\r\n.fqa-hide {\r\n display: none!important;\r\n}\r\n/* 404 */\r\n.no-content {\r\n display: none!important;\r\n}\r\n\r\n.fqa-comic-img {\r\n width: 100%!important;\r\n height: 100%!important;\r\n max-width: 100%!important;\r\n max-height: 100%!important;\r\n padding-top: 0!important;\r\n padding-bottom: 0!important;\r\n margin-top: 0!important;\r\n margin-bottom: 0!important;\r\n}\r\n\r\n.fqa-comic-reader {\r\n line-height: 0!important;\r\n}\r\n\r\n.fqa-menu-item,\r\n.arco-menu-item {\r\n width: 100%!important;\r\n}\r\n\r\n#dynamic-el {\r\n display: none!important;\r\n}"; + 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}"; async function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -75,11 +76,21 @@ } function chunk(list, size) { const result = []; - for (let i = 0; i < list.length; i += size) { - result.push(list.slice(i, i + size)); + 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() { while (!document.body) { console.log("Waiting for body..."); @@ -88,6 +99,65 @@ 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, + readerFont: "", + customCssEnabled: false, + customCss: "", + 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; + } + return s; + } + const settings = vue.reactive(normalize(read(STORE_KEY$1))); + let saveTimer; + vue.watch( + settings, + () => { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + saveTimer = void 0; + write(STORE_KEY$1, { ...settings }); + }, 200); + }, + { deep: true } + ); + function flushSettings() { + if (saveTimer) { + clearTimeout(saveTimer); + saveTimer = void 0; + } + write(STORE_KEY$1, { ...settings }); + } const enTag = ".font-DNMrHsV173Pd4pgy"; const code_ed = 58715; const code_st = 58344; @@ -488,6 +558,7 @@ return changed ? result : text; } function decryptElement(element) { + if (!settings.decryptFont) return; const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT @@ -545,6 +616,47 @@ characterData: true }); decryptPage(document); + vue.watch( + () => settings.decryptFont, + (on) => { + if (on) decryptPage(document); + } + ); + } + const STYLE_ID$2 = "fqa-user-style"; + const READER_SCOPE = "#fqa-reader-content, .muye-reader-content"; + function buildCss() { + const parts = []; + const font = settings.readerFont.trim(); + if (font) { + const family = /^["']|,/.test(font) ? font : `"${font}"`; + parts.push(`${READER_SCOPE}, ${READER_SCOPE} p { font-family: ${family}, inherit !important; }`); + } + if (settings.customCssEnabled && settings.customCss.trim()) { + parts.push(settings.customCss); + } + return parts.join("\n"); + } + function apply() { + const css = buildCss(); + let el = document.getElementById(STYLE_ID$2); + if (!css) { + el == null ? void 0 : el.remove(); + return; + } + if (!el) { + el = document.createElement("style"); + el.id = STYLE_ID$2; + document.head.appendChild(el); + } + el.textContent = css; + } + function initUserStyle() { + apply(); + vue.watch( + () => [settings.readerFont, settings.customCssEnabled, settings.customCss], + apply + ); } const supportedMethods = /* @__PURE__ */ new Set([ "GET", @@ -644,79 +756,227 @@ }); return error; } - unsafeWindow.localStorage; - function write(key, value) { - GM_setValue(key, JSON.stringify(value)); + function rotateLeft(value, shiftBits) { + return value << shiftBits | value >>> 32 - shiftBits; } - function read(key) { - return JSON.parse(GM_getValue(key) || "null"); + function addUnsigned(left, right) { + return left + right >>> 0; } - function del(key) { - GM_deleteValue(key); + function f(x, y, z) { + return x & y | ~x & z; } - function getCookie(key) { - var _a; - return ((_a = document.cookie.split(";").find((c) => c.trim().startsWith(key + "="))) == null ? void 0 : _a.split("=")[1]) || null; + function g(x, y, z) { + return x & z | y & ~z; } - const baseUrl = "https://api5-sinfonlinec.jxbhmy.com/redcandle/search"; - const baseQuery = { - aid: "1967", - app_name: "novelapp" - }; - async function get(path, cookie, query, headers, optionsOverride) { - let url = baseUrl + path; - getCookie("sessionid"); - const h = { - Cookie: "", - ...headers - }; - const deviceQuery = { - device_id: _config.currentConfig.device_id, - iid: _config.currentConfig.install_id - }; - const finalQuery = { - ...baseQuery, - ...deviceQuery, - ...query - }; - url += "?" + new URLSearchParams(finalQuery).toString(); - const options = { - method: "GET", - headers: h, - ...optionsOverride - }; - console.log("---start--- GET ", url, options); - const res = await apiFetch(url, options); - console.log("---complete--- GET ", url, res); - return res; + function h(x, y, z) { + return x ^ y ^ z; } - async function post(path, cookie, query, body, headers, optionsOverride) { - let url = baseUrl + path; - getCookie("sessionid"); - const h = { - Cookie: "", - ...headers - }; - const deviceQuery = { - device_id: _config.currentConfig.device_id, - iid: _config.currentConfig.install_id - }; - const finalQuery = { - ...baseQuery, - ...deviceQuery, - ...query - }; - url += "?" + new URLSearchParams(finalQuery).toString(); - const options = { - method: "POST", - headers: h, - body, - ...optionsOverride - }; - console.log("---start--- POST ", url, options); - const res = await apiFetch(url, options); - console.log("---complete--- POST ", url, res); - return res; + 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 state = 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 = state[0]; + let b = state[1]; + let c = state[2]; + let d = state[3]; + let e = state[4]; + let f2 = state[5]; + let g2 = state[6]; + let h2 = state[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); + } + state[0] = state[0] ^ a; + state[1] = state[1] ^ b; + state[2] = state[2] ^ c; + state[3] = state[3] ^ d; + state[4] = state[4] ^ e; + state[5] = state[5] ^ f2; + state[6] = state[6] ^ g2; + state[7] = state[7] ^ h2; + } + const result = new Uint8Array(32); + for (let i2 = 0; i2 < state.length; i2++) { + const word = state[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; @@ -732,8 +992,8 @@ const binaryString = atob(b64); const len = binaryString.length; const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); + for (let i2 = 0; i2 < len; i2++) { + bytes[i2] = binaryString.charCodeAt(i2); } return bytes.buffer; } @@ -741,37 +1001,446 @@ const bytes = new Uint8Array(buffer); const chunkSize = 32768; const chunks = []; - for (let i = 0; i < bytes.length; i += chunkSize) { + for (let i2 = 0; i2 < bytes.length; i2 += chunkSize) { chunks.push( - String.fromCharCode(...bytes.subarray(i, i + chunkSize)) + String.fromCharCode(...bytes.subarray(i2, i2 + chunkSize)) ); } return btoa(chunks.join("")); } - function unhex(hex) { - if (hex.length % 2 !== 0) { + function unhex(hex2) { + if (hex2.length % 2 !== 0) { throw new Error("Invalid hex string"); } - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < hex.length; i += 2) { - const byte = parseInt(hex.slice(i, i + 2), 16); + 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[i / 2] = byte; + 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 i = 0; i < length; i++) { - result += chars.charAt(array[i] % chars.length); + for (let i2 = 0; i2 < length; i2++) { + result += chars.charAt(array[i2] % chars.length); } return result; } + function toBytes(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(input)); + return hex(digest); + }, + sha256bytes: async (input) => { + const subtle = getSubtle(); + return subtle.digest("SHA-256", toBytes(input)); + }, + sha512: async (input) => { + const subtle = getSubtle(); + const digest = await subtle.digest("SHA-512", toBytes(input)); + return hex(digest); + }, + sha512bytes: async (input) => { + const subtle = getSubtle(); + return subtle.digest("SHA-512", toBytes(input)); + }, + md5: async (input) => md5( + typeof input === "string" ? input : toBytes(input).buffer + ), + md5bytes: async (input) => unhex(md5( + typeof input === "string" ? input : toBytes(input).buffer + )), + sm3: async (input) => hex(sm3(toBytes(input)).buffer), + sm3bytes: async (input) => sm3(toBytes(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; + } + const appBaseUrl = "https://reading.snssdk.com/reading"; + const redcandleBaseUrl = "https://api5-sinfonlinec.jxbhmy.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", + ...extra + }); + } + 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 appGet(path, query, headers) { + if (settings.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 requestApp(path, query, headers); + } + async function appPost(path, body, query, headers) { + const url = `${appBaseUrl}${path}?${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, config = defaultConfig) { var _a; if (!encrypted) { @@ -810,10 +1479,27 @@ } }); } + 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 i = be.length; i > 0; i -= 2) result += be.slice(i - 2, i); + for (let i2 = be.length; i2 > 0; i2 -= 2) result += be.slice(i2 - 2, i2); return result; } async function encryptKeyinfoBody(config) { @@ -860,33 +1546,14 @@ data ); } - 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 - ); - } async function refreshKeyinfo() { var _a, _b, _c; const b = await encryptKeyinfoBody(_config.currentConfig); - const res = await post("/reader/crypt/registerkey/v1", false, {}, b); - console.log(res); + const res = await appPost("/crypt/registerkey", b); const j = res.json(); - console.log("response: ", j); const ek = (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.key; if (!ek) { - throw new Error("Failed to get key info"); + throw new Error(`Failed to get key info: ${res.responseText}`); } const key = await decryptKeyinfoResponse(ek); const keyinfo = { @@ -930,10 +1597,10 @@ if (!_config.currentConfig.key_info) { await ensureKeyinfo(); } - const res = await get(`/reader/full/v1`, false, { item_id: itemId }); + const res = await appGet("/reader/full/v", { item_id: itemId, req_type: "1" }); const j = (_a = res.json()) == null ? void 0 : _a.data; if (!j) { - console.warn("Failed to get chapter: ", itemId, ", response: ", j); + console.warn("Failed to get chapter: ", itemId, ", response: ", res.responseText); return await getChapter(itemId, _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)) { @@ -945,12 +1612,14 @@ return j; } async function getCatalogRaw(bookId) { - const response = await apiFetch(`https://fanqienovel.com/api/reader/directory/detail?bookId=${bookId}`); + var _a; + const response = await appGet("/bookapi/directory/all_items/v", { book_id: bookId }); const j = response.json(); - if (j.code !== 0 || j.data.chapterListWithVolume.length === 0) { + 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) { throw new Error("Empty catalog"); } - return [j.data.chapterListWithVolume, j.data.allItemIds]; + return [items, items.map((it) => String(it.item_id))]; } async function getCatalog(bookId) { const r = await getCatalogRaw(bookId); @@ -958,24 +1627,25 @@ const allItemIds = r[1]; const vmap = {}; const chapters = []; - catalogRaw[0].map((item) => { + catalogRaw.forEach((item) => { + const volumeName = item.volume_name ?? ""; const chapterItem = { - item_id: item.itemId, + item_id: String(item.item_id), title: item.title, // YYYY-MM-DD HH:mm:ss - update_time: moment(item.firstPassTime * 1e3).format("YYYY-MM-DD HH:mm:ss"), - char_count: item.charCount || 0, - volume_title: item.volume_name + update_time: moment(item.first_pass_time * 1e3).format("YYYY-MM-DD HH:mm:ss"), + char_count: item.chapter_word_number || 0, + volume_title: volumeName }; chapters.push(chapterItem); - if (!vmap[item.volume_name]) { - vmap[item.volume_name] = { - title: item.volume_name, + if (!vmap[volumeName]) { + vmap[volumeName] = { + title: volumeName, book_id: bookId, chapter_list: [] }; } - vmap[item.volume_name].chapter_list.push(chapterItem); + vmap[volumeName].chapter_list.push(chapterItem); }); return { book_id: bookId, @@ -997,7 +1667,7 @@ } } async function getBookInfoRaw(bookId) { - const response = await apiFetch(`https://api5-sinfonlinec.jxbhmy.com/reading/bookapi/multi-detail/v?book_id=${bookId}&aid=1967`); + const response = await appGet("/bookapi/multi-detail/v", { book_id: bookId }); const j = response.json(); console.log("Book Info:", j); if (typeof j === "object" && j !== null && "data" in j && Array.isArray(j.data) && j.data.length > 0) return j.data[0]; @@ -1034,10 +1704,280 @@ 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 = (num, text) => { + const sup = document.createElement("sup"); + sup.className = FOOTNOTE_REF_CLASS; + sup.textContent = String(num); + 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, text } of ordered) { + const li = document.createElement("li"); + li.id = `fqa-fn-${num}`; + 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 num = (_a = sup.textContent) == null ? void 0 : _a.trim(); + if (!num) return; + const target = container2.querySelector(`#fqa-fn-${num}`); + 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); + } + }); + } let currentBook = null; let latestItemId = null; async function insertContent() { - var _a, _b, _c, _d, _e, _f, _g, _h; + var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j; const itemId = ((_a = window.location.pathname.split("/").pop()) == null ? void 0 : _a.substring(0, 19)) || ""; if (!itemId) { console.warn("No item_id found in URL"); @@ -1057,16 +1997,25 @@ 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; - const article = body.querySelector("article"); - const toProcess = article || body; - for (let i = 0; i < toProcess.childNodes.length; i++) { - if (i < 1 && ((_e = toProcess.childNodes[i]) == null ? void 0 : _e.innerHTML.includes(chapterTitle))) { - toProcess.removeChild(toProcess.childNodes[i]); + 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) { let scriptContainer = document.getElementById("fqa-reader-content"); @@ -1074,24 +2023,25 @@ scriptContainer = cloneElement(readerContainer); scriptContainer.id = "fqa-reader-content"; scriptContainer.classList.add("fqa"); - scriptContainer.classList.remove("noselect"); + if (settings.allowCopy) scriptContainer.classList.remove("noselect"); readerContainer.insertAdjacentElement("beforebegin", scriptContainer); } scriptContainer.innerHTML = ""; readerContainer.classList.add("fqa-hide"); scriptContainer.appendChild(toProcess); + bindFootnoteInteraction(scriptContainer); } } else if (chapter.content.picInfos) { if (chapter.content.encrypt) { const imgs = []; - for (let i = 0; i < chapter.content.picInfos.length; i++) { - const picInfo = chapter.content.picInfos[i]; + 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 = `第${i + 1}页`; + img.alt = `第${i2 + 1}页`; img.dataset.encryptedUrl = picInfo.picUrl; img.dataset.encryptKey = chapter.content.encrypt_key; - img.dataset.pageIndex = i.toString(); + img.dataset.pageIndex = i2.toString(); img.style.minHeight = "500px"; img.style.backgroundColor = "#f0f0f0"; imgs.push(img); @@ -1104,7 +2054,7 @@ scriptContainer.id = "fqa-reader-content"; scriptContainer.classList.add("fqa"); scriptContainer.classList.add("fqa-comic-reader"); - scriptContainer.classList.remove("noselect"); + if (settings.allowCopy) scriptContainer.classList.remove("noselect"); readerContainer.insertAdjacentElement("beforebegin", scriptContainer); } scriptContainer.innerHTML = ""; @@ -1118,7 +2068,7 @@ if (img.dataset.encryptedUrl && img.dataset.encryptKey && !img.src) { observer2.unobserve(img); try { - const response = await fetch(img.dataset.encryptedUrl); + const response = await fetch$1(img.dataset.encryptedUrl); const encryptedBuffer = await response.arrayBuffer(); const decryptedBuffer = await decryptComicImage( encryptedBuffer, @@ -1149,11 +2099,11 @@ } } else { const imgs = []; - for (let i = 0; i < chapter.content.picInfos.length; i++) { - const picInfo = chapter.content.picInfos[i]; + 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 = `第${i + 1}页`; + img.alt = `第${i2 + 1}页`; img.src = picInfo.picUrl; imgs.push(img); } @@ -1165,7 +2115,7 @@ scriptContainer.id = "fqa-reader-content"; scriptContainer.classList.add("fqa"); scriptContainer.classList.add("fqa-comic-reader"); - scriptContainer.classList.remove("noselect"); + if (settings.allowCopy) scriptContainer.classList.remove("noselect"); readerContainer.insertAdjacentElement("beforebegin", scriptContainer); } scriptContainer.innerHTML = ""; @@ -1176,7 +2126,7 @@ } const muyeReaderTitle = document.querySelector("h1.muye-reader-title"); let muyeReaderSubtitle = document.querySelector("div.muye-reader-subtitle"); - (_f = document.querySelector("#fqa-subtitle")) == null ? void 0 : _f.remove(); + (_h = document.querySelector("#fqa-subtitle")) == null ? void 0 : _h.remove(); if (muyeReaderSubtitle) { let _cloned = cloneElement(muyeReaderSubtitle); muyeReaderSubtitle.classList.add("fqa-hide"); @@ -1190,8 +2140,8 @@ muyeReaderTitle.textContent = chapterTitle; } console.log("Current book:", currentBook); - if (!currentBook || currentBook == null || currentBook.book_id !== ((_g = chapter.novel_data) == null ? void 0 : _g.book_id)) { - currentBook = await getBookInfoAndCatalog((_h = chapter.novel_data) == null ? void 0 : _h.book_id); + 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) { @@ -1273,6 +2223,7 @@ tc: 10 }); function checkBlack(url) { + if (!settings.blockReport) return false; return blackList.some((black) => url.includes(black)); } const originalFetch = unsafeWindow.fetch.bind(unsafeWindow); @@ -1367,7 +2318,7 @@ 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("https://fanqienovel.com/reading/user/basic_info/get/v?aid=1967"); + 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; @@ -1386,7 +2337,7 @@ } async function checkLogin() { var _a, _b, _c, _d, _e, _f; - const response = await fetch("https://fanqienovel.com/api/user/info/v2"); + 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, @@ -1413,6 +2364,734 @@ } } const bookshelf = '\r\n \r\n \r\n \r\n \r\n \r\n'; + 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; + } + 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"; + 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, { + 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, { + 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, { + 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; + return { + device_id: s.device_id, + install_id: s.install_id, + device_type: s.device_type, + device_brand: s.device_brand, + key_info: s.key ? { key: b64decode(s.key), keyver: s.keyver } : void 0 + }; + } + function save(c, vipExpireTime) { + var _a; + 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 + }; + if ((_a = c.key_info) == null ? void 0 : _a.key) { + s.key = b64encode(c.key_info.key); + s.keyver = c.key_info.keyver; + } + 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); + return c; + } + async function ensureDevice() { + const { deviceId, installId, deviceType } = settings; + 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; + } + } + const name = "fanqie-assistant"; + const version = "0.0.4"; + const _hoisted_1$5 = { + class: "fqa-set-dialog", + role: "dialog", + "aria-modal": "true", + "aria-label": "助手设置" + }; + const _hoisted_2$5 = { class: "fqa-set-side" }; + const _hoisted_3$5 = ["onClick", "onKeydown"]; + const _hoisted_4$4 = { class: "fqa-set-main" }; + const _hoisted_5$4 = { class: "fqa-set-row" }; + const _hoisted_6$4 = { class: "fqa-set-row" }; + const _hoisted_7$2 = { class: "fqa-set-row" }; + const _hoisted_8$2 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_9$2 = { class: "fqa-set-row" }; + const _hoisted_10$2 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_11$2 = ["disabled"]; + const _hoisted_12$2 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_13$1 = { class: "fqa-set-radios" }; + const _hoisted_14$1 = { class: "fqa-set-radio" }; + const _hoisted_15$1 = { class: "fqa-set-radio" }; + const _hoisted_16 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_17 = { class: "fqa-set-field" }; + const _hoisted_18 = ["placeholder"]; + const _hoisted_19 = { class: "fqa-set-field" }; + const _hoisted_20 = ["placeholder"]; + const _hoisted_21 = { class: "fqa-set-field" }; + const _hoisted_22 = ["placeholder"]; + const _hoisted_23 = { class: "fqa-set-actions" }; + const _hoisted_24 = ["disabled"]; + const _hoisted_25 = { + key: 0, + class: "fqa-set-note" + }; + const _hoisted_26 = { class: "fqa-set-note" }; + const _hoisted_27 = { class: "fqa-set-links" }; + const _hoisted_28 = { class: "fqa-set-link-row" }; + const _hoisted_29 = ["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$5 = /* @__PURE__ */ vue.defineComponent({ + __name: "SettingsView", + emits: ["close"], + setup(__props, { emit: __emit }) { + const emit = __emit; + const SECTIONS = [ + { key: "general", label: "常规" }, + { key: "ui", 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.deviceId = ""; + settings.installId = ""; + settings.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.deviceId || _config.currentConfig.device_id, + install_id: settings.installId || _config.currentConfig.install_id, + device_type: settings.deviceType || _config.currentConfig.device_type || "" + })); + function close() { + flushSettings(); + emit("close"); + } + function onKey(e) { + if (e.key === "Escape") close(); + } + 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(close, ["self"]) + }, [ + vue.createElementVNode("div", _hoisted_1$5, [ + vue.createElementVNode("button", { + class: "fqa-set-close", + "aria-label": "关闭", + onClick: close + }, "✕"), + vue.createElementVNode("nav", _hoisted_2$5, [ + _cache[11] || (_cache[11] = 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$5); + }), 64)) + ]), + vue.createElementVNode("section", _hoisted_4$4, [ + active.value === "general" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, [ + _cache[15] || (_cache[15] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "常规", -1)), + vue.createElementVNode("label", _hoisted_5$4, [ + _cache[12] || (_cache[12] = vue.createElementVNode("span", { class: "fqa-set-label" }, "解密网页端混淆字体", -1)), + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => vue.unref(settings).decryptFont = $event), + type: "checkbox", + class: "fqa-set-switch" + }, null, 512), [ + [vue.vModelCheckbox, vue.unref(settings).decryptFont] + ]) + ]), + vue.createElementVNode("label", _hoisted_6$4, [ + _cache[13] || (_cache[13] = vue.createElementVNode("span", { class: "fqa-set-label" }, "拦截网页事件上报", -1)), + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[1] || (_cache[1] = ($event) => vue.unref(settings).blockReport = $event), + type: "checkbox", + class: "fqa-set-switch" + }, null, 512), [ + [vue.vModelCheckbox, vue.unref(settings).blockReport] + ]) + ]), + vue.createElementVNode("label", _hoisted_7$2, [ + _cache[14] || (_cache[14] = vue.createElementVNode("span", { class: "fqa-set-label" }, "允许阅读器复制文本", -1)), + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[2] || (_cache[2] = ($event) => vue.unref(settings).allowCopy = $event), + type: "checkbox", + class: "fqa-set-switch" + }, null, 512), [ + [vue.vModelCheckbox, vue.unref(settings).allowCopy] + ]) + ]) + ], 64)) : active.value === "ui" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 1 }, [ + _cache[20] || (_cache[20] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "界面", -1)), + vue.createElementVNode("div", _hoisted_8$2, [ + _cache[16] || (_cache[16] = vue.createElementVNode("span", { class: "fqa-set-label" }, "阅读器字体", -1)), + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[3] || (_cache[3] = ($event) => vue.unref(settings).readerFont = $event), + class: "fqa-set-input", + type: "text", + placeholder: "留空表示使用默认字体" + }, null, 512), [ + [vue.vModelText, vue.unref(settings).readerFont] + ]), + _cache[17] || (_cache[17] = vue.createElementVNode("p", { class: "fqa-set-note" }, "填写字体名称,例如「思源宋体」。留空则跟随网页默认。", -1)) + ]), + vue.createElementVNode("div", _hoisted_9$2, [ + _cache[18] || (_cache[18] = vue.createElementVNode("span", { class: "fqa-set-label" }, "自定义 CSS", -1)), + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[4] || (_cache[4] = ($event) => vue.unref(settings).customCssEnabled = $event), + type: "checkbox", + class: "fqa-set-switch" + }, null, 512), [ + [vue.vModelCheckbox, vue.unref(settings).customCssEnabled] + ]) + ]), + vue.createElementVNode("div", _hoisted_10$2, [ + vue.withDirectives(vue.createElementVNode("textarea", { + "onUpdate:modelValue": _cache[5] || (_cache[5] = ($event) => vue.unref(settings).customCss = $event), + class: "fqa-set-textarea", + disabled: !vue.unref(settings).customCssEnabled, + spellcheck: "false", + placeholder: "/* 自定义 CSS */" + }, null, 8, _hoisted_11$2), [ + [vue.vModelText, vue.unref(settings).customCss] + ]), + _cache[19] || (_cache[19] = vue.createElementVNode("p", { class: "fqa-set-note" }, "关闭开关后内容会保留,只是不再应用。", -1)) + ]) + ], 64)) : active.value === "protocol" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 2 }, [ + _cache[30] || (_cache[30] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "协议", -1)), + vue.createElementVNode("div", _hoisted_12$2, [ + _cache[23] || (_cache[23] = vue.createElementVNode("span", { class: "fqa-set-label" }, "API 偏好", -1)), + vue.createElementVNode("div", _hoisted_13$1, [ + vue.createElementVNode("label", _hoisted_14$1, [ + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[6] || (_cache[6] = ($event) => vue.unref(settings).apiPreference = $event), + type: "radio", + value: "app" + }, null, 512), [ + [vue.vModelRadio, vue.unref(settings).apiPreference] + ]), + _cache[21] || (_cache[21] = vue.createElementVNode("span", null, "番茄 APP", -1)) + ]), + vue.createElementVNode("label", _hoisted_15$1, [ + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[7] || (_cache[7] = ($event) => vue.unref(settings).apiPreference = $event), + type: "radio", + value: "redcandle" + }, null, 512), [ + [vue.vModelRadio, vue.unref(settings).apiPreference] + ]), + _cache[22] || (_cache[22] = vue.createElementVNode("span", null, "红烛 APP", -1)) + ]) + ]), + _cache[24] || (_cache[24] = vue.createElementVNode("p", { class: "fqa-set-note" }, "如果某协议数据不全,脚本可能会选择其他接口作为补充。", -1)) + ]), + vue.createElementVNode("div", _hoisted_16, [ + _cache[28] || (_cache[28] = vue.createElementVNode("span", { class: "fqa-set-label" }, "设备信息", -1)), + _cache[29] || (_cache[29] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 如果不知道这是什么,请保持默认。乱填可能导致脚本功能异常。 ", -1)), + vue.createElementVNode("label", _hoisted_17, [ + _cache[25] || (_cache[25] = vue.createElementVNode("span", null, "device_id", -1)), + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[8] || (_cache[8] = ($event) => vue.unref(settings).deviceId = $event), + class: "fqa-set-input", + type: "text", + placeholder: currentDevice.value.device_id || "自动注册" + }, null, 8, _hoisted_18), [ + [vue.vModelText, vue.unref(settings).deviceId] + ]) + ]), + vue.createElementVNode("label", _hoisted_19, [ + _cache[26] || (_cache[26] = vue.createElementVNode("span", null, "install_id (iid)", -1)), + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[9] || (_cache[9] = ($event) => vue.unref(settings).installId = $event), + class: "fqa-set-input", + type: "text", + placeholder: currentDevice.value.install_id || "自动注册" + }, null, 8, _hoisted_20), [ + [vue.vModelText, vue.unref(settings).installId] + ]) + ]), + vue.createElementVNode("label", _hoisted_21, [ + _cache[27] || (_cache[27] = vue.createElementVNode("span", null, "device_type", -1)), + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[10] || (_cache[10] = ($event) => vue.unref(settings).deviceType = $event), + class: "fqa-set-input", + type: "text", + placeholder: currentDevice.value.device_type || "自动注册" + }, null, 8, _hoisted_22), [ + [vue.vModelText, vue.unref(settings).deviceType] + ]) + ]), + vue.createElementVNode("div", _hoisted_23, [ + vue.createElementVNode("button", { + class: "fqa-set-btn", + disabled: registering.value, + onClick: reRegister + }, vue.toDisplayString(registering.value ? "注册中…" : "重新注册"), 9, _hoisted_24), + registerMsg.value ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_25, vue.toDisplayString(registerMsg.value), 1)) : vue.createCommentVNode("", true) + ]) + ]) + ], 64)) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 3 }, [ + _cache[35] || (_cache[35] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "关于", -1)), + vue.createElementVNode("p", _hoisted_26, "番茄小说助手 v" + vue.toDisplayString(vue.unref(version)), 1), + vue.createElementVNode("div", _hoisted_27, [ + vue.createElementVNode("div", { class: "fqa-set-link-row" }, [ + _cache[31] || (_cache[31] = 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[32] || (_cache[32] = vue.createElementVNode("span", null, "GitHub 地址:", -1)), + vue.createElementVNode("a", { + href: GITHUB, + target: "_blank", + rel: "noreferrer noopener" + }, "跳转") + ]), + vue.createElementVNode("div", _hoisted_28, [ + _cache[34] || (_cache[34] = vue.createElementVNode("span", null, "问题反馈:", -1)), + vue.createElementVNode("a", { + href: FEEDBACK, + target: "_blank", + rel: "noreferrer noopener" + }, "GreasyFork"), + vue.createElementVNode("span", null, [ + _cache[33] || (_cache[33] = vue.createTextVNode(" 或 ", -1)), + vue.createElementVNode("a", { + href: `${GITHUB}/issues`, + target: "_blank", + rel: "noreferrer noopener" + }, " GitHub Issues ", 8, _hoisted_29) + ]) + ]) + ]), + _cache[36] || (_cache[36] = 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$1 = "fqa-settings-root"; + const STYLE_ID$1 = "fqa-settings-style"; + let app$1 = null; + let container$1 = null; + function injectStyle$1() { + if (document.getElementById(STYLE_ID$1)) return; + const style = document.createElement("style"); + style.id = STYLE_ID$1; + style.textContent = settingscss; + document.head.appendChild(style); + } + function closeSettings() { + app$1 == null ? void 0 : app$1.unmount(); + app$1 = null; + container$1 == null ? void 0 : container$1.remove(); + container$1 = null; + } + function openSettings() { + if (app$1) return; + injectStyle$1(); + container$1 = document.createElement("div"); + container$1.id = CONTAINER_ID$1; + document.body.appendChild(container$1); + app$1 = vue.createApp({ + render: () => vue.h(_sfc_main$5, { onClose: closeSettings }) + }); + app$1.config.errorHandler = (err, _instance, info) => { + console.error(`[fqa:settings] Vue error (${info}):`, err); + }; + app$1.mount(container$1); + } function formatReadingTime(readBookTime) { let minutes = readBookTime / 60000n; const hours = minutes / 60n; @@ -1428,6 +3107,12 @@ 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); } @@ -1461,6 +3146,7 @@ window.open("https://fanqienovel.com/bookshelf", "_blank"); }); secondDiv.insertAdjacentElement("afterend", thirdDiv); + menuInner.appendChild(createMenuItem("助手设置", void 0, openSettings)); }; const scan = (root) => { if (root.classList.contains("arco-menu-inner")) { @@ -1482,32 +3168,75 @@ 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$1(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$1 = [ { id: "userHook", event: "load", filter: filter$1, handler: mainHook$1 + }, + { + id: "userHook_guest", + event: "load", + filter: guestFilter, + handler: guestHook } ]; - const _hoisted_1$3 = ["aria-label"]; - const _hoisted_2$3 = { class: "fqa-cover" }; - const _hoisted_3$3 = ["src", "alt"]; + const _hoisted_1$4 = ["aria-label"]; + const _hoisted_2$4 = { class: "fqa-cover" }; + const _hoisted_3$4 = ["src", "alt"]; const _hoisted_4$3 = { key: 1, class: "fqa-cover-progress" }; const _hoisted_5$3 = ["title"]; const _hoisted_6$3 = ["title"]; - const _sfc_main$3 = /* @__PURE__ */ vue.defineComponent({ + const _sfc_main$4 = /* @__PURE__ */ vue.defineComponent({ __name: "BookCard", props: { entry: {} }, - emits: ["hover", "leave", "open", "visible"], + emits: ["hover", "leave", "open", "visible", "contextmenu"], setup(__props, { emit: __emit }) { const props = __props; const emit = __emit; @@ -1542,6 +3271,10 @@ function onEnter(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 }); + } const imgLoaded = vue.ref(false); vue.watch( () => { @@ -1583,6 +3316,7 @@ "aria-label": title.value, onMouseenter: onEnter, onMouseleave: _cache[2] || (_cache[2] = ($event) => emit("leave")), + onContextmenu: onContextMenu, onClick: _cache[3] || (_cache[3] = ($event) => emit("open", __props.entry)), onKeydown: [ _cache[4] || (_cache[4] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.entry), ["prevent"]), ["enter"])), @@ -1600,7 +3334,7 @@ style: { "width": "55%" } }, null, -1)) ], 64)) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 1 }, [ - vue.createElementVNode("div", _hoisted_2$3, [ + vue.createElementVNode("div", _hoisted_2$4, [ vue.createElementVNode("img", { class: vue.normalizeClass(["fqa-cover-img", { "fqa-cover-img-loading": !imgLoaded.value }]), crossorigin: "anonymous", @@ -1610,7 +3344,7 @@ 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$3), + }, null, 42, _hoisted_3$4), tag.value ? (vue.openBlock(), vue.createElementBlock("span", { key: 0, class: vue.normalizeClass(["fqa-cover-tag", tag.value.cls]) @@ -1631,17 +3365,17 @@ title: detail.value.current_chapter_title }, vue.toDisplayString(progressText.value), 9, _hoisted_6$3) ], 64)) - ], 40, _hoisted_1$3); + ], 40, _hoisted_1$4); }; } }); - const _hoisted_1$2 = ["aria-label"]; - const _hoisted_2$2 = { class: "fqa-group-cover" }; - const _hoisted_3$2 = { class: "fqa-group-grid" }; + const _hoisted_1$3 = ["aria-label"]; + const _hoisted_2$3 = { class: "fqa-group-cover" }; + const _hoisted_3$3 = { class: "fqa-group-grid" }; const _hoisted_4$2 = ["src", "alt"]; const _hoisted_5$2 = ["title"]; const _hoisted_6$2 = { class: "fqa-card-sub" }; - const _sfc_main$2 = /* @__PURE__ */ vue.defineComponent({ + const _sfc_main$3 = /* @__PURE__ */ vue.defineComponent({ __name: "BookGroupCard", props: { group: {} @@ -1691,8 +3425,8 @@ _cache[2] || (_cache[2] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.group), ["prevent"]), ["space"])) ] }, [ - vue.createElementVNode("div", _hoisted_2$2, [ - vue.createElementVNode("div", _hoisted_3$2, [ + vue.createElementVNode("div", _hoisted_2$3, [ + vue.createElementVNode("div", _hoisted_3$3, [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(covers.value, (detail) => { return vue.openBlock(), vue.createElementBlock("div", { key: detail.book_id, @@ -1714,7 +3448,7 @@ title: __props.group.name }, vue.toDisplayString(__props.group.name), 9, _hoisted_5$2), vue.createElementVNode("div", _hoisted_6$2, "共" + vue.toDisplayString(__props.group.books.length) + "本书", 1) - ], 40, _hoisted_1$2); + ], 40, _hoisted_1$3); }; } }); @@ -1746,12 +3480,12 @@ 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$1 = ["title"]; - const _hoisted_2$1 = { + const _hoisted_1$2 = ["title"]; + const _hoisted_2$2 = { key: 0, class: "fqa-hover-author" }; - const _hoisted_3$1 = { class: "fqa-hover-stats" }; + const _hoisted_3$2 = { class: "fqa-hover-stats" }; const _hoisted_4$1 = ["title"]; const _hoisted_5$1 = { class: "fqa-hover-stat-k" }; const _hoisted_6$1 = { class: "fqa-hover-stat" }; @@ -1764,7 +3498,7 @@ key: 0, class: "fqa-hover-chapter" }; - const _sfc_main$1 = /* @__PURE__ */ vue.defineComponent({ + const _sfc_main$2 = /* @__PURE__ */ vue.defineComponent({ __name: "BookHoverCard", props: { entry: {}, @@ -1867,9 +3601,9 @@ vue.createElementVNode("div", { class: "fqa-hover-title", title: detail.value.title - }, vue.toDisplayString(detail.value.title), 9, _hoisted_1$1), - detail.value.author ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_2$1, vue.toDisplayString(detail.value.author), 1)) : vue.createCommentVNode("", true), - vue.createElementVNode("div", _hoisted_3$1, [ + }, vue.toDisplayString(detail.value.title), 9, _hoisted_1$2), + detail.value.author ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_2$2, vue.toDisplayString(detail.value.author), 1)) : vue.createCommentVNode("", true), + vue.createElementVNode("div", _hoisted_3$2, [ vue.createElementVNode("div", { class: "fqa-hover-stat", onMouseenter: _cache[0] || (_cache[0] = ($event) => showUpdateTime.value = true), @@ -1909,10 +3643,161 @@ }; } }); + const _hoisted_1$1 = ["aria-disabled", "onMouseenter", "onClick"]; + const _hoisted_2$1 = { + key: 0, + class: "fqa-menu-arrow" + }; + const _hoisted_3$1 = ["onClick"]; + const MARGIN = 8; + const _sfc_main$1 = /* @__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$1, "›")) : vue.createCommentVNode("", true) + ], 42, _hoisted_1$1); + }), 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$1); + }), 128)) + ], 4)) : vue.createCommentVNode("", true) + ], 64); + }; + } + }); + const SHELF_BASE = "https://fanqienovel.com/reading/bookapi/bookshelf"; async function getBookshelf() { const responses = await Promise.all([ - fetch("https://fanqienovel.com/reading/bookapi/bookshelf/info/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700"), - fetch("https://fanqienovel.com/api/reader/book/progress") + 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]; @@ -1921,7 +3806,7 @@ const data_ = await response2.json(); const data2 = data_.data; const all_items = info.map((item) => item.book_id); - const response3 = await fetch("https://fanqienovel.com/api/book/simple/info", { + const response3 = await fetch$1("https://fanqienovel.com/api/book/simple/info", { method: "POST", headers: { "Content-Type": "application/json" @@ -1933,14 +3818,14 @@ const data__ = await response3.json(); const data3 = data__.data.bookList; const results = []; - for (let i of data.data.book_shelf_info) { - const progress = data2.find((it) => it.book_id === i.book_id); - const simple = data3.find((it) => it.book_id === i.book_id); + 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: i.book_id, - last_operate_time: progress ? progress.read_timestamp : i.last_operate_time, - add_shelf_time: i.add_shelf_time, - group_name: i.group_name, + 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" @@ -1950,7 +3835,7 @@ return results; } async function multidetail(books) { - const response = await fetch("https://fanqienovel.com/api/bookshelf/multidetail", { + const response = await fetch$1("https://fanqienovel.com/api/bookshelf/multidetail", { method: "POST", headers: { "Content-Type": "application/json" @@ -1967,28 +3852,69 @@ const data = await response.json(); const list = data.data.detail_list; const results = []; - for (let i of list) { + for (let i2 of list) { const item = { - book_id: i.book_id, - summary: i.abstract, - title: i.book_name, - author: i.author_name, - cover_url: i.thumb_url, - current_chapter_title: i.item_show_title, - current_chapter_id: i.item_id, - current_chapter_order: i.real_chapter_order, - total_chapter_count: i.serial_count, - last_chapter_update_time: i.last_chapter_update_time * 1e3, - last_chapter_id: i.last_chapter_item_id, - current_chapter_summary: i.item_abstract, + 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: i.update_stop === "1" ? 4 : i.creation_status, - update_status: i.update_status + status: i2.update_stop === "1" ? 4 : i2.creation_status, + update_status: i2.update_status }; results.push(item); } return results; } + function identify(bookId, modifyTime = 0) { + return { + asterisked: false, + book_id: bookId, + 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 removeFromBookshelf(bookId) { + await shelfPost("/delete", { + identify_data: [identify(bookId, Date.now())] + }); + } + async function moveToGroup(bookId, groupName) { + await shelfPost("/update", { + book_data: [ + { + asterisked: false, + book_id: bookId, + book_type: 0, + group_name: groupName, + has_shown: false, + is_pin: false, + modify_time: Date.now() + } + ] + }); + } const DETAIL_BATCH_SIZE = 20; const PAGE_SIZE = 24; const TABS = [ @@ -2037,7 +3963,7 @@ if (token === loadToken && inflight.size === 0) detailLoading.value = false; } } - async function load(force = false) { + async function load2(force = false) { const token = ++loadToken; inflight.clear(); if (force) detailCache = /* @__PURE__ */ new Map(); @@ -2117,7 +4043,7 @@ error, groups, counts, - load, + load: load2, ensureDetails, cellsOf, findGroup, @@ -2147,15 +4073,20 @@ }; const _hoisted_13 = { class: "fqa-status-title" }; const _hoisted_14 = { class: "fqa-grid" }; + const _hoisted_15 = { + 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 _sfc_main = /* @__PURE__ */ vue.defineComponent({ __name: "BookshelfView", setup(__props) { - const { loading, detailLoading, error, counts, load, ensureDetails, cellsOf, findGroup, PAGE_SIZE: PAGE_SIZE2 } = useBookshelf(); + 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); @@ -2192,7 +4123,7 @@ } async function refresh() { resetPaging(); - await load(true); + await load2(true); } vue.watch([activeTab, openedGroupName], resetPaging); const isEmpty = vue.computed(() => !loading.value && !error.value && allCells.value.length === 0); @@ -2322,6 +4253,81 @@ 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: "无分组" }); + return [ + { key: "open", label: "打开" }, + { key: "detail", label: "查看详情" }, + { + key: "move", + label: "移动到分组", + disabled: targets.length === 0, + children: targets + }, + { key: "remove", label: "从书架删除", danger: true } + ]; + }); + 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 bookId = entry.item.book_id; + if (key === "open") { + openBook(entry); + return; + } + if (key === "detail") { + unsafeWindow.location.href = `https://fanqienovel.com/page/${bookId}`; + return; + } + if (key === "remove") { + if (!unsafeWindow.confirm(`确定要把《${((_a = entry.detail) == null ? void 0 : _a.title) ?? bookId}》从书架删除吗?`)) return; + try { + await removeFromBookshelf(bookId); + 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(bookId, 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); @@ -2337,7 +4343,7 @@ updateInk(); } vue.onMounted(async () => { - await load(); + await load2(); await vue.nextTick(); updateInk(); window.addEventListener("scroll", onScrollOrResize, true); @@ -2373,7 +4379,7 @@ return (_ctx, _cache) => { return vue.openBlock(), vue.createElementBlock("div", _hoisted_1, [ vue.createElementVNode("div", _hoisted_2, [ - _cache[0] || (_cache[0] = vue.createElementVNode("div", { class: "fqa-bs-title" }, "我的书架", -1)), + _cache[1] || (_cache[1] = vue.createElementVNode("div", { class: "fqa-bs-title" }, "我的书架", -1)), vue.createElementVNode("div", _hoisted_3, [ vue.unref(detailLoading) ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_4, "正在补全详情…")) : vue.createCommentVNode("", true), vue.createElementVNode("button", { @@ -2417,7 +4423,7 @@ vue.createElementVNode("span", _hoisted_10, "共" + vue.toDisplayString(openedGroup.value.books.length) + "本书", 1) ])) : vue.createCommentVNode("", true), vue.unref(error) ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_11, [ - _cache[1] || (_cache[1] = vue.createElementVNode("div", { class: "fqa-status-title" }, "书架加载失败", -1)), + _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", @@ -2431,7 +4437,7 @@ return vue.createElementVNode("div", { key: `sk-${n}`, class: "fqa-card" - }, [..._cache[2] || (_cache[2] = [ + }, [..._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", @@ -2446,14 +4452,15 @@ return vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: cell.key }, [ - cell.kind === "book" ? (vue.openBlock(), vue.createBlock(_sfc_main$3, { + cell.kind === "book" ? (vue.openBlock(), vue.createBlock(_sfc_main$4, { key: 0, entry: cell.entry, onHover: onCardHover, onLeave: scheduleHide, onOpen: openBook, - onVisible: onCardVisible - }, null, 8, ["entry"])) : (vue.openBlock(), vue.createBlock(_sfc_main$2, { + onVisible: onCardVisible, + onContextmenu: onCardContextMenu + }, null, 8, ["entry"])) : (vue.openBlock(), vue.createBlock(_sfc_main$3, { key: 1, group: cell.group, onOpen: openGroup, @@ -2470,7 +4477,7 @@ }, "加载中…", 512)) : vue.createCommentVNode("", true) ], 64)), (vue.openBlock(), vue.createBlock(vue.Teleport, { to: "body" }, [ - vue.createVNode(_sfc_main$1, { + vue.createVNode(_sfc_main$2, { entry: hoverEntry.value, x: hoverPos.value.x, y: hoverPos.value.y, @@ -2478,13 +4485,22 @@ visible: hoverVisible.value, onPanelEnter, onPanelLeave - }, null, 8, ["entry", "x", "y", "height", "visible"]) + }, null, 8, ["entry", "x", "y", "height", "visible"]), + vue.createVNode(_sfc_main$1, { + 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, 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#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@keyframes fqa-shimmer {\n 100% {\n transform: translateX(100%);\n }\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"; + 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#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@keyframes fqa-shimmer {\n 100% {\n transform: translateX(100%);\n }\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/* ----------------------------- 右键菜单 ----------------------------- */\n\n/*\n * 菜单 Teleport 到 body,拿不到 #fqa-bookshelf 上的变量,\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@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"; const CONTAINER_ID = "fqa-bookshelf-root"; const STYLE_ID = "fqa-bookshelf-style"; const ORIGIN_SELECTOR = ".muye-bookshelf, .muye-bookshelf-home-page, .bookshelf-tabs"; @@ -2566,7 +4582,7 @@ observer.observe(document.body, { childList: true, subtree: true }); } function filter(path, _query, _hash) { - return isBookshelfPath(path) || !!app; + return (isBookshelfPath(path) || !!app) && userState.isLogin; } const _exports = [ { @@ -2591,11 +4607,11 @@ async function onEvent(event, previous) { console.log(`onEvent, event: ${event}, previous: ${previous}`); const path = window.location.pathname; - const hash = window.location.hash; + 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, hash)) { + if (hook.event === event && hook.filter(path, params, hash2)) { tasks.push(async () => { try { await hook.handler(previous); @@ -2619,8 +4635,6 @@ async function onLoad() { return await onEvent("load"); } - const name = "fanqie-assistant"; - const version = "0.0.3"; const win = unsafeWindow; let previousUrl = win.location.href; let previousHash = win.location.hash; @@ -2651,9 +4665,11 @@ installNavigationHooks(); initFontDecrypt(); await inject(); + initUserStyle(); + await ensureDevice(); await init(); void onLoad(); } mainInit(); -})(moment, Vue); \ No newline at end of file +})(Vue, moment); \ No newline at end of file