mirror of
https://github.com/naiyQAQ/fanqie-assistant.git
synced 2026-10-21 01:54:48 +00:00
4675 lines
195 KiB
JavaScript
4675 lines
195 KiB
JavaScript
// ==UserScript==
|
||
// @name 番茄小说助手
|
||
// @namespace https://github.com/naiyQAQ/fanqie-assistant
|
||
// @version 0.0.4
|
||
// @author naiyQAQ
|
||
// @description 番茄小说助手,去广告、去推广、解锁章节、优化体验。
|
||
// @license GPLv3
|
||
// @icon data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4NCiAgICA8cGF0aA0KICAgICAgICBkPSJNMjMuMzExNSAxSDYuNjg4NTNDMy41NDY4NCAxIDEgMy41NDY4NCAxIDYuNjg4NTNWMjMuMzExNUMxIDI2LjQ1MzIgMy41NDY4NCAyOSA2LjY4ODUzIDI5SDIzLjMxMTVDMjYuNDUzMiAyOSAyOSAyNi40NTMyIDI5IDIzLjMxMTVWNi42ODg1M0MyOSAzLjU0Njg0IDI2LjQ1MzIgMSAyMy4zMTE1IDFaIg0KICAgICAgICBmaWxsPSJ3aGl0ZSI+PC9wYXRoPg0KICAgIDxwYXRoDQogICAgICAgIGQ9Ik0yMy4zMTE1IDAuNzVINi42ODg1M0MzLjQwODc3IDAuNzUgMC43NSAzLjQwODc3IDAuNzUgNi42ODg1M1YyMy4zMTE1QzAuNzUgMjYuNTkxMiAzLjQwODc3IDI5LjI1IDYuNjg4NTMgMjkuMjVIMjMuMzExNUMyNi41OTEyIDI5LjI1IDI5LjI1IDI2LjU5MTIgMjkuMjUgMjMuMzExNVY2LjY4ODUzQzI5LjI1IDMuNDA4NzcgMjYuNTkxMiAwLjc1IDIzLjMxMTUgMC43NVoiDQogICAgICAgIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjA4IiBzdHJva2Utd2lkdGg9IjAuNSI+PC9wYXRoPg0KICAgIDxtYXNrIGlkPSJtYXNrMF80NzBfNDgzNjQiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjEiIHk9IjEiIHdpZHRoPSIyOCIgaGVpZ2h0PSIyOCI+DQogICAgICAgIDxwYXRoDQogICAgICAgICAgICBkPSJNMjMuMzExNSAxSDYuNjg4NTNDMy41NDY4NCAxIDEgMy41NDY4NCAxIDYuNjg4NTNWMjMuMzExNUMxIDI2LjQ1MzIgMy41NDY4NCAyOSA2LjY4ODUzIDI5SDIzLjMxMTVDMjYuNDUzMiAyOSAyOSAyNi40NTMyIDI5IDIzLjMxMTVWNi42ODg1M0MyOSAzLjU0Njg0IDI2LjQ1MzIgMSAyMy4zMTE1IDFaIg0KICAgICAgICAgICAgZmlsbD0id2hpdGUiPjwvcGF0aD4NCiAgICA8L21hc2s+DQogICAgPGcgbWFzaz0idXJsKCNtYXNrMF80NzBfNDgzNjQpIj4NCiAgICAgICAgPHBhdGgNCiAgICAgICAgICAgIGQ9Ik0yMy4zMTE1IDFINi42ODg1M0MzLjU0Njg0IDEgMSAzLjU0Njg0IDEgNi42ODg1M1YyMy4zMTE1QzEgMjYuNDUzMiAzLjU0Njg0IDI5IDYuNjg4NTMgMjlIMjMuMzExNUMyNi40NTMyIDI5IDI5IDI2LjQ1MzIgMjkgMjMuMzExNVY2LjY4ODUzQzI5IDMuNTQ2ODQgMjYuNDUzMiAxIDIzLjMxMTUgMVoiDQogICAgICAgICAgICBmaWxsPSJ3aGl0ZSI+PC9wYXRoPg0KICAgICAgICA8cGF0aA0KICAgICAgICAgICAgZD0iTTE1LjAwMDggNDguNjY0MkMyNS40MDE3IDQ4LjY2NDIgMzMuODMzNCA0MC4yMzI2IDMzLjgzMzQgMjkuODMxNkMzMy44MzM0IDE5LjQzMDcgMjUuNDAxNyAxMC45OTkgMTUuMDAwOCAxMC45OTlDNC41OTk4NSAxMC45OTkgLTMuODMxNzkgMTkuNDMwNyAtMy44MzE3OSAyOS44MzE2Qy0zLjgzMTc5IDQwLjIzMjYgNC41OTk4NSA0OC42NjQyIDE1LjAwMDggNDguNjY0MloiDQogICAgICAgICAgICBmaWxsPSJ1cmwoI3BhaW50MF9yYWRpYWxfNDcwXzQ4MzY0KSI+PC9wYXRoPg0KICAgICAgICA8cGF0aCBkPSJNMjMuMjY4OCAxVjcuMjEyOTRMMjAuNjY2MyA1LjcxNDM3TDE4LjA2NzQgNy4yMTI5NFYxSDIzLjI2ODhaIiBmaWxsPSIjRkY1RjAwIj48L3BhdGg+DQogICAgICAgIDxwYXRoDQogICAgICAgICAgICBkPSJNMTUuMTM0MyAxOC44ODFDMTUuMTM0MyAxOC44ODEgMTYuMTAxNCAxNy41NTEzIDE2LjEwMTQgMTYuNDA2NUMxNi4xMDE0IDE1LjI2MTcgMTUuNjY3NiAxNC43MzczIDE1LjEzNDMgMTQuNzM3M0MxNC42MDEgMTQuNzM3MyAxNC4xNjczIDE1LjI2MzUgMTQuMTY3MyAxNi40MDY1QzE0LjE2NzMgMTcuNTQ5NiAxNS4xMzQzIDE4Ljg4MSAxNS4xMzQzIDE4Ljg4MVoiDQogICAgICAgICAgICBmaWxsPSJ3aGl0ZSI+PC9wYXRoPg0KICAgICAgICA8cGF0aA0KICAgICAgICAgICAgZD0iTTcuNjI3MjQgMjIuNjU4NUM4Ljc3MjA1IDIyLjY1ODUgMTAuMTAxNyAyMy42MjU2IDEwLjEwMTcgMjMuNjI1NkMxMC4xMDE3IDIzLjYyNTYgOC43NzAyNyAyNC41OTI2IDcuNjI3MjQgMjQuNTkyNkM2LjQ4NDIgMjQuNTkyNiA1Ljk1ODAxIDI0LjE1ODkgNS45NTgwMSAyMy42MjU2QzUuOTU4MDEgMjMuMDkyMyA2LjQ4MjQyIDIyLjY1ODUgNy42MjcyNCAyMi42NTg1WiINCiAgICAgICAgICAgIGZpbGw9IndoaXRlIj48L3BhdGg+DQogICAgICAgIDxwYXRoDQogICAgICAgICAgICBkPSJNMjIuNjM5NiAyNC41OTI2QzIxLjQ5NDggMjQuNTkyNiAyMC4xNjUxIDIzLjYyNTYgMjAuMTY1MSAyMy42MjU2QzIwLjE2NTEgMjMuNjI1NiAyMS40OTY2IDIyLjY1ODUgMjIuNjM5NiAyMi42NTg1QzIzLjc4MjYgMjIuNjU4NSAyNC4zMDg4IDIzLjA5MjMgMjQuMzA4OCAyMy42MjU2QzI0LjMwODggMjQuMTU4OSAyMy43ODQ0IDI0LjU5MjYgMjIuNjM5NiAyNC41OTI2WiINCiAgICAgICAgICAgIGZpbGw9IndoaXRlIj48L3BhdGg+DQogICAgICAgIDxwYXRoDQogICAgICAgICAgICBkPSJNMTAuNDU1NSAxOC4zMTM5QzExLjI2NDMgMTkuMTIyNyAxMS41MjIxIDIwLjc0NzUgMTEuNTIyMSAyMC43NDc1QzExLjUyMjEgMjAuNzQ3NSA5Ljg5NzMyIDIwLjQ4OTcgOS4wODg0OCAxOS42ODA5QzguMjc5NjQgMTguODcyMSA4LjIxMzg3IDE4LjE5NDggOC41OTI1MSAxNy44MTYxQzguOTcxMTUgMTcuNDM3NSA5LjY0NjY2IDE3LjUwMzMgMTAuNDU3MyAxOC4zMTIxTDEwLjQ1NTUgMTguMzEzOVoiDQogICAgICAgICAgICBmaWxsPSJ3aGl0ZSI+PC9wYXRoPg0KICAgICAgICA8cGF0aA0KICAgICAgICAgICAgZD0iTTE4Ljc0NjUgMjAuNzQ3NkMxOC43NDY1IDIwLjc0NzYgMTkuMDA0MyAxOS4xMjI4IDE5LjgxMzEgMTguMzE0TDE5LjgxMTMgMTguMzEyMkMyMC42MjIgMTcuNTAzMyAyMS4yOTkzIDE3LjQzOTMgMjEuNjc2MSAxNy44MTYyQzIyLjA1NDggMTguMTk0OSAyMS45ODkgMTguODcyMSAyMS4xODAyIDE5LjY4MUMyMC4zNzEzIDIwLjQ4OTggMTguNzQ2NSAyMC43NDc2IDE4Ljc0NjUgMjAuNzQ3NloiDQogICAgICAgICAgICBmaWxsPSJ3aGl0ZSI+PC9wYXRoPg0KICAgIDwvZz4NCiAgICA8ZGVmcz4NCiAgICAgICAgPHJhZGlhbEdyYWRpZW50IGlkPSJwYWludDBfcmFkaWFsXzQ3MF80ODM2NCIgY3g9IjAiIGN5PSIwIiByPSIxIg0KICAgICAgICAgICAgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiDQogICAgICAgICAgICBncmFkaWVudFRyYW5zZm9ybT0idHJhbnNsYXRlKDE1LjAwMDggMjkuODMxNikgc2NhbGUoMTguODMyNikiPg0KICAgICAgICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iI0NDMDUwMCI+PC9zdG9wPg0KICAgICAgICAgICAgPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjRkY1RjAwIj48L3N0b3A+DQogICAgICAgIDwvcmFkaWFsR3JhZGllbnQ+DQogICAgPC9kZWZzPg0KPC9zdmc+
|
||
// @match *://*.fanqienovel.com/*
|
||
// @require https://registry.npmmirror.com/vue/3.5.40/files/dist/vue.global.prod.js
|
||
// @require https://registry.npmmirror.com/moment/2.30.1/files/min/moment.min.js
|
||
// @connect fanqienovel.com
|
||
// @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
|
||
// @grant GM_setValue
|
||
// @grant GM_xmlhttpRequest
|
||
// @grant unsafeWindow
|
||
// @run-at document-start
|
||
// ==/UserScript==
|
||
|
||
(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, typeof key !== "symbol" ? key + "" : key, value);
|
||
const shared_key = new Uint8Array([172, 37, 198, 125, 221, 143, 56, 193, 179, 122, 35, 72, 130, 142, 34, 46]).buffer;
|
||
const fetch$1 = unsafeWindow.fetch;
|
||
unsafeWindow.XMLHttpRequest;
|
||
const defaultConfig = {
|
||
install_id: "2187355326270644",
|
||
device_id: "2187355326004404",
|
||
device_type: "P30"
|
||
};
|
||
const _config = {
|
||
currentConfig: defaultConfig
|
||
};
|
||
const scriptcss = "/* 移除章节锁定图标 */\n.muyeicon-lock {\n display: none;\n}\n/* 移除APP推广图标 */\n.muye-to-fanqie {\n display: none!important;\n}\n.reader-toolbar-item-download {\n display: none!important;\n}\n.download-btn {\n display: none!important;\n}\n.download-icon {\n display: none!important;\n}\n\n.fqa-hide {\n display: none!important;\n}\n/* 404 */\n.no-content {\n display: none!important;\n}\n\n.fqa-comic-img {\n width: 100%!important;\n height: 100%!important;\n max-width: 100%!important;\n max-height: 100%!important;\n padding-top: 0!important;\n padding-bottom: 0!important;\n margin-top: 0!important;\n margin-bottom: 0!important;\n}\n\n.fqa-comic-reader {\n line-height: 0!important;\n}\n\n.fqa-menu-item,\n.arco-menu-item {\n width: 100%!important;\n}\n\n#dynamic-el {\n display: none!important;\n}\n\n.fqa-footnote-ref {\n display: inline-block;\n margin: 0 0.15em;\n padding: 0 0.25em;\n font-size: 0.7em;\n line-height: 1.4;\n vertical-align: super;\n color: var(--web-brand_normal, #f14646);\n cursor: pointer;\n user-select: none;\n border-radius: 3px;\n text-indent: 0;\n}\n\n.fqa-footnote-ref:hover,\n.fqa-footnote-ref:focus-visible {\n background: var(--web-brand_light, rgba(241, 70, 70, 0.12));\n outline: none;\n}\n\n\n.fqa-footnote {\n margin-top: 2em;\n padding-top: 1em;\n border-top: 1px solid var(--web-gray_20, rgba(128, 128, 128, 0.25));\n font-size: var(--fqa-body-size, 1.6rem);\n}\n\n.muye-reader-content-16 .fqa-footnote { font-size: var(--fqa-body-size, 1.6rem); }\n.muye-reader-content-20 .fqa-footnote { font-size: var(--fqa-body-size, 2rem); }\n.muye-reader-content-24 .fqa-footnote { font-size: var(--fqa-body-size, 2.4rem); }\n.muye-reader-content-28 .fqa-footnote { font-size: var(--fqa-body-size, 2.8rem); }\n.muye-reader-content-32 .fqa-footnote { font-size: var(--fqa-body-size, 3.2rem); }\n\n.fqa-footnote-title {\n margin-bottom: 0.6em;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--web-gray_40, #8a8a8a);\n text-indent: 0;\n}\n\n.fqa-footnote-list {\n margin: 0;\n padding-left: 1.6em;\n font-size: 0.85em;\n line-height: 1.7;\n color: var(--web-gray_40, #8a8a8a);\n}\n\n.fqa-footnote-list li {\n margin-bottom: 0.5em;\n text-indent: 0;\n transition: background-color 0.3s ease;\n}\n\n.fqa-footnote-list li.fqa-footnote-active {\n background: var(--web-brand_light, rgba(241, 70, 70, 0.12));\n border-radius: 4px;\n}\n\n.muye-reader-content > body {\n background-color: var(--web-bg)!important;\n}\n\n.fqa-icon-dark {\n color: #B3B3B3\n}";
|
||
async function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
function cloneElement(element) {
|
||
return element.cloneNode(true);
|
||
}
|
||
function waitForElement(selector, timeout = 15e3) {
|
||
const existing = document.querySelector(selector);
|
||
if (existing) return Promise.resolve(existing);
|
||
return new Promise((resolve) => {
|
||
let timer;
|
||
const observer2 = new MutationObserver(() => {
|
||
const el = document.querySelector(selector);
|
||
if (el) {
|
||
if (timer) clearTimeout(timer);
|
||
observer2.disconnect();
|
||
resolve(el);
|
||
}
|
||
});
|
||
const start = () => {
|
||
observer2.observe(document.documentElement, { childList: true, subtree: true });
|
||
timer = setTimeout(() => {
|
||
observer2.disconnect();
|
||
resolve(document.querySelector(selector));
|
||
}, timeout);
|
||
};
|
||
if (document.documentElement) {
|
||
start();
|
||
} else {
|
||
document.addEventListener("DOMContentLoaded", start, { once: true });
|
||
}
|
||
});
|
||
}
|
||
function chunk(list, size) {
|
||
const result = [];
|
||
for (let i2 = 0; i2 < list.length; i2 += size) {
|
||
result.push(list.slice(i2, i2 + size));
|
||
}
|
||
return result;
|
||
}
|
||
function concatArrayBuffers(...buffers) {
|
||
const totalLength = buffers.reduce((sum, buf) => sum + buf.byteLength, 0);
|
||
const result = new Uint8Array(totalLength);
|
||
let offset = 0;
|
||
for (const buf of buffers) {
|
||
result.set(new Uint8Array(buf), offset);
|
||
offset += buf.byteLength;
|
||
}
|
||
return result.buffer;
|
||
}
|
||
async function inject() {
|
||
while (!document.body) {
|
||
console.log("Waiting for body...");
|
||
await sleep(200);
|
||
}
|
||
GM_addStyle(scriptcss);
|
||
console.log("CSS injected successfully!");
|
||
}
|
||
unsafeWindow.localStorage;
|
||
function write(key, value) {
|
||
GM_setValue(key, JSON.stringify(value));
|
||
}
|
||
function read(key) {
|
||
return JSON.parse(GM_getValue(key) || "null");
|
||
}
|
||
function del(key) {
|
||
GM_deleteValue(key);
|
||
}
|
||
const STORE_KEY$1 = "settings";
|
||
const DEFAULT_SETTINGS = {
|
||
decryptFont: true,
|
||
blockReport: true,
|
||
allowCopy: true,
|
||
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;
|
||
const mapping = [
|
||
"D",
|
||
"在",
|
||
"主",
|
||
"特",
|
||
"家",
|
||
"军",
|
||
"然",
|
||
"表",
|
||
"场",
|
||
"4",
|
||
"要",
|
||
"只",
|
||
"v",
|
||
"和",
|
||
"?",
|
||
"6",
|
||
"别",
|
||
"还",
|
||
"g",
|
||
"现",
|
||
"儿",
|
||
"岁",
|
||
"?",
|
||
"?",
|
||
"此",
|
||
"象",
|
||
"月",
|
||
"3",
|
||
"出",
|
||
"战",
|
||
"工",
|
||
"相",
|
||
"o",
|
||
"男",
|
||
"直",
|
||
"失",
|
||
"世",
|
||
"F",
|
||
"都",
|
||
"平",
|
||
"文",
|
||
"什",
|
||
"V",
|
||
"O",
|
||
"将",
|
||
"真",
|
||
"T",
|
||
"那",
|
||
"当",
|
||
"?",
|
||
"会",
|
||
"立",
|
||
"些",
|
||
"u",
|
||
"是",
|
||
"十",
|
||
"张",
|
||
"学",
|
||
"气",
|
||
"大",
|
||
"爱",
|
||
"两",
|
||
"命",
|
||
"全",
|
||
"后",
|
||
"东",
|
||
"性",
|
||
"通",
|
||
"被",
|
||
"1",
|
||
"它",
|
||
"乐",
|
||
"接",
|
||
"而",
|
||
"感",
|
||
"车",
|
||
"山",
|
||
"公",
|
||
"了",
|
||
"常",
|
||
"以",
|
||
"何",
|
||
"可",
|
||
"话",
|
||
"先",
|
||
"p",
|
||
"i",
|
||
"叫",
|
||
"轻",
|
||
"M",
|
||
"士",
|
||
"w",
|
||
"着",
|
||
"变",
|
||
"尔",
|
||
"快",
|
||
"l",
|
||
"个",
|
||
"说",
|
||
"少",
|
||
"色",
|
||
"里",
|
||
"安",
|
||
"花",
|
||
"远",
|
||
"7",
|
||
"难",
|
||
"师",
|
||
"放",
|
||
"t",
|
||
"报",
|
||
"认",
|
||
"面",
|
||
"道",
|
||
"S",
|
||
"?",
|
||
"克",
|
||
"地",
|
||
"度",
|
||
"I",
|
||
"好",
|
||
"机",
|
||
"U",
|
||
"民",
|
||
"写",
|
||
"把",
|
||
"万",
|
||
"同",
|
||
"水",
|
||
"新",
|
||
"没",
|
||
"书",
|
||
"电",
|
||
"吃",
|
||
"像",
|
||
"斯",
|
||
"5",
|
||
"为",
|
||
"y",
|
||
"白",
|
||
"几",
|
||
"日",
|
||
"教",
|
||
"看",
|
||
"但",
|
||
"第",
|
||
"加",
|
||
"候",
|
||
"作",
|
||
"上",
|
||
"拉",
|
||
"住",
|
||
"有",
|
||
"法",
|
||
"r",
|
||
"事",
|
||
"应",
|
||
"位",
|
||
"利",
|
||
"你",
|
||
"声",
|
||
"身",
|
||
"国",
|
||
"问",
|
||
"马",
|
||
"女",
|
||
"他",
|
||
"Y",
|
||
"比",
|
||
"父",
|
||
"x",
|
||
"A",
|
||
"H",
|
||
"N",
|
||
"s",
|
||
"X",
|
||
"边",
|
||
"美",
|
||
"对",
|
||
"所",
|
||
"金",
|
||
"活",
|
||
"回",
|
||
"意",
|
||
"到",
|
||
"z",
|
||
"从",
|
||
"j",
|
||
"知",
|
||
"又",
|
||
"内",
|
||
"因",
|
||
"点",
|
||
"Q",
|
||
"三",
|
||
"定",
|
||
"8",
|
||
"R",
|
||
"b",
|
||
"正",
|
||
"或",
|
||
"夫",
|
||
"向",
|
||
"德",
|
||
"听",
|
||
"更",
|
||
"?",
|
||
"得",
|
||
"告",
|
||
"并",
|
||
"本",
|
||
"q",
|
||
"过",
|
||
"记",
|
||
"L",
|
||
"让",
|
||
"打",
|
||
"f",
|
||
"人",
|
||
"就",
|
||
"者",
|
||
"去",
|
||
"原",
|
||
"满",
|
||
"体",
|
||
"做",
|
||
"经",
|
||
"K",
|
||
"走",
|
||
"如",
|
||
"孩",
|
||
"c",
|
||
"G",
|
||
"给",
|
||
"使",
|
||
"物",
|
||
"?",
|
||
"最",
|
||
"笑",
|
||
"部",
|
||
"?",
|
||
"员",
|
||
"等",
|
||
"受",
|
||
"k",
|
||
"行",
|
||
"一",
|
||
"条",
|
||
"果",
|
||
"动",
|
||
"光",
|
||
"门",
|
||
"头",
|
||
"见",
|
||
"往",
|
||
"自",
|
||
"解",
|
||
"成",
|
||
"处",
|
||
"天",
|
||
"能",
|
||
"于",
|
||
"名",
|
||
"其",
|
||
"发",
|
||
"总",
|
||
"母",
|
||
"的",
|
||
"死",
|
||
"手",
|
||
"入",
|
||
"路",
|
||
"进",
|
||
"心",
|
||
"来",
|
||
"h",
|
||
"时",
|
||
"力",
|
||
"多",
|
||
"开",
|
||
"已",
|
||
"许",
|
||
"d",
|
||
"至",
|
||
"由",
|
||
"很",
|
||
"界",
|
||
"n",
|
||
"小",
|
||
"与",
|
||
"Z",
|
||
"想",
|
||
"代",
|
||
"么",
|
||
"分",
|
||
"生",
|
||
"口",
|
||
"再",
|
||
"妈",
|
||
"望",
|
||
"次",
|
||
"西",
|
||
"风",
|
||
"种",
|
||
"带",
|
||
"J",
|
||
"?",
|
||
"实",
|
||
"情",
|
||
"才",
|
||
"这",
|
||
"?",
|
||
"E",
|
||
"我",
|
||
"神",
|
||
"格",
|
||
"长",
|
||
"觉",
|
||
"间",
|
||
"年",
|
||
"眼",
|
||
"无",
|
||
"不",
|
||
"亲",
|
||
"关",
|
||
"结",
|
||
"0",
|
||
"友",
|
||
"信",
|
||
"下",
|
||
"却",
|
||
"重",
|
||
"己",
|
||
"老",
|
||
"2",
|
||
"音",
|
||
"字",
|
||
"m",
|
||
"呢",
|
||
"明",
|
||
"之",
|
||
"前",
|
||
"高",
|
||
"P",
|
||
"B",
|
||
"目",
|
||
"太",
|
||
"e",
|
||
"9",
|
||
"起",
|
||
"稜",
|
||
"她",
|
||
"也",
|
||
"W",
|
||
"用",
|
||
"方",
|
||
"子",
|
||
"英",
|
||
"每",
|
||
"理",
|
||
"便",
|
||
"四",
|
||
"数",
|
||
"期",
|
||
"中",
|
||
"C",
|
||
"外",
|
||
"样",
|
||
"a",
|
||
"海",
|
||
"们",
|
||
"任"
|
||
];
|
||
function decryptText(text) {
|
||
let result = "";
|
||
let changed = false;
|
||
for (const char of text) {
|
||
const codePoint = char.codePointAt(0);
|
||
if (typeof codePoint !== "number") {
|
||
return text;
|
||
}
|
||
if (codePoint < code_st || codePoint > code_ed) {
|
||
result += char;
|
||
continue;
|
||
}
|
||
const mapped = mapping[codePoint - code_st];
|
||
if (mapped && mapped !== "?") {
|
||
result += mapped;
|
||
changed = true;
|
||
} else {
|
||
result += char;
|
||
}
|
||
}
|
||
return changed ? result : text;
|
||
}
|
||
function decryptElement(element) {
|
||
if (!settings.decryptFont) return;
|
||
const walker = document.createTreeWalker(
|
||
element,
|
||
NodeFilter.SHOW_TEXT
|
||
);
|
||
let textNode;
|
||
while (textNode = walker.nextNode()) {
|
||
const oldText = textNode.nodeValue;
|
||
if (!oldText) {
|
||
continue;
|
||
}
|
||
const newText = decryptText(oldText);
|
||
if (newText !== oldText) {
|
||
textNode.nodeValue = newText;
|
||
}
|
||
}
|
||
}
|
||
function decryptPage(root) {
|
||
if (root instanceof Element && root.matches(enTag)) {
|
||
decryptElement(root);
|
||
}
|
||
root.querySelectorAll(enTag).forEach(decryptElement);
|
||
}
|
||
function initFontDecrypt() {
|
||
const observer2 = new MutationObserver((mutations) => {
|
||
var _a, _b;
|
||
for (const mutation of mutations) {
|
||
if (mutation.type === "characterData") {
|
||
const encryptedElement = (_a = mutation.target.parentElement) == null ? void 0 : _a.closest(
|
||
enTag
|
||
);
|
||
if (encryptedElement) {
|
||
decryptElement(encryptedElement);
|
||
}
|
||
continue;
|
||
}
|
||
for (const node of mutation.addedNodes) {
|
||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||
decryptPage(node);
|
||
continue;
|
||
}
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
const encryptedElement = (_b = node.parentElement) == null ? void 0 : _b.closest(
|
||
enTag
|
||
);
|
||
if (encryptedElement) {
|
||
decryptElement(encryptedElement);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
observer2.observe(document, {
|
||
subtree: true,
|
||
childList: true,
|
||
characterData: true
|
||
});
|
||
decryptPage(document);
|
||
vue.watch(
|
||
() => settings.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",
|
||
"HEAD",
|
||
"POST",
|
||
"PUT",
|
||
"DELETE"
|
||
]);
|
||
function apiFetch(url, options = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const { signal } = options;
|
||
if (signal == null ? void 0 : signal.aborted) {
|
||
reject(signal.reason ?? new DOMException("The operation was aborted", "AbortError"));
|
||
return;
|
||
}
|
||
const headers = normalizeHeaders(options.headers);
|
||
const data = normalizeBody(options.body);
|
||
const method = options.method ?? (data ? "POST" : "GET");
|
||
if (!supportedMethods.has(method)) {
|
||
reject(new TypeError(`Unsupported request method: ${method}`));
|
||
return;
|
||
}
|
||
let request;
|
||
const abort = () => request == null ? void 0 : request.abort();
|
||
function cleanup() {
|
||
signal == null ? void 0 : signal.removeEventListener("abort", abort);
|
||
}
|
||
request = GM_xmlhttpRequest({
|
||
url,
|
||
method,
|
||
headers,
|
||
data,
|
||
anonymous: options.credentials === "omit",
|
||
redirect: options.redirect === "error" ? "error" : "follow",
|
||
onload(response) {
|
||
cleanup();
|
||
resolve(Object.assign(response, {
|
||
json() {
|
||
return JSON.parse(this.responseText);
|
||
}
|
||
}));
|
||
},
|
||
onerror(response) {
|
||
cleanup();
|
||
reject(createRequestError("Network request failed", response));
|
||
},
|
||
ontimeout() {
|
||
cleanup();
|
||
reject(createRequestError("Network request timed out", {
|
||
status: 0,
|
||
statusText: "Timeout",
|
||
url
|
||
}));
|
||
},
|
||
onabort() {
|
||
cleanup();
|
||
reject(
|
||
(signal == null ? void 0 : signal.reason) ?? new DOMException("The operation was aborted", "AbortError")
|
||
);
|
||
}
|
||
});
|
||
signal == null ? void 0 : signal.addEventListener("abort", abort, { once: true });
|
||
});
|
||
}
|
||
function normalizeHeaders(headers) {
|
||
if (!headers) {
|
||
return void 0;
|
||
}
|
||
return Object.fromEntries(new Headers(headers).entries());
|
||
}
|
||
function normalizeBody(body) {
|
||
if (body == null) {
|
||
return void 0;
|
||
}
|
||
if (body instanceof URLSearchParams) {
|
||
return body.toString();
|
||
}
|
||
if (typeof body === "string" || body instanceof Blob || body instanceof ArrayBuffer || body instanceof FormData) {
|
||
return body;
|
||
}
|
||
if (ArrayBuffer.isView(body)) {
|
||
return body.buffer.slice(
|
||
body.byteOffset,
|
||
body.byteOffset + body.byteLength
|
||
);
|
||
}
|
||
throw new TypeError(
|
||
"GM_xmlhttpRequest does not support ReadableStream request bodies"
|
||
);
|
||
}
|
||
function createRequestError(message, response) {
|
||
const error = new TypeError(message);
|
||
Object.defineProperty(error, "response", {
|
||
configurable: true,
|
||
enumerable: false,
|
||
value: response
|
||
});
|
||
return error;
|
||
}
|
||
function rotateLeft(value, shiftBits) {
|
||
return value << shiftBits | value >>> 32 - shiftBits;
|
||
}
|
||
function addUnsigned(left, right) {
|
||
return left + right >>> 0;
|
||
}
|
||
function f(x, y, z) {
|
||
return x & y | ~x & z;
|
||
}
|
||
function g(x, y, z) {
|
||
return x & z | y & ~z;
|
||
}
|
||
function h(x, y, z) {
|
||
return x ^ y ^ z;
|
||
}
|
||
function i(x, y, z) {
|
||
return y ^ (x | ~z);
|
||
}
|
||
function ff(a, b, c, d, x, s, ac) {
|
||
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(f(b, c, d), x), ac)), s), b);
|
||
}
|
||
function gg(a, b, c, d, x, s, ac) {
|
||
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(g(b, c, d), x), ac)), s), b);
|
||
}
|
||
function hh(a, b, c, d, x, s, ac) {
|
||
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(h(b, c, d), x), ac)), s), b);
|
||
}
|
||
function ii(a, b, c, d, x, s, ac) {
|
||
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(i(b, c, d), x), ac)), s), b);
|
||
}
|
||
function convertToWordArray(bytes) {
|
||
const wordCount = Math.ceil((bytes.length + 9) / 64) * 16;
|
||
const words = new Array(wordCount).fill(0);
|
||
for (let byteIndex = 0; byteIndex < bytes.length; byteIndex++) {
|
||
const wordIndex = Math.floor(byteIndex / 4);
|
||
const bytePosition = byteIndex % 4 * 8;
|
||
words[wordIndex] = words[wordIndex] | bytes[byteIndex] << bytePosition;
|
||
}
|
||
const paddingWordIndex = Math.floor(bytes.length / 4);
|
||
const paddingBytePosition = bytes.length % 4 * 8;
|
||
words[paddingWordIndex] = words[paddingWordIndex] | 128 << paddingBytePosition;
|
||
words[wordCount - 2] = bytes.length << 3;
|
||
words[wordCount - 1] = bytes.length >>> 29;
|
||
return words;
|
||
}
|
||
function wordToHex(value) {
|
||
let hex2 = "";
|
||
for (let byteIndex = 0; byteIndex < 4; byteIndex++) {
|
||
const byte = value >>> byteIndex * 8 & 255;
|
||
hex2 += byte.toString(16).padStart(2, "0");
|
||
}
|
||
return hex2;
|
||
}
|
||
function md5(input) {
|
||
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : new Uint8Array(input);
|
||
const words = convertToWordArray(bytes);
|
||
let a = 1732584193;
|
||
let b = 4023233417;
|
||
let c = 2562383102;
|
||
let d = 271733878;
|
||
for (let k = 0; k < words.length; k += 16) {
|
||
const aa = a;
|
||
const bb = b;
|
||
const cc = c;
|
||
const dd = d;
|
||
a = ff(a, b, c, d, words[k], 7, 3614090360);
|
||
d = ff(d, a, b, c, words[k + 1], 12, 3905402710);
|
||
c = ff(c, d, a, b, words[k + 2], 17, 606105819);
|
||
b = ff(b, c, d, a, words[k + 3], 22, 3250441966);
|
||
a = ff(a, b, c, d, words[k + 4], 7, 4118548399);
|
||
d = ff(d, a, b, c, words[k + 5], 12, 1200080426);
|
||
c = ff(c, d, a, b, words[k + 6], 17, 2821735955);
|
||
b = ff(b, c, d, a, words[k + 7], 22, 4249261313);
|
||
a = ff(a, b, c, d, words[k + 8], 7, 1770035416);
|
||
d = ff(d, a, b, c, words[k + 9], 12, 2336552879);
|
||
c = ff(c, d, a, b, words[k + 10], 17, 4294925233);
|
||
b = ff(b, c, d, a, words[k + 11], 22, 2304563134);
|
||
a = ff(a, b, c, d, words[k + 12], 7, 1804603682);
|
||
d = ff(d, a, b, c, words[k + 13], 12, 4254626195);
|
||
c = ff(c, d, a, b, words[k + 14], 17, 2792965006);
|
||
b = ff(b, c, d, a, words[k + 15], 22, 1236535329);
|
||
a = gg(a, b, c, d, words[k + 1], 5, 4129170786);
|
||
d = gg(d, a, b, c, words[k + 6], 9, 3225465664);
|
||
c = gg(c, d, a, b, words[k + 11], 14, 643717713);
|
||
b = gg(b, c, d, a, words[k], 20, 3921069994);
|
||
a = gg(a, b, c, d, words[k + 5], 5, 3593408605);
|
||
d = gg(d, a, b, c, words[k + 10], 9, 38016083);
|
||
c = gg(c, d, a, b, words[k + 15], 14, 3634488961);
|
||
b = gg(b, c, d, a, words[k + 4], 20, 3889429448);
|
||
a = gg(a, b, c, d, words[k + 9], 5, 568446438);
|
||
d = gg(d, a, b, c, words[k + 14], 9, 3275163606);
|
||
c = gg(c, d, a, b, words[k + 3], 14, 4107603335);
|
||
b = gg(b, c, d, a, words[k + 8], 20, 1163531501);
|
||
a = gg(a, b, c, d, words[k + 13], 5, 2850285829);
|
||
d = gg(d, a, b, c, words[k + 2], 9, 4243563512);
|
||
c = gg(c, d, a, b, words[k + 7], 14, 1735328473);
|
||
b = gg(b, c, d, a, words[k + 12], 20, 2368359562);
|
||
a = hh(a, b, c, d, words[k + 5], 4, 4294588738);
|
||
d = hh(d, a, b, c, words[k + 8], 11, 2272392833);
|
||
c = hh(c, d, a, b, words[k + 11], 16, 1839030562);
|
||
b = hh(b, c, d, a, words[k + 14], 23, 4259657740);
|
||
a = hh(a, b, c, d, words[k + 1], 4, 2763975236);
|
||
d = hh(d, a, b, c, words[k + 4], 11, 1272893353);
|
||
c = hh(c, d, a, b, words[k + 7], 16, 4139469664);
|
||
b = hh(b, c, d, a, words[k + 10], 23, 3200236656);
|
||
a = hh(a, b, c, d, words[k + 13], 4, 681279174);
|
||
d = hh(d, a, b, c, words[k], 11, 3936430074);
|
||
c = hh(c, d, a, b, words[k + 3], 16, 3572445317);
|
||
b = hh(b, c, d, a, words[k + 6], 23, 76029189);
|
||
a = hh(a, b, c, d, words[k + 9], 4, 3654602809);
|
||
d = hh(d, a, b, c, words[k + 12], 11, 3873151461);
|
||
c = hh(c, d, a, b, words[k + 15], 16, 530742520);
|
||
b = hh(b, c, d, a, words[k + 2], 23, 3299628645);
|
||
a = ii(a, b, c, d, words[k], 6, 4096336452);
|
||
d = ii(d, a, b, c, words[k + 7], 10, 1126891415);
|
||
c = ii(c, d, a, b, words[k + 14], 15, 2878612391);
|
||
b = ii(b, c, d, a, words[k + 5], 21, 4237533241);
|
||
a = ii(a, b, c, d, words[k + 12], 6, 1700485571);
|
||
d = ii(d, a, b, c, words[k + 3], 10, 2399980690);
|
||
c = ii(c, d, a, b, words[k + 10], 15, 4293915773);
|
||
b = ii(b, c, d, a, words[k + 1], 21, 2240044497);
|
||
a = ii(a, b, c, d, words[k + 8], 6, 1873313359);
|
||
d = ii(d, a, b, c, words[k + 15], 10, 4264355552);
|
||
c = ii(c, d, a, b, words[k + 6], 15, 2734768916);
|
||
b = ii(b, c, d, a, words[k + 13], 21, 1309151649);
|
||
a = ii(a, b, c, d, words[k + 4], 6, 4149444226);
|
||
d = ii(d, a, b, c, words[k + 11], 10, 3174756917);
|
||
c = ii(c, d, a, b, words[k + 2], 15, 718787259);
|
||
b = ii(b, c, d, a, words[k + 9], 21, 3951481745);
|
||
a = addUnsigned(a, aa);
|
||
b = addUnsigned(b, bb);
|
||
c = addUnsigned(c, cc);
|
||
d = addUnsigned(d, dd);
|
||
}
|
||
return (wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)).toLowerCase();
|
||
}
|
||
function rotl(value, shift) {
|
||
const normalizedShift = shift & 31;
|
||
return value << normalizedShift | value >>> 32 - normalizedShift;
|
||
}
|
||
function p0(value) {
|
||
return value ^ rotl(value, 9) ^ rotl(value, 17);
|
||
}
|
||
function p1(value) {
|
||
return value ^ rotl(value, 15) ^ rotl(value, 23);
|
||
}
|
||
function sm3(input) {
|
||
const inputLength = input.length;
|
||
const paddedLength = Math.ceil((inputLength + 9) / 64) * 64;
|
||
const message = new Uint8Array(paddedLength);
|
||
message.set(input);
|
||
message[inputLength] = 128;
|
||
const bitLength = BigInt(inputLength) * 8n;
|
||
for (let i2 = 0; i2 < 8; i2++) {
|
||
message[paddedLength - 1 - i2] = Number(bitLength >> BigInt(i2 * 8) & 0xffn);
|
||
}
|
||
const dataView = new DataView(message.buffer);
|
||
const 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;
|
||
if (!(c == null ? void 0 : c.subtle)) {
|
||
throw new Error("Crypto API不可用,请检查浏览器版本是否支持该API");
|
||
}
|
||
return c;
|
||
}
|
||
function getSubtle() {
|
||
return getCrypto().subtle;
|
||
}
|
||
function b64decode(b64) {
|
||
const binaryString = atob(b64);
|
||
const len = binaryString.length;
|
||
const bytes = new Uint8Array(len);
|
||
for (let i2 = 0; i2 < len; i2++) {
|
||
bytes[i2] = binaryString.charCodeAt(i2);
|
||
}
|
||
return bytes.buffer;
|
||
}
|
||
function b64encode(buffer) {
|
||
const bytes = new Uint8Array(buffer);
|
||
const chunkSize = 32768;
|
||
const chunks = [];
|
||
for (let i2 = 0; i2 < bytes.length; i2 += chunkSize) {
|
||
chunks.push(
|
||
String.fromCharCode(...bytes.subarray(i2, i2 + chunkSize))
|
||
);
|
||
}
|
||
return btoa(chunks.join(""));
|
||
}
|
||
function unhex(hex2) {
|
||
if (hex2.length % 2 !== 0) {
|
||
throw new Error("Invalid hex string");
|
||
}
|
||
const bytes = new Uint8Array(hex2.length / 2);
|
||
for (let i2 = 0; i2 < hex2.length; i2 += 2) {
|
||
const byte = parseInt(hex2.slice(i2, i2 + 2), 16);
|
||
if (Number.isNaN(byte)) {
|
||
throw new Error("Invalid hex string");
|
||
}
|
||
bytes[i2 / 2] = byte;
|
||
}
|
||
return bytes.buffer;
|
||
}
|
||
function hex(buffer) {
|
||
const bytes = new Uint8Array(buffer);
|
||
let hexString = "";
|
||
for (let i2 = 0; i2 < bytes.length; i2++) {
|
||
hexString += bytes[i2].toString(16).padStart(2, "0");
|
||
}
|
||
return hexString;
|
||
}
|
||
function pkcs7Pad(data, blockSize = 16) {
|
||
const padLength = blockSize - data.length % blockSize;
|
||
const padded = new Uint8Array(data.length + padLength);
|
||
padded.set(data);
|
||
padded.fill(padLength, data.length);
|
||
return padded;
|
||
}
|
||
function randomString(length) {
|
||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||
let result = "";
|
||
const array = new Uint8Array(length);
|
||
getCrypto().getRandomValues(array);
|
||
for (let i2 = 0; i2 < length; i2++) {
|
||
result += chars.charAt(array[i2] % chars.length);
|
||
}
|
||
return result;
|
||
}
|
||
function toBytes(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) {
|
||
throw new Error("Invalid encrypted chapter");
|
||
}
|
||
const buf = b64decode(encrypted);
|
||
const iv = buf.slice(0, 16);
|
||
const data = buf.slice(16);
|
||
const key = (_a = config.key_info) == null ? void 0 : _a.key;
|
||
if (!key) {
|
||
throw new Error("Missing decrypt key");
|
||
}
|
||
const subtle = getSubtle();
|
||
const cryptoKey = await subtle.importKey(
|
||
"raw",
|
||
key,
|
||
{ name: "AES-CBC" },
|
||
false,
|
||
["decrypt"]
|
||
);
|
||
return subtle.decrypt(
|
||
{ name: "AES-CBC", iv },
|
||
cryptoKey,
|
||
data
|
||
).then((decrypted) => {
|
||
const decoder = new TextDecoder();
|
||
const plain = decoder.decode(decrypted);
|
||
if (plain.trim().startsWith("<")) {
|
||
return plain;
|
||
}
|
||
try {
|
||
return JSON.parse(plain);
|
||
} catch (e) {
|
||
console.warn("Invalid chapter content: ", plain, e);
|
||
return void 0;
|
||
}
|
||
});
|
||
}
|
||
async function decryptComicImage(image, key) {
|
||
const subtle = getSubtle();
|
||
const cryptoKey = await subtle.importKey(
|
||
"raw",
|
||
unhex(key),
|
||
{ name: "AES-GCM" },
|
||
false,
|
||
["decrypt"]
|
||
);
|
||
const iv = image.slice(0, 12);
|
||
const data = image.slice(12);
|
||
return await subtle.decrypt(
|
||
{ name: "AES-GCM", iv },
|
||
cryptoKey,
|
||
data
|
||
);
|
||
}
|
||
function reverseHex(value) {
|
||
const be = BigInt(value).toString(16).padStart(32, "0");
|
||
let result = "";
|
||
for (let i2 = be.length; i2 > 0; i2 -= 2) result += be.slice(i2 - 2, i2);
|
||
return result;
|
||
}
|
||
async function encryptKeyinfoBody(config) {
|
||
const deviceId = config.device_id;
|
||
const iv = new TextEncoder().encode(randomString(16));
|
||
const data = new Uint8Array(unhex(reverseHex(deviceId))).slice(0, 8);
|
||
console.log(data);
|
||
const subtle = getSubtle();
|
||
const k = await subtle.importKey(
|
||
"raw",
|
||
shared_key,
|
||
{ name: "AES-CBC" },
|
||
false,
|
||
["encrypt"]
|
||
);
|
||
const encrypted = await subtle.encrypt(
|
||
{ name: "AES-CBC", iv },
|
||
k,
|
||
data
|
||
);
|
||
const final = new Uint8Array(iv.length + encrypted.byteLength);
|
||
console.log(final);
|
||
final.set(iv, 0);
|
||
final.set(new Uint8Array(encrypted), iv.length);
|
||
return JSON.stringify({
|
||
content: b64encode(final.buffer)
|
||
});
|
||
}
|
||
async function decryptKeyinfoResponse(encrypted) {
|
||
const buf = b64decode(encrypted);
|
||
const iv = buf.slice(0, 16);
|
||
const data = buf.slice(16);
|
||
const subtle = getSubtle();
|
||
const k = await subtle.importKey(
|
||
"raw",
|
||
shared_key,
|
||
{ name: "AES-CBC" },
|
||
false,
|
||
["decrypt"]
|
||
);
|
||
return subtle.decrypt(
|
||
{ name: "AES-CBC", iv },
|
||
k,
|
||
data
|
||
);
|
||
}
|
||
async function refreshKeyinfo() {
|
||
var _a, _b, _c;
|
||
const b = await encryptKeyinfoBody(_config.currentConfig);
|
||
const res = await appPost("/crypt/registerkey", b);
|
||
const j = res.json();
|
||
const ek = (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.key;
|
||
if (!ek) {
|
||
throw new Error(`Failed to get key info: ${res.responseText}`);
|
||
}
|
||
const key = await decryptKeyinfoResponse(ek);
|
||
const keyinfo = {
|
||
key,
|
||
keyver: (_b = j == null ? void 0 : j.data) == null ? void 0 : _b.keyver
|
||
};
|
||
console.log("Refreshed key info:", keyinfo);
|
||
_config.currentConfig.key_info = keyinfo;
|
||
write("keyinfo", {
|
||
key: b64encode(key),
|
||
keyver: (_c = j == null ? void 0 : j.data) == null ? void 0 : _c.keyver
|
||
});
|
||
}
|
||
async function ensureKeyinfo(expectedKeyVersion) {
|
||
const keyinfo = _config.currentConfig.key_info;
|
||
const cachedKeyInfo = read("keyinfo");
|
||
console.log("cached key info: ", cachedKeyInfo);
|
||
if (cachedKeyInfo) {
|
||
const cki = {
|
||
key: b64decode(cachedKeyInfo.key),
|
||
keyver: cachedKeyInfo.keyver
|
||
};
|
||
if (typeof expectedKeyVersion === "undefined" || cki.keyver === expectedKeyVersion) {
|
||
_config.currentConfig.key_info = cki;
|
||
return;
|
||
}
|
||
}
|
||
if (!keyinfo) {
|
||
return await refreshKeyinfo();
|
||
}
|
||
if ((keyinfo == null ? void 0 : keyinfo.keyver) !== expectedKeyVersion) {
|
||
return await refreshKeyinfo();
|
||
}
|
||
}
|
||
async function getChapter(itemId, _retry) {
|
||
var _a, _b;
|
||
if (typeof _retry === "undefined") _retry = 0;
|
||
if (_retry > 5) {
|
||
throw new Error(`Failed to get chapter: ${itemId}`);
|
||
}
|
||
if (!_config.currentConfig.key_info) {
|
||
await ensureKeyinfo();
|
||
}
|
||
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: ", 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)) {
|
||
console.warn("Key reg expired, regster again and retrying...");
|
||
await ensureKeyinfo(parseInt(j == null ? void 0 : j.key_version));
|
||
return await getChapter(itemId, _retry + 1);
|
||
}
|
||
j.content = await decryptChapter(j == null ? void 0 : j.content, _config.currentConfig);
|
||
return j;
|
||
}
|
||
async function getCatalogRaw(bookId) {
|
||
var _a;
|
||
const response = await appGet("/bookapi/directory/all_items/v", { book_id: bookId });
|
||
const j = response.json();
|
||
const items = (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.item_data_list;
|
||
if ((j == null ? void 0 : j.code) !== 0 || !Array.isArray(items) || items.length === 0) {
|
||
throw new Error("Empty catalog");
|
||
}
|
||
return [items, items.map((it) => String(it.item_id))];
|
||
}
|
||
async function getCatalog(bookId) {
|
||
const r = await getCatalogRaw(bookId);
|
||
const catalogRaw = r[0];
|
||
const allItemIds = r[1];
|
||
const vmap = {};
|
||
const chapters = [];
|
||
catalogRaw.forEach((item) => {
|
||
const volumeName = item.volume_name ?? "";
|
||
const chapterItem = {
|
||
item_id: String(item.item_id),
|
||
title: item.title,
|
||
// YYYY-MM-DD HH:mm:ss
|
||
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[volumeName]) {
|
||
vmap[volumeName] = {
|
||
title: volumeName,
|
||
book_id: bookId,
|
||
chapter_list: []
|
||
};
|
||
}
|
||
vmap[volumeName].chapter_list.push(chapterItem);
|
||
});
|
||
return {
|
||
book_id: bookId,
|
||
volume_list: Object.values(vmap),
|
||
chapter_list: chapters,
|
||
all_item_ids: allItemIds
|
||
};
|
||
}
|
||
function mappingCreationStatus(status) {
|
||
switch (status) {
|
||
case "0":
|
||
return "完结";
|
||
case "1":
|
||
return "连载";
|
||
case "4":
|
||
return "断更";
|
||
default:
|
||
return "未知";
|
||
}
|
||
}
|
||
async function getBookInfoRaw(bookId) {
|
||
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];
|
||
return null;
|
||
}
|
||
async function getBookInfo(bookId) {
|
||
const bookInfo = await getBookInfoRaw(bookId);
|
||
if (!bookInfo) {
|
||
throw new Error("Book not found");
|
||
}
|
||
return {
|
||
book_id: bookInfo.book_id,
|
||
title: bookInfo.book_name || bookInfo.original_book_name,
|
||
author: bookInfo.author,
|
||
cover_url: bookInfo.thumb_url,
|
||
summary: bookInfo.abstract,
|
||
// volume_list: bookInfo.volume_list,
|
||
update_time: moment(bookInfo.last_chapter_first_pass_time * 1e3).format("YYYY-MM-DD HH:mm:ss"),
|
||
status: mappingCreationStatus(bookInfo.creation_status)
|
||
// chapter_count: bookInfo.chapter_count,
|
||
};
|
||
}
|
||
async function getBookInfoAndCatalog(book) {
|
||
if (typeof book !== "string") {
|
||
book = book.book_id;
|
||
}
|
||
const bookInfo = await getBookInfo(book);
|
||
if (!bookInfo) {
|
||
throw new Error("Book not found");
|
||
}
|
||
const catalog = await getCatalog(bookInfo.book_id);
|
||
console.log("Catalog:", catalog);
|
||
bookInfo.volume_list = catalog.volume_list;
|
||
bookInfo.chapter_list = catalog.chapter_list;
|
||
return bookInfo;
|
||
}
|
||
const CDN_PREFIX = "https://p3-novel.byteimg.com/origin/";
|
||
const cssCache = /* @__PURE__ */ new Map();
|
||
function stripComments(css) {
|
||
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||
}
|
||
function scopeSelector(selector, scope) {
|
||
const s = selector.trim();
|
||
if (!s) return "";
|
||
const where = `:where(${scope})`;
|
||
if (/^(body|html|:root)$/i.test(s)) return where;
|
||
const m = s.match(/^(body|html)\b([\s\S]*)$/i);
|
||
if (m) return `${where}${m[2]}`;
|
||
return `${where} ${s}`;
|
||
}
|
||
function stripRootDecls(body) {
|
||
return body.split(";").filter((decl) => !/^\s*(color|background|background-color|font-size)\s*:/i.test(decl)).join(";");
|
||
}
|
||
function scopeCss(css, scope) {
|
||
const src = stripComments(css);
|
||
let out = "";
|
||
let buf = "";
|
||
let i2 = 0;
|
||
while (i2 < src.length) {
|
||
const ch = src[i2];
|
||
if (ch === "{") {
|
||
const prelude = buf.trim();
|
||
buf = "";
|
||
i2++;
|
||
if (prelude.startsWith("@")) {
|
||
if (/^@(media|supports|document)\b/i.test(prelude)) {
|
||
const inner2 = readBlock(src, i2);
|
||
out += `${prelude}{${scopeCss(inner2.text, scope)}}`;
|
||
i2 = inner2.end;
|
||
} else {
|
||
const inner2 = readBlock(src, i2);
|
||
out += `${prelude}{${inner2.text}}`;
|
||
i2 = inner2.end;
|
||
}
|
||
continue;
|
||
}
|
||
const inner = readBlock(src, i2);
|
||
const selectors = prelude.split(",").map((s) => scopeSelector(s, scope)).filter(Boolean);
|
||
const isRoot = selectors.length === 1 && selectors[0] === `:where(${scope})`;
|
||
const declarations = isRoot ? stripRootDecls(inner.text) : inner.text;
|
||
if (selectors.length && declarations.trim()) {
|
||
out += `${selectors.join(",")}{${declarations}}`;
|
||
}
|
||
i2 = inner.end;
|
||
continue;
|
||
}
|
||
if (ch === ";" && buf.trim().startsWith("@")) {
|
||
buf = "";
|
||
i2++;
|
||
continue;
|
||
}
|
||
buf += ch;
|
||
i2++;
|
||
}
|
||
return out;
|
||
}
|
||
function readBlock(src, start) {
|
||
let depth = 1;
|
||
let i2 = start;
|
||
while (i2 < src.length && depth > 0) {
|
||
const c = src[i2];
|
||
if (c === "{") depth++;
|
||
else if (c === "}") depth--;
|
||
if (depth === 0) break;
|
||
i2++;
|
||
}
|
||
return { text: src.slice(start, i2), end: Math.min(i2 + 1, src.length) };
|
||
}
|
||
function parseCssMap(cssMap) {
|
||
if (!cssMap || typeof cssMap !== "string") return {};
|
||
try {
|
||
const parsed = JSON.parse(cssMap);
|
||
return parsed && typeof parsed === "object" ? parsed : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
async function fetchCss(uri) {
|
||
const cached = cssCache.get(uri);
|
||
if (cached !== void 0) return cached;
|
||
try {
|
||
const res = await fetch(CDN_PREFIX + uri);
|
||
const text = res.ok ? await res.text() : "";
|
||
cssCache.set(uri, text);
|
||
return text;
|
||
} catch (e) {
|
||
console.warn("获取书籍样式表失败:", uri, e);
|
||
cssCache.set(uri, "");
|
||
return "";
|
||
}
|
||
}
|
||
async function getScopedBookCss(cssMap, scope) {
|
||
const map = parseCssMap(cssMap);
|
||
const uris = Object.values(map).filter(Boolean);
|
||
if (uris.length === 0) return "";
|
||
const sheets = await Promise.all(uris.map(fetchCss));
|
||
return sheets.filter(Boolean).map((css) => scopeCss(css, scope)).join("\n");
|
||
}
|
||
async function applyBookCss(cssMap, scope, styleId = "fqa-book-style") {
|
||
const css = await getScopedBookCss(cssMap, scope);
|
||
let el = document.getElementById(styleId);
|
||
if (!css) {
|
||
el == null ? void 0 : el.remove();
|
||
return false;
|
||
}
|
||
if (!el) {
|
||
el = document.createElement("style");
|
||
el.id = styleId;
|
||
document.head.appendChild(el);
|
||
}
|
||
el.textContent = css;
|
||
return true;
|
||
}
|
||
const FOOTNOTE_CLASS = "fqa-footnote";
|
||
const FOOTNOTE_REF_CLASS = "fqa-footnote-ref";
|
||
const FOOTNOTE_LIST_CLASS = "fqa-footnote-list";
|
||
function byAttr(root, tag, attr, value) {
|
||
return [...root.querySelectorAll(tag)].filter((el) => {
|
||
const v = el.getAttribute(attr);
|
||
if (v === null) return false;
|
||
return value === void 0 || v === value;
|
||
});
|
||
}
|
||
function processFootnotes(root) {
|
||
const notes = /* @__PURE__ */ new Map();
|
||
const sections = byAttr(root, "section", "epub:type", "footnotes");
|
||
for (const section of sections) {
|
||
for (const aside of section.querySelectorAll("aside")) {
|
||
const id = aside.getAttribute("id");
|
||
if (id) notes.set(id, aside.innerHTML.trim());
|
||
}
|
||
}
|
||
if (notes.size === 0) {
|
||
for (const aside of byAttr(root, "aside", "epub:type", "footnote")) {
|
||
const id = aside.getAttribute("id");
|
||
if (id) notes.set(id, aside.innerHTML.trim());
|
||
}
|
||
}
|
||
const collectRefs = (scope) => {
|
||
const set = new Set(byAttr(scope, "a", "epub:type", "noteref"));
|
||
for (const img of scope.querySelectorAll("img.bdFootnote")) {
|
||
const a = img.closest('a[href^="#"]');
|
||
if (a && scope.contains(a)) set.add(a);
|
||
}
|
||
return [...scope.querySelectorAll("a")].filter((a) => set.has(a));
|
||
};
|
||
const inSection = (el) => sections.some((s) => s.contains(el));
|
||
const refs = collectRefs(root).filter((a) => !inSection(a));
|
||
if (refs.length === 0 && notes.size === 0) return 0;
|
||
const ordered = [];
|
||
const numberOf = /* @__PURE__ */ new Map();
|
||
let counter = 0;
|
||
const makeSup = (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, _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");
|
||
return;
|
||
}
|
||
latestItemId = itemId;
|
||
const chapter = await getChapter(itemId);
|
||
if (!chapter) {
|
||
console.warn("No chapter found for item_id:", itemId);
|
||
return;
|
||
}
|
||
if (latestItemId !== itemId) {
|
||
console.debug("Stale chapter response discarded:", itemId);
|
||
return;
|
||
}
|
||
console.log("Chapter:", chapter);
|
||
const pageState = unsafeWindow.__INITIAL_STATE__;
|
||
const chapterTitle = ((_b = chapter.novel_data) == null ? void 0 : _b.title) || ((_d = (_c = pageState == null ? void 0 : pageState.reader) == null ? void 0 : _c.chapterData) == null ? void 0 : _d.title);
|
||
if (typeof chapter.content === "string") {
|
||
void applyBookCss((_e = chapter.novel_data) == null ? void 0 : _e.css_map, "#fqa-reader-content");
|
||
const dp = new DOMParser();
|
||
const doc = dp.parseFromString(chapter.content, "text/html");
|
||
const body = doc.body;
|
||
body.querySelectorAll('link[rel="stylesheet"]').forEach((el) => el.remove());
|
||
let article = body.querySelector("article");
|
||
let toProcess = article || body;
|
||
processFootnotes(toProcess);
|
||
for (let i2 = 0; i2 < toProcess.childNodes.length; i2++) {
|
||
if (i2 < 2 && ((_g = (_f = toProcess.childNodes[i2]) == null ? void 0 : _f.innerHTML) == null ? void 0 : _g.includes(chapterTitle))) {
|
||
toProcess.removeChild(toProcess.childNodes[i2]);
|
||
break;
|
||
}
|
||
}
|
||
if (!article) {
|
||
article = document.createElement("article");
|
||
article.innerHTML = toProcess.innerHTML;
|
||
toProcess = article;
|
||
}
|
||
const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)");
|
||
if (readerContainer) {
|
||
let scriptContainer = document.getElementById("fqa-reader-content");
|
||
if (!scriptContainer) {
|
||
scriptContainer = cloneElement(readerContainer);
|
||
scriptContainer.id = "fqa-reader-content";
|
||
scriptContainer.classList.add("fqa");
|
||
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 i2 = 0; i2 < chapter.content.picInfos.length; i2++) {
|
||
const picInfo = chapter.content.picInfos[i2];
|
||
const img = document.createElement("img");
|
||
img.className = "fqa-comic-img fqa-comic-encrypted";
|
||
img.alt = `第${i2 + 1}页`;
|
||
img.dataset.encryptedUrl = picInfo.picUrl;
|
||
img.dataset.encryptKey = chapter.content.encrypt_key;
|
||
img.dataset.pageIndex = i2.toString();
|
||
img.style.minHeight = "500px";
|
||
img.style.backgroundColor = "#f0f0f0";
|
||
imgs.push(img);
|
||
}
|
||
const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)");
|
||
if (readerContainer) {
|
||
let scriptContainer = document.getElementById("fqa-comic-content");
|
||
if (!scriptContainer) {
|
||
scriptContainer = cloneElement(readerContainer);
|
||
scriptContainer.id = "fqa-reader-content";
|
||
scriptContainer.classList.add("fqa");
|
||
scriptContainer.classList.add("fqa-comic-reader");
|
||
if (settings.allowCopy) scriptContainer.classList.remove("noselect");
|
||
readerContainer.insertAdjacentElement("beforebegin", scriptContainer);
|
||
}
|
||
scriptContainer.innerHTML = "";
|
||
readerContainer.classList.add("fqa-hide");
|
||
imgs.forEach((img) => scriptContainer.appendChild(img));
|
||
const observer2 = new IntersectionObserver(
|
||
async (entries) => {
|
||
for (const entry of entries) {
|
||
if (entry.isIntersecting) {
|
||
const img = entry.target;
|
||
if (img.dataset.encryptedUrl && img.dataset.encryptKey && !img.src) {
|
||
observer2.unobserve(img);
|
||
try {
|
||
const response = await fetch$1(img.dataset.encryptedUrl);
|
||
const encryptedBuffer = await response.arrayBuffer();
|
||
const decryptedBuffer = await decryptComicImage(
|
||
encryptedBuffer,
|
||
img.dataset.encryptKey
|
||
);
|
||
const blob = new Blob([decryptedBuffer], { type: "image/jpeg" });
|
||
const blobUrl = URL.createObjectURL(blob);
|
||
img.src = blobUrl;
|
||
img.style.minHeight = "";
|
||
img.style.backgroundColor = "";
|
||
img.onload = () => {
|
||
URL.revokeObjectURL(blobUrl);
|
||
};
|
||
} catch (error) {
|
||
console.error(`解密图片失败 (页 ${img.dataset.pageIndex}):`, error);
|
||
img.alt = `第${Number(img.dataset.pageIndex) + 1}页 - 解密失败`;
|
||
img.style.backgroundColor = "#ffebee";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
{
|
||
rootMargin: "200px"
|
||
}
|
||
);
|
||
imgs.forEach((img) => observer2.observe(img));
|
||
}
|
||
} else {
|
||
const imgs = [];
|
||
for (let i2 = 0; i2 < chapter.content.picInfos.length; i2++) {
|
||
const picInfo = chapter.content.picInfos[i2];
|
||
const img = document.createElement("img");
|
||
img.className = "fqa-comic-img";
|
||
img.alt = `第${i2 + 1}页`;
|
||
img.src = picInfo.picUrl;
|
||
imgs.push(img);
|
||
}
|
||
const readerContainer = document.querySelector("div.muye-reader-content:not(.fqa)");
|
||
if (readerContainer) {
|
||
let scriptContainer = document.getElementById("fqa-comic-content");
|
||
if (!scriptContainer) {
|
||
scriptContainer = cloneElement(readerContainer);
|
||
scriptContainer.id = "fqa-reader-content";
|
||
scriptContainer.classList.add("fqa");
|
||
scriptContainer.classList.add("fqa-comic-reader");
|
||
if (settings.allowCopy) scriptContainer.classList.remove("noselect");
|
||
readerContainer.insertAdjacentElement("beforebegin", scriptContainer);
|
||
}
|
||
scriptContainer.innerHTML = "";
|
||
readerContainer.classList.add("fqa-hide");
|
||
imgs.forEach((img) => scriptContainer.appendChild(img));
|
||
}
|
||
}
|
||
}
|
||
const muyeReaderTitle = document.querySelector("h1.muye-reader-title");
|
||
let muyeReaderSubtitle = document.querySelector("div.muye-reader-subtitle");
|
||
(_h = document.querySelector("#fqa-subtitle")) == null ? void 0 : _h.remove();
|
||
if (muyeReaderSubtitle) {
|
||
let _cloned = cloneElement(muyeReaderSubtitle);
|
||
muyeReaderSubtitle.classList.add("fqa-hide");
|
||
_cloned.id = "fqa-subtitle";
|
||
muyeReaderSubtitle.insertAdjacentElement("afterend", _cloned);
|
||
muyeReaderSubtitle = _cloned;
|
||
_cloned.classList.remove("fqa-hide");
|
||
console.log("clone subtitle: ", _cloned);
|
||
}
|
||
if (muyeReaderTitle) {
|
||
muyeReaderTitle.textContent = chapterTitle;
|
||
}
|
||
console.log("Current book:", currentBook);
|
||
if (!currentBook || currentBook == null || currentBook.book_id !== ((_i = chapter.novel_data) == null ? void 0 : _i.book_id)) {
|
||
currentBook = await getBookInfoAndCatalog((_j = chapter.novel_data) == null ? void 0 : _j.book_id);
|
||
console.log("Current book:", currentBook);
|
||
}
|
||
if (currentBook && currentBook.chapter_list) {
|
||
const currentChapterItem = currentBook.chapter_list.find((c) => c.item_id === itemId);
|
||
if (currentChapterItem) {
|
||
console.log("Current chapter:", currentChapterItem);
|
||
document.title = currentChapterItem.title + " - " + currentBook.title + " - 番茄小说";
|
||
if (document.getElementById("fqa-current-chapter-volume")) {
|
||
const c = document.getElementById("fqa-current-chapter-volume");
|
||
if (c) {
|
||
c.textContent = currentChapterItem.volume_title;
|
||
}
|
||
} else {
|
||
const volSpan = document.createElement("span");
|
||
volSpan.className = "desc-item";
|
||
volSpan.id = "fqa-current-chapter-volume";
|
||
volSpan.textContent = currentChapterItem.volume_title;
|
||
const c = muyeReaderSubtitle == null ? void 0 : muyeReaderSubtitle.firstChild;
|
||
if (c) {
|
||
c.insertAdjacentElement("beforebegin", volSpan);
|
||
}
|
||
}
|
||
let updateTimeSpans = (muyeReaderSubtitle == null ? void 0 : muyeReaderSubtitle.querySelectorAll("span.desc-item")) || [];
|
||
if (updateTimeSpans.length >= 2) {
|
||
let updateTimeSpan = updateTimeSpans[updateTimeSpans.length - 1];
|
||
let uttspan = updateTimeSpan.firstChild;
|
||
uttspan == null ? void 0 : uttspan.remove();
|
||
updateTimeSpan.innerHTML = "更新时间:" + currentChapterItem.update_time;
|
||
} else {
|
||
let updateTimeSpan = document.createElement("span");
|
||
updateTimeSpan.className = "desc-item";
|
||
updateTimeSpan.textContent = `更新时间:${currentChapterItem.update_time}`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
async function onUrlChange$1(_previous) {
|
||
await insertContent();
|
||
}
|
||
async function onHashChange$1(_previous) {
|
||
}
|
||
async function onLoad$1() {
|
||
document.querySelector("div.muye-reader-btns");
|
||
await insertContent();
|
||
}
|
||
function readerFilter(path, _query, _hash) {
|
||
return path.startsWith("/reader") || path.startsWith("reader");
|
||
}
|
||
const _exports$3 = [
|
||
{
|
||
id: "readerHook_load",
|
||
event: "load",
|
||
handler: onLoad$1,
|
||
filter: readerFilter
|
||
},
|
||
{
|
||
id: "readerHook_urlChange",
|
||
event: "onUrlChange",
|
||
handler: onUrlChange$1,
|
||
filter: readerFilter
|
||
},
|
||
{
|
||
id: "readerHook_hashChange",
|
||
event: "onHashChange",
|
||
handler: onHashChange$1,
|
||
filter: readerFilter
|
||
}
|
||
];
|
||
const blackList = [
|
||
"mcs.zijieapi.com",
|
||
"vcs.zijieapi.com/vc/setting",
|
||
"mon.zijieapi.com",
|
||
"mssdk.bytedance.com/web/common",
|
||
"hm.baidu.com"
|
||
];
|
||
const BLOCKED_BODY = JSON.stringify({
|
||
e: 0,
|
||
sc: 10,
|
||
tc: 10
|
||
});
|
||
function checkBlack(url) {
|
||
if (!settings.blockReport) return false;
|
||
return blackList.some((black) => url.includes(black));
|
||
}
|
||
const originalFetch = unsafeWindow.fetch.bind(unsafeWindow);
|
||
unsafeWindow.fetch = function fetch2(input, init2) {
|
||
let url;
|
||
if (input instanceof Request) {
|
||
url = input.url;
|
||
} else if (input instanceof URL) {
|
||
url = input.href;
|
||
} else {
|
||
url = input;
|
||
}
|
||
if (checkBlack(url)) {
|
||
console.log("blocked request: " + url);
|
||
return Promise.resolve(new Response(BLOCKED_BODY, {
|
||
status: 200,
|
||
headers: { "Content-Type": "application/json" }
|
||
}));
|
||
}
|
||
return originalFetch(input, init2);
|
||
};
|
||
const originalXMLHttpRequest = unsafeWindow.XMLHttpRequest;
|
||
unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpRequest {
|
||
constructor() {
|
||
super(...arguments);
|
||
__publicField(this, "_blockedUrl");
|
||
}
|
||
open(method, url, async = true, user, password) {
|
||
if (checkBlack(url)) {
|
||
console.log("blocked request: " + url);
|
||
this._blockedUrl = url;
|
||
return;
|
||
}
|
||
this._blockedUrl = void 0;
|
||
super.open(method, url, async, user, password);
|
||
}
|
||
setRequestHeader(name2, value) {
|
||
if (this._blockedUrl !== void 0) return;
|
||
super.setRequestHeader(name2, value);
|
||
}
|
||
send(body) {
|
||
if (this._blockedUrl === void 0) {
|
||
super.send(body);
|
||
return;
|
||
}
|
||
const url = this._blockedUrl;
|
||
const shadow = (prop, value) => Object.defineProperty(this, prop, { configurable: true, get: () => value });
|
||
setTimeout(() => {
|
||
shadow("readyState", 4);
|
||
shadow("status", 200);
|
||
shadow("statusText", "OK");
|
||
shadow("responseURL", url);
|
||
shadow("responseText", this.responseType === "" || this.responseType === "text" ? BLOCKED_BODY : "");
|
||
shadow("response", this.responseType === "json" ? {
|
||
"e": 0,
|
||
"sc": 10,
|
||
"tc": 10
|
||
} : BLOCKED_BODY);
|
||
this.dispatchEvent(new Event("readystatechange"));
|
||
this.dispatchEvent(new ProgressEvent("load"));
|
||
this.dispatchEvent(new ProgressEvent("loadend"));
|
||
}, 0);
|
||
}
|
||
abort() {
|
||
if (this._blockedUrl !== void 0) return;
|
||
super.abort();
|
||
}
|
||
getAllResponseHeaders() {
|
||
if (this._blockedUrl !== void 0) return "content-type: application/json\r\n";
|
||
return super.getAllResponseHeaders();
|
||
}
|
||
getResponseHeader(name2) {
|
||
if (this._blockedUrl !== void 0) {
|
||
return name2.toLowerCase() === "content-type" ? "application/json" : null;
|
||
}
|
||
return super.getResponseHeader(name2);
|
||
}
|
||
};
|
||
const _exports$2 = [];
|
||
let userState = {
|
||
isLogin: false,
|
||
userInfo: null
|
||
};
|
||
if (read("userState")) {
|
||
userState = read("userState");
|
||
}
|
||
console.log("userState:", userState);
|
||
async function getDetailedUserInfo() {
|
||
if (!(userState == null ? void 0 : userState.isLogin) || !(userState == null ? void 0 : userState.userInfo)) {
|
||
return null;
|
||
}
|
||
if (userState.userInfo.gender !== void 0 && userState.userInfo.recommend_gender !== void 0 && userState.userInfo.fans_num !== void 0 && userState.userInfo.following_num !== void 0 && userState.userInfo.is_author !== void 0 && userState.userInfo.author_desc !== void 0 && userState.userInfo.read_book_num !== void 0 && userState.userInfo.read_book_time !== void 0) {
|
||
return userState.userInfo;
|
||
}
|
||
const response = await fetch$1("https://fanqienovel.com/reading/user/basic_info/get/v?aid=1967");
|
||
const j = await response.json();
|
||
if (j == null ? void 0 : j.data) {
|
||
const data = j.data;
|
||
userState.userInfo.gender = data.profile_gender;
|
||
userState.userInfo.recommend_gender = data.gender;
|
||
userState.userInfo.fans_num = data.fans_num;
|
||
userState.userInfo.following_num = data.follow_user_num;
|
||
userState.userInfo.is_author = data.is_author;
|
||
userState.userInfo.author_desc = data.author_desc;
|
||
userState.userInfo.read_book_num = data.read_book_num;
|
||
userState.userInfo.read_book_time = BigInt(data.read_book_time);
|
||
return userState.userInfo;
|
||
}
|
||
write("userState", userState);
|
||
return userState.userInfo;
|
||
}
|
||
async function checkLogin() {
|
||
var _a, _b, _c, _d, _e, _f;
|
||
const response = await fetch$1("https://fanqienovel.com/api/user/info/v2");
|
||
const j = await response.json();
|
||
const _userInfo = {
|
||
id: (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.id,
|
||
username: (_b = j == null ? void 0 : j.data) == null ? void 0 : _b.name,
|
||
avatar: (_c = j == null ? void 0 : j.data) == null ? void 0 : _c.avatar,
|
||
desc: (_d = j == null ? void 0 : j.data) == null ? void 0 : _d.desc,
|
||
age: (_e = j == null ? void 0 : j.data) == null ? void 0 : _e.age
|
||
};
|
||
if (((_f = j == null ? void 0 : j.data) == null ? void 0 : _f.id) > 1) {
|
||
userState.isLogin = true;
|
||
userState.userInfo = _userInfo;
|
||
write("userState", userState);
|
||
return true;
|
||
} else {
|
||
del("userState");
|
||
return false;
|
||
}
|
||
}
|
||
async function init() {
|
||
var _a;
|
||
await checkLogin();
|
||
if (userState.isLogin) {
|
||
console.log("Hello, ", (_a = userState == null ? void 0 : userState.userInfo) == null ? void 0 : _a.username);
|
||
}
|
||
}
|
||
const bookshelf = '<svg xmlns="http://www.w3.org/2000/svg"\r\n width="24"\r\n height="24"\r\n viewBox="0 0 24 24"\r\n fill="none"\r\n stroke="currentColor"\r\n stroke-width="1.2"\r\n stroke-linecap="round"\r\n stroke-linejoin="round">\r\n <path d="M3.5 20h17"/>\r\n <rect x="5" y="7" width="3.5" height="13" rx="0.8"/>\r\n <rect x="8.5" y="5" width="4" height="15" rx="0.8"/>\r\n <path d="M15.1 6.2 18 5.5l3.1 13.6-2.9.7z"/>\r\n <path d="M9.8 8h1.4M6.1 10h1.3M17 8.8l1.3-.3"/>\r\n</svg>';
|
||
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;
|
||
minutes = minutes % 60n;
|
||
return `${hours} 时 ${minutes} 分`;
|
||
}
|
||
function createMenuItem(text, icon, onclick) {
|
||
const item = document.createElement("div");
|
||
item.role = "menuitem";
|
||
item.classList.add("fqa-menu-item", "arco-menu-item", "serial-menu-item", "slogin-user-avatar__menu-item-wrapper");
|
||
const inner1 = document.createElement("div");
|
||
inner1.classList.add("slogin-user-avatar__menu-item");
|
||
if (icon) {
|
||
const iconDiv = document.createElement("div");
|
||
iconDiv.classList.add("slogin-user-avatar__menu-item__icon");
|
||
if (document.querySelector("div.muye-reader-dark")) {
|
||
console.log("isdark");
|
||
iconDiv.classList.add("fqa-icon-dark");
|
||
} else {
|
||
console.log("islight");
|
||
}
|
||
iconDiv.innerHTML = icon;
|
||
inner1.appendChild(iconDiv);
|
||
}
|
||
const inner2 = document.createElement("div");
|
||
inner2.classList.add("slogin-user-avatar__menu-item__content");
|
||
inner2.textContent = text;
|
||
inner1.appendChild(inner2);
|
||
item.appendChild(inner1);
|
||
if (onclick) {
|
||
item.addEventListener("click", onclick);
|
||
}
|
||
return item;
|
||
}
|
||
async function mainHook$1(_previous) {
|
||
const userInfo = await getDetailedUserInfo();
|
||
if (!userInfo) {
|
||
return;
|
||
}
|
||
const injected = /* @__PURE__ */ new WeakSet();
|
||
const inject2 = (menuInner) => {
|
||
if (injected.has(menuInner)) {
|
||
return;
|
||
}
|
||
injected.add(menuInner);
|
||
console.log("user action dialog created", menuInner);
|
||
const firstDiv = createMenuItem(`阅读 ${userInfo.read_book_num} 本书`);
|
||
menuInner.insertAdjacentElement("afterbegin", firstDiv);
|
||
const secondDiv = createMenuItem(formatReadingTime(userInfo.read_book_time ?? 0n));
|
||
firstDiv.insertAdjacentElement("afterend", secondDiv);
|
||
const thirdDiv = createMenuItem("我的书架", bookshelf, () => {
|
||
window.open("https://fanqienovel.com/bookshelf", "_blank");
|
||
});
|
||
secondDiv.insertAdjacentElement("afterend", thirdDiv);
|
||
menuInner.appendChild(createMenuItem("助手设置", void 0, openSettings));
|
||
};
|
||
const scan = (root) => {
|
||
if (root.classList.contains("arco-menu-inner")) {
|
||
inject2(root);
|
||
}
|
||
root.querySelectorAll(".arco-menu-inner").forEach(inject2);
|
||
};
|
||
const observer2 = new MutationObserver((mutationsList) => {
|
||
mutationsList.forEach((mutation) => {
|
||
if (mutation.type === "childList") {
|
||
mutation.addedNodes.forEach((node) => {
|
||
if (node instanceof HTMLElement) {
|
||
scan(node);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
});
|
||
observer2.observe(document.body, { childList: true, subtree: true });
|
||
scan(document.body);
|
||
}
|
||
async function guestHook(_previous) {
|
||
const injected = /* @__PURE__ */ new WeakSet();
|
||
const inject2 = (registerBtn) => {
|
||
const parent = registerBtn.parentElement;
|
||
if (!parent || injected.has(parent)) return;
|
||
if (parent.querySelector(".fqa-settings-entry")) return;
|
||
injected.add(parent);
|
||
const entry = cloneElement(registerBtn);
|
||
entry.classList.add("fqa-settings-entry");
|
||
entry.textContent = "助手设置";
|
||
entry.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
openSettings();
|
||
});
|
||
registerBtn.insertAdjacentElement("afterend", entry);
|
||
};
|
||
const scan = (root) => {
|
||
root.querySelectorAll(".slogin-user-avatar__buttons__item").forEach((el) => {
|
||
var _a;
|
||
if (el.classList.contains("fqa-settings-entry")) return;
|
||
if (((_a = el.textContent) == null ? void 0 : _a.trim()) === "注册") inject2(el);
|
||
});
|
||
};
|
||
const observer2 = new MutationObserver((mutations) => {
|
||
for (const mutation of mutations) {
|
||
for (const node of mutation.addedNodes) {
|
||
if (node instanceof HTMLElement) scan(node);
|
||
}
|
||
}
|
||
});
|
||
observer2.observe(document.body, { childList: true, subtree: true });
|
||
scan(document);
|
||
}
|
||
function filter$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$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$4 = /* @__PURE__ */ vue.defineComponent({
|
||
__name: "BookCard",
|
||
props: {
|
||
entry: {}
|
||
},
|
||
emits: ["hover", "leave", "open", "visible", "contextmenu"],
|
||
setup(__props, { emit: __emit }) {
|
||
const props = __props;
|
||
const emit = __emit;
|
||
const detail = vue.computed(() => props.entry.detail);
|
||
const title = vue.computed(() => {
|
||
var _a;
|
||
return ((_a = detail.value) == null ? void 0 : _a.title) ?? "";
|
||
});
|
||
const tag = vue.computed(() => {
|
||
const d = detail.value;
|
||
if (!d) return null;
|
||
if (d.update_status === "1") return { text: "更新", cls: "" };
|
||
if (d.status === "4") return { text: "断更", cls: "fqa-cover-tag-gray" };
|
||
if (d.status === "0") return { text: "完结", cls: "fqa-cover-tag-gray" };
|
||
if (d.status === "1") return { text: "连载", cls: "fqa-cover-tag-gray" };
|
||
return null;
|
||
});
|
||
const progressText = vue.computed(() => {
|
||
const d = detail.value;
|
||
if (!d) return "";
|
||
const total = d.total_chapter_count || 0;
|
||
const order = Math.max(0, Math.min(d.current_chapter_order || 0, total));
|
||
if (!order) return total ? `未读 · 共${total}章` : "未读";
|
||
return `${order}章/${total}章`;
|
||
});
|
||
const progressPercent = vue.computed(() => {
|
||
const d = detail.value;
|
||
if (!d || !d.total_chapter_count) return 0;
|
||
const ratio = (d.current_chapter_order || 0) / d.total_chapter_count;
|
||
return Math.max(0, Math.min(1, ratio)) * 100;
|
||
});
|
||
function 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(
|
||
() => {
|
||
var _a;
|
||
return (_a = detail.value) == null ? void 0 : _a.cover_url;
|
||
},
|
||
() => {
|
||
imgLoaded.value = false;
|
||
}
|
||
);
|
||
const cardRef = vue.ref(null);
|
||
let io = null;
|
||
vue.onMounted(() => {
|
||
if (typeof IntersectionObserver === "undefined") {
|
||
emit("visible", props.entry);
|
||
return;
|
||
}
|
||
io = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries.some((e) => e.isIntersecting)) {
|
||
emit("visible", props.entry);
|
||
}
|
||
},
|
||
{ rootMargin: "200px" }
|
||
);
|
||
if (cardRef.value) io.observe(cardRef.value);
|
||
});
|
||
vue.onBeforeUnmount(() => {
|
||
io == null ? void 0 : io.disconnect();
|
||
io = null;
|
||
});
|
||
return (_ctx, _cache) => {
|
||
return vue.openBlock(), vue.createElementBlock("div", {
|
||
ref_key: "cardRef",
|
||
ref: cardRef,
|
||
class: "fqa-card",
|
||
role: "link",
|
||
tabindex: "0",
|
||
"aria-label": title.value,
|
||
onMouseenter: 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"])),
|
||
_cache[5] || (_cache[5] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.entry), ["prevent"]), ["space"]))
|
||
]
|
||
}, [
|
||
!detail.value ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, [
|
||
_cache[6] || (_cache[6] = vue.createElementVNode("div", { class: "fqa-sk-cover fqa-sk-anim" }, null, -1)),
|
||
_cache[7] || (_cache[7] = vue.createElementVNode("div", {
|
||
class: "fqa-sk-line fqa-sk-anim",
|
||
style: { "width": "90%" }
|
||
}, null, -1)),
|
||
_cache[8] || (_cache[8] = vue.createElementVNode("div", {
|
||
class: "fqa-sk-line fqa-sk-anim",
|
||
style: { "width": "55%" }
|
||
}, null, -1))
|
||
], 64)) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 1 }, [
|
||
vue.createElementVNode("div", _hoisted_2$4, [
|
||
vue.createElementVNode("img", {
|
||
class: vue.normalizeClass(["fqa-cover-img", { "fqa-cover-img-loading": !imgLoaded.value }]),
|
||
crossorigin: "anonymous",
|
||
loading: "lazy",
|
||
referrerpolicy: "no-referrer",
|
||
src: detail.value.cover_url,
|
||
alt: title.value,
|
||
onLoad: _cache[0] || (_cache[0] = ($event) => imgLoaded.value = true),
|
||
onError: _cache[1] || (_cache[1] = ($event) => imgLoaded.value = true)
|
||
}, null, 42, _hoisted_3$4),
|
||
tag.value ? (vue.openBlock(), vue.createElementBlock("span", {
|
||
key: 0,
|
||
class: vue.normalizeClass(["fqa-cover-tag", tag.value.cls])
|
||
}, vue.toDisplayString(tag.value.text), 3)) : vue.createCommentVNode("", true),
|
||
progressPercent.value > 0 ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_4$3, [
|
||
vue.createElementVNode("span", {
|
||
class: "fqa-cover-progress-bar",
|
||
style: vue.normalizeStyle({ width: progressPercent.value + "%" })
|
||
}, null, 4)
|
||
])) : vue.createCommentVNode("", true)
|
||
]),
|
||
vue.createElementVNode("div", {
|
||
class: "fqa-card-title",
|
||
title: title.value
|
||
}, vue.toDisplayString(title.value), 9, _hoisted_5$3),
|
||
vue.createElementVNode("div", {
|
||
class: "fqa-card-sub",
|
||
title: detail.value.current_chapter_title
|
||
}, vue.toDisplayString(progressText.value), 9, _hoisted_6$3)
|
||
], 64))
|
||
], 40, _hoisted_1$4);
|
||
};
|
||
}
|
||
});
|
||
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$3 = /* @__PURE__ */ vue.defineComponent({
|
||
__name: "BookGroupCard",
|
||
props: {
|
||
group: {}
|
||
},
|
||
emits: ["open", "visible"],
|
||
setup(__props, { emit: __emit }) {
|
||
const props = __props;
|
||
const emit = __emit;
|
||
const previewBooks = vue.computed(() => props.group.books.slice(0, 4));
|
||
const covers = vue.computed(
|
||
() => previewBooks.value.map((entry) => entry.detail).filter((detail) => !!detail)
|
||
);
|
||
const cardRef = vue.ref(null);
|
||
let io = null;
|
||
function request() {
|
||
const pending = previewBooks.value.filter((entry) => !entry.detail);
|
||
if (pending.length) emit("visible", pending);
|
||
}
|
||
vue.onMounted(() => {
|
||
if (typeof IntersectionObserver === "undefined") {
|
||
request();
|
||
return;
|
||
}
|
||
io = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries.some((e) => e.isIntersecting)) request();
|
||
},
|
||
{ rootMargin: "200px" }
|
||
);
|
||
if (cardRef.value) io.observe(cardRef.value);
|
||
});
|
||
vue.onBeforeUnmount(() => {
|
||
io == null ? void 0 : io.disconnect();
|
||
io = null;
|
||
});
|
||
return (_ctx, _cache) => {
|
||
return vue.openBlock(), vue.createElementBlock("div", {
|
||
ref_key: "cardRef",
|
||
ref: cardRef,
|
||
class: "fqa-card",
|
||
role: "button",
|
||
tabindex: "0",
|
||
"aria-label": `分组 ${__props.group.name}`,
|
||
onClick: _cache[0] || (_cache[0] = ($event) => emit("open", __props.group)),
|
||
onKeydown: [
|
||
_cache[1] || (_cache[1] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.group), ["prevent"]), ["enter"])),
|
||
_cache[2] || (_cache[2] = vue.withKeys(vue.withModifiers(($event) => emit("open", __props.group), ["prevent"]), ["space"]))
|
||
]
|
||
}, [
|
||
vue.createElementVNode("div", _hoisted_2$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,
|
||
class: "fqa-group-cell"
|
||
}, [
|
||
vue.createElementVNode("img", {
|
||
crossorigin: "anonymous",
|
||
loading: "lazy",
|
||
referrerpolicy: "no-referrer",
|
||
src: detail.cover_url,
|
||
alt: detail.title
|
||
}, null, 8, _hoisted_4$2)
|
||
]);
|
||
}), 128))
|
||
])
|
||
]),
|
||
vue.createElementVNode("div", {
|
||
class: "fqa-card-title",
|
||
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$3);
|
||
};
|
||
}
|
||
});
|
||
const SECOND = 1e3;
|
||
const MINUTE = 60 * SECOND;
|
||
const HOUR = 60 * MINUTE;
|
||
const DAY = 24 * HOUR;
|
||
const MONTH = 30 * DAY;
|
||
const YEAR = 365 * DAY;
|
||
function normalizeTimestamp(ts) {
|
||
if (!ts || ts <= 0) return 0;
|
||
return ts < 1e12 ? ts * 1e3 : ts;
|
||
}
|
||
function fromNow(ts) {
|
||
const time = normalizeTimestamp(ts);
|
||
if (!time) return "从未";
|
||
const diff = Date.now() - time;
|
||
if (diff < MINUTE) return "刚刚";
|
||
if (diff < HOUR) return `${Math.floor(diff / MINUTE)}分钟前`;
|
||
if (diff < DAY) return `${Math.floor(diff / HOUR)}小时前`;
|
||
if (diff < MONTH) return `${Math.floor(diff / DAY)}天前`;
|
||
if (diff < YEAR) return `${Math.floor(diff / MONTH)}个月前`;
|
||
return `${Math.floor(diff / YEAR)}年前`;
|
||
}
|
||
function formatDateTime(ts) {
|
||
const time = normalizeTimestamp(ts);
|
||
if (!time) return "未知";
|
||
const d = new Date(time);
|
||
const pad = (n) => String(n).padStart(2, "0");
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
const _hoisted_1$2 = ["title"];
|
||
const _hoisted_2$2 = {
|
||
key: 0,
|
||
class: "fqa-hover-author"
|
||
};
|
||
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" };
|
||
const _hoisted_7$1 = { class: "fqa-hover-stat-v" };
|
||
const _hoisted_8$1 = { class: "fqa-hover-stat" };
|
||
const _hoisted_9$1 = { class: "fqa-hover-stat-v" };
|
||
const _hoisted_10$1 = { class: "fqa-hover-seg" };
|
||
const _hoisted_11$1 = { class: "fqa-hover-abstract" };
|
||
const _hoisted_12$1 = {
|
||
key: 0,
|
||
class: "fqa-hover-chapter"
|
||
};
|
||
const _sfc_main$2 = /* @__PURE__ */ vue.defineComponent({
|
||
__name: "BookHoverCard",
|
||
props: {
|
||
entry: {},
|
||
x: {},
|
||
y: {},
|
||
height: {},
|
||
visible: { type: Boolean }
|
||
},
|
||
emits: ["panel-enter", "panel-leave"],
|
||
setup(__props, { emit: __emit }) {
|
||
const props = __props;
|
||
const emit = __emit;
|
||
const detail = vue.computed(() => {
|
||
var _a;
|
||
return ((_a = props.entry) == null ? void 0 : _a.detail) ?? null;
|
||
});
|
||
const item = vue.computed(() => {
|
||
var _a;
|
||
return ((_a = props.entry) == null ? void 0 : _a.item) ?? null;
|
||
});
|
||
const readAt = vue.computed(() => {
|
||
var _a;
|
||
return fromNow((_a = item.value) == null ? void 0 : _a.last_read_timestamp);
|
||
});
|
||
const addedAt = vue.computed(() => {
|
||
var _a;
|
||
return fromNow((_a = item.value) == null ? void 0 : _a.add_shelf_time);
|
||
});
|
||
const updatedAt = vue.computed(() => {
|
||
var _a;
|
||
return fromNow((_a = detail.value) == null ? void 0 : _a.last_chapter_update_time);
|
||
});
|
||
const updatedAtFull = vue.computed(() => {
|
||
var _a;
|
||
return formatDateTime((_a = detail.value) == null ? void 0 : _a.last_chapter_update_time);
|
||
});
|
||
const showUpdateTime = vue.ref(false);
|
||
const latestChapter = vue.computed(() => {
|
||
const d = detail.value;
|
||
if (!d) return "—";
|
||
return d.total_chapter_count ? `${d.total_chapter_count}章` : "—";
|
||
});
|
||
const pickedTab = vue.ref(null);
|
||
const chapterText = vue.computed(() => {
|
||
var _a, _b;
|
||
return ((_b = (_a = detail.value) == null ? void 0 : _a.current_chapter_summary) == null ? void 0 : _b.trim()) ?? "";
|
||
});
|
||
const bookText = vue.computed(() => {
|
||
var _a, _b;
|
||
return ((_b = (_a = detail.value) == null ? void 0 : _a.summary) == null ? void 0 : _b.trim()) ?? "";
|
||
});
|
||
const activeTab = vue.computed(() => {
|
||
if (pickedTab.value) return pickedTab.value;
|
||
return chapterText.value ? "chapter" : "book";
|
||
});
|
||
const abstractText = vue.computed(() => {
|
||
const text = activeTab.value === "chapter" ? chapterText.value : bookText.value;
|
||
return text || "暂无数据";
|
||
});
|
||
vue.watch(
|
||
() => {
|
||
var _a;
|
||
return (_a = props.entry) == null ? void 0 : _a.item.book_id;
|
||
},
|
||
() => {
|
||
pickedTab.value = null;
|
||
showUpdateTime.value = false;
|
||
}
|
||
);
|
||
const root = vue.ref(null);
|
||
const supportsPopover = typeof HTMLElement !== "undefined" && typeof HTMLElement.prototype.showPopover === "function";
|
||
vue.watch(
|
||
() => props.visible && !!detail.value,
|
||
(show) => {
|
||
const el = root.value;
|
||
if (!el || !supportsPopover) return;
|
||
try {
|
||
if (show) el.showPopover();
|
||
else el.hidePopover();
|
||
} catch {
|
||
}
|
||
}
|
||
);
|
||
return (_ctx, _cache) => {
|
||
return vue.openBlock(), vue.createElementBlock("div", {
|
||
id: "fqa-bookshelf-hover",
|
||
ref_key: "root",
|
||
ref: root,
|
||
popover: "manual",
|
||
class: vue.normalizeClass({ "fqa-visible": __props.visible && !!detail.value }),
|
||
style: vue.normalizeStyle({ left: __props.x + "px", top: __props.y + "px" }),
|
||
onMouseenter: _cache[4] || (_cache[4] = ($event) => emit("panel-enter")),
|
||
onMouseleave: _cache[5] || (_cache[5] = ($event) => emit("panel-leave"))
|
||
}, [
|
||
detail.value ? (vue.openBlock(), vue.createElementBlock("div", {
|
||
key: 0,
|
||
class: "fqa-hover-inner",
|
||
style: vue.normalizeStyle(__props.height ? { height: __props.height + "px" } : void 0)
|
||
}, [
|
||
vue.createElementVNode("div", {
|
||
class: "fqa-hover-title",
|
||
title: detail.value.title
|
||
}, vue.toDisplayString(detail.value.title), 9, _hoisted_1$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),
|
||
onMouseleave: _cache[1] || (_cache[1] = ($event) => showUpdateTime.value = false)
|
||
}, [
|
||
vue.createElementVNode("div", {
|
||
class: "fqa-hover-stat-v",
|
||
title: updatedAtFull.value
|
||
}, vue.toDisplayString(showUpdateTime.value ? updatedAt.value : latestChapter.value), 9, _hoisted_4$1),
|
||
vue.createElementVNode("div", _hoisted_5$1, vue.toDisplayString(showUpdateTime.value ? "更新于" : "最新章"), 1)
|
||
], 32),
|
||
vue.createElementVNode("div", _hoisted_6$1, [
|
||
vue.createElementVNode("div", _hoisted_7$1, vue.toDisplayString(readAt.value), 1),
|
||
_cache[6] || (_cache[6] = vue.createElementVNode("div", { class: "fqa-hover-stat-k" }, "阅读过", -1))
|
||
]),
|
||
vue.createElementVNode("div", _hoisted_8$1, [
|
||
vue.createElementVNode("div", _hoisted_9$1, vue.toDisplayString(addedAt.value), 1),
|
||
_cache[7] || (_cache[7] = vue.createElementVNode("div", { class: "fqa-hover-stat-k" }, "已加入书架", -1))
|
||
])
|
||
]),
|
||
vue.createElementVNode("div", _hoisted_10$1, [
|
||
vue.createElementVNode("button", {
|
||
class: vue.normalizeClass(["fqa-hover-seg-btn", { "fqa-hover-seg-active": activeTab.value === "chapter" }]),
|
||
onClick: _cache[2] || (_cache[2] = ($event) => pickedTab.value = "chapter")
|
||
}, " 本章梗概 ", 2),
|
||
vue.createElementVNode("button", {
|
||
class: vue.normalizeClass(["fqa-hover-seg-btn", { "fqa-hover-seg-active": activeTab.value === "book" }]),
|
||
onClick: _cache[3] || (_cache[3] = ($event) => pickedTab.value = "book")
|
||
}, " 全书简介 ", 2)
|
||
]),
|
||
vue.createElementVNode("div", _hoisted_11$1, [
|
||
activeTab.value === "chapter" && detail.value.current_chapter_title ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_12$1, vue.toDisplayString(detail.value.current_chapter_title), 1)) : vue.createCommentVNode("", true),
|
||
vue.createTextVNode(" " + vue.toDisplayString(abstractText.value), 1)
|
||
])
|
||
], 4)) : vue.createCommentVNode("", true)
|
||
], 38);
|
||
};
|
||
}
|
||
});
|
||
const _hoisted_1$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$1(SHELF_BASE + "/info/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700"),
|
||
fetch$1("https://fanqienovel.com/api/reader/book/progress")
|
||
]);
|
||
const response = responses[0];
|
||
const response2 = responses[1];
|
||
const data = await response.json();
|
||
const info = data.data.book_shelf_info;
|
||
const data_ = await response2.json();
|
||
const data2 = data_.data;
|
||
const all_items = info.map((item) => item.book_id);
|
||
const response3 = await fetch$1("https://fanqienovel.com/api/book/simple/info", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json"
|
||
},
|
||
body: JSON.stringify({
|
||
book_ids: all_items
|
||
})
|
||
});
|
||
const data__ = await response3.json();
|
||
const data3 = data__.data.bookList;
|
||
const results = [];
|
||
for (let i2 of data.data.book_shelf_info) {
|
||
const progress = data2.find((it) => it.book_id === i2.book_id);
|
||
const simple = data3.find((it) => it.book_id === i2.book_id);
|
||
const item = {
|
||
book_id: i2.book_id,
|
||
last_operate_time: progress ? progress.read_timestamp : i2.last_operate_time,
|
||
add_shelf_time: i2.add_shelf_time,
|
||
group_name: i2.group_name,
|
||
last_read_timestamp: progress ? progress.read_timestamp : 0,
|
||
last_read_chapter_id: progress ? progress.item_id : "0",
|
||
is_publish: (simple == null ? void 0 : simple.genre) === "6"
|
||
};
|
||
results.push(item);
|
||
}
|
||
return results;
|
||
}
|
||
async function multidetail(books) {
|
||
const response = await fetch$1("https://fanqienovel.com/api/bookshelf/multidetail", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json"
|
||
},
|
||
body: JSON.stringify({
|
||
books: books.map((book) => {
|
||
return {
|
||
book_id: book.book_id,
|
||
item_id: book.last_read_chapter_id
|
||
};
|
||
})
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
const list = data.data.detail_list;
|
||
const results = [];
|
||
for (let i2 of list) {
|
||
const item = {
|
||
book_id: i2.book_id,
|
||
summary: i2.abstract,
|
||
title: i2.book_name,
|
||
author: i2.author_name,
|
||
cover_url: i2.thumb_url,
|
||
current_chapter_title: i2.item_show_title,
|
||
current_chapter_id: i2.item_id,
|
||
current_chapter_order: i2.real_chapter_order,
|
||
total_chapter_count: i2.serial_count,
|
||
last_chapter_update_time: i2.last_chapter_update_time * 1e3,
|
||
last_chapter_id: i2.last_chapter_item_id,
|
||
current_chapter_summary: i2.item_abstract,
|
||
// last_chapter_title: i.last_chapter_show_title,
|
||
status: i2.update_stop === "1" ? 4 : i2.creation_status,
|
||
update_status: i2.update_status
|
||
};
|
||
results.push(item);
|
||
}
|
||
return results;
|
||
}
|
||
function identify(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 = [
|
||
{ key: "all", label: "全部" },
|
||
{ key: "group", label: "分组" },
|
||
{ key: "publish", label: "出版" }
|
||
];
|
||
function useBookshelf() {
|
||
const entries = vue.ref([]);
|
||
const loading = vue.ref(false);
|
||
const detailLoading = vue.ref(false);
|
||
const error = vue.ref(null);
|
||
let loadToken = 0;
|
||
let detailCache = /* @__PURE__ */ new Map();
|
||
let inflight = /* @__PURE__ */ new Set();
|
||
function applyDetails(details) {
|
||
if (!details.length) return;
|
||
const byId = new Map(details.map((d) => [d.book_id, d]));
|
||
entries.value = entries.value.map((entry) => {
|
||
const detail = byId.get(entry.item.book_id);
|
||
return detail ? { ...entry, detail } : entry;
|
||
});
|
||
}
|
||
async function ensureDetails(items) {
|
||
const token = loadToken;
|
||
const pending = items.filter(
|
||
(item) => !detailCache.has(item.book_id) && !inflight.has(item.book_id)
|
||
);
|
||
if (!pending.length) return;
|
||
pending.forEach((item) => inflight.add(item.book_id));
|
||
detailLoading.value = true;
|
||
try {
|
||
for (const batch of chunk(pending, DETAIL_BATCH_SIZE)) {
|
||
if (token !== loadToken) return;
|
||
try {
|
||
const details = await multidetail(batch);
|
||
if (token !== loadToken) return;
|
||
details.forEach((d) => detailCache.set(d.book_id, d));
|
||
applyDetails(details);
|
||
} catch (err) {
|
||
console.error("[fqa:bookshelf] 加载书籍详情失败:", err);
|
||
}
|
||
}
|
||
} finally {
|
||
pending.forEach((item) => inflight.delete(item.book_id));
|
||
if (token === loadToken && inflight.size === 0) detailLoading.value = false;
|
||
}
|
||
}
|
||
async function load2(force = false) {
|
||
const token = ++loadToken;
|
||
inflight.clear();
|
||
if (force) detailCache = /* @__PURE__ */ new Map();
|
||
loading.value = true;
|
||
error.value = null;
|
||
try {
|
||
const items = await getBookshelf();
|
||
if (token !== loadToken) return;
|
||
items.sort((a, b) => b.last_operate_time - a.last_operate_time);
|
||
entries.value = items.map((item) => ({
|
||
item,
|
||
detail: detailCache.get(item.book_id) ?? null
|
||
}));
|
||
loading.value = false;
|
||
} catch (err) {
|
||
if (token !== loadToken) return;
|
||
console.error("[fqa:bookshelf] 加载书架失败:", err);
|
||
error.value = err instanceof Error ? err.message : String(err);
|
||
loading.value = false;
|
||
}
|
||
}
|
||
const groups = vue.computed(() => {
|
||
const map = /* @__PURE__ */ new Map();
|
||
for (const entry of entries.value) {
|
||
const name2 = entry.item.group_name;
|
||
if (!name2) continue;
|
||
let group = map.get(name2);
|
||
if (!group) {
|
||
group = { name: name2, books: [], last_operate_time: 0 };
|
||
map.set(name2, group);
|
||
}
|
||
group.books.push(entry);
|
||
group.last_operate_time = Math.max(group.last_operate_time, entry.item.last_operate_time);
|
||
}
|
||
return [...map.values()].sort((a, b) => b.last_operate_time - a.last_operate_time);
|
||
});
|
||
const allCells = vue.computed(() => {
|
||
const sortable = [];
|
||
for (const entry of entries.value) {
|
||
if (entry.item.group_name) continue;
|
||
sortable.push({
|
||
time: entry.item.last_operate_time,
|
||
cell: { kind: "book", key: `book:${entry.item.book_id}`, entry }
|
||
});
|
||
}
|
||
for (const group of groups.value) {
|
||
sortable.push({
|
||
time: group.last_operate_time,
|
||
cell: { kind: "group", key: `group:${group.name}`, group }
|
||
});
|
||
}
|
||
return sortable.sort((a, b) => b.time - a.time).map((s) => s.cell);
|
||
});
|
||
const groupCells = vue.computed(
|
||
() => groups.value.map((group) => ({ kind: "group", key: `group:${group.name}`, group }))
|
||
);
|
||
const publishCells = vue.computed(
|
||
() => entries.value.filter((entry) => entry.item.is_publish).map((entry) => ({ kind: "book", key: `book:${entry.item.book_id}`, entry }))
|
||
);
|
||
const counts = vue.computed(() => ({
|
||
all: entries.value.length,
|
||
group: groups.value.length,
|
||
publish: entries.value.filter((entry) => entry.item.is_publish).length
|
||
}));
|
||
function cellsOf(tab) {
|
||
if (tab === "group") return groupCells.value;
|
||
if (tab === "publish") return publishCells.value;
|
||
return allCells.value;
|
||
}
|
||
function findGroup(name2) {
|
||
return groups.value.find((group) => group.name === name2) ?? null;
|
||
}
|
||
return {
|
||
entries,
|
||
loading,
|
||
detailLoading,
|
||
error,
|
||
groups,
|
||
counts,
|
||
load: load2,
|
||
ensureDetails,
|
||
cellsOf,
|
||
findGroup,
|
||
PAGE_SIZE
|
||
};
|
||
}
|
||
const _hoisted_1 = { id: "fqa-bookshelf" };
|
||
const _hoisted_2 = { class: "fqa-bs-header" };
|
||
const _hoisted_3 = { class: "fqa-bs-actions" };
|
||
const _hoisted_4 = { key: 0 };
|
||
const _hoisted_5 = ["disabled"];
|
||
const _hoisted_6 = ["aria-selected", "onClick", "onKeydown"];
|
||
const _hoisted_7 = { class: "fqa-tab-count" };
|
||
const _hoisted_8 = {
|
||
key: 0,
|
||
class: "fqa-groupbar"
|
||
};
|
||
const _hoisted_9 = { class: "fqa-groupbar-name" };
|
||
const _hoisted_10 = { class: "fqa-groupbar-count" };
|
||
const _hoisted_11 = {
|
||
key: 1,
|
||
class: "fqa-status"
|
||
};
|
||
const _hoisted_12 = {
|
||
key: 2,
|
||
class: "fqa-status"
|
||
};
|
||
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, groups, load: load2, ensureDetails, cellsOf, findGroup, PAGE_SIZE: PAGE_SIZE2 } = useBookshelf();
|
||
const activeTab = vue.ref("all");
|
||
const openedGroupName = vue.ref(null);
|
||
const tabsRef = vue.ref(null);
|
||
const inkStyle = vue.ref({ left: "0px", width: "0px" });
|
||
const hoverEntry = vue.ref(null);
|
||
const hoverVisible = vue.ref(false);
|
||
const hoverPos = vue.ref({ x: 0, y: 0 });
|
||
const hoverHeight = vue.ref(0);
|
||
let hoverTimer;
|
||
let hideTimer;
|
||
let hoverAnchor = null;
|
||
const pointerInPanel = vue.ref(false);
|
||
const openedGroup = vue.computed(
|
||
() => openedGroupName.value ? findGroup(openedGroupName.value) : null
|
||
);
|
||
const allCells = vue.computed(() => {
|
||
const group = openedGroup.value;
|
||
if (group) {
|
||
return group.books.map((entry) => ({
|
||
kind: "book",
|
||
key: `book:${entry.item.book_id}`,
|
||
entry
|
||
}));
|
||
}
|
||
return cellsOf(activeTab.value);
|
||
});
|
||
const visibleCount = vue.ref(PAGE_SIZE2);
|
||
const cells = vue.computed(() => allCells.value.slice(0, visibleCount.value));
|
||
const hasMore = vue.computed(() => visibleCount.value < allCells.value.length);
|
||
const sentinel = vue.ref(null);
|
||
let pageObserver = null;
|
||
function resetPaging() {
|
||
visibleCount.value = PAGE_SIZE2;
|
||
}
|
||
async function refresh() {
|
||
resetPaging();
|
||
await load2(true);
|
||
}
|
||
vue.watch([activeTab, openedGroupName], resetPaging);
|
||
const isEmpty = vue.computed(() => !loading.value && !error.value && allCells.value.length === 0);
|
||
const emptyText = vue.computed(() => {
|
||
if (openedGroup.value) return "这个分组还没有书";
|
||
if (activeTab.value === "group") return "还没有创建任何分组";
|
||
if (activeTab.value === "publish") return "书架里还没有出版物";
|
||
return "书架空空如也,去首页找几本书看看吧";
|
||
});
|
||
function updateInk() {
|
||
const wrap = tabsRef.value;
|
||
if (!wrap) return;
|
||
const index = TABS.findIndex((tab) => tab.key === activeTab.value);
|
||
const el = wrap.querySelectorAll(".fqa-tab")[index];
|
||
if (!el) return;
|
||
inkStyle.value = {
|
||
left: `${el.offsetLeft}px`,
|
||
width: `${el.offsetWidth}px`
|
||
};
|
||
}
|
||
function selectTab(key) {
|
||
openedGroupName.value = null;
|
||
activeTab.value = key;
|
||
hideHover(true);
|
||
}
|
||
vue.watch(activeTab, () => vue.nextTick(updateInk));
|
||
vue.watch(counts, () => vue.nextTick(updateInk), { deep: true });
|
||
function computePosition(el) {
|
||
const cover = el.querySelector(".fqa-cover") ?? el;
|
||
const rect = cover.getBoundingClientRect();
|
||
let x = rect.right + HOVER_GAP;
|
||
if (x + HOVER_WIDTH > window.innerWidth - VIEWPORT_MARGIN) {
|
||
x = rect.left - HOVER_WIDTH - HOVER_GAP;
|
||
}
|
||
x = Math.max(VIEWPORT_MARGIN, Math.min(x, window.innerWidth - HOVER_WIDTH - VIEWPORT_MARGIN));
|
||
const topLimit = VIEWPORT_MARGIN;
|
||
const bottomLimit = window.innerHeight - VIEWPORT_MARGIN;
|
||
const height = Math.min(rect.height, bottomLimit - topLimit);
|
||
const y = Math.max(topLimit, Math.min(rect.top, bottomLimit - height));
|
||
hoverHeight.value = height;
|
||
hoverPos.value = { x, y };
|
||
}
|
||
function onCardHover({ entry, el }) {
|
||
if (hoverTimer) clearTimeout(hoverTimer);
|
||
if (hideTimer) {
|
||
clearTimeout(hideTimer);
|
||
hideTimer = void 0;
|
||
}
|
||
if (!entry.detail) return;
|
||
hoverAnchor = el;
|
||
if (hoverVisible.value && hoverEntry.value !== entry) {
|
||
computePosition(el);
|
||
hoverEntry.value = entry;
|
||
return;
|
||
}
|
||
hoverTimer = setTimeout(() => {
|
||
hoverTimer = void 0;
|
||
computePosition(el);
|
||
hoverEntry.value = entry;
|
||
hoverVisible.value = true;
|
||
}, HOVER_DELAY);
|
||
}
|
||
function scheduleHide() {
|
||
if (hoverTimer) {
|
||
clearTimeout(hoverTimer);
|
||
hoverTimer = void 0;
|
||
}
|
||
if (hideTimer) clearTimeout(hideTimer);
|
||
hideTimer = setTimeout(() => {
|
||
hideTimer = void 0;
|
||
if (!pointerInPanel.value) hideHover(true);
|
||
}, HIDE_DELAY);
|
||
}
|
||
function hideHover(immediate = false) {
|
||
if (hoverTimer) {
|
||
clearTimeout(hoverTimer);
|
||
hoverTimer = void 0;
|
||
}
|
||
if (hideTimer) {
|
||
clearTimeout(hideTimer);
|
||
hideTimer = void 0;
|
||
}
|
||
pointerInPanel.value = false;
|
||
hoverAnchor = null;
|
||
hoverVisible.value = false;
|
||
if (immediate) hoverEntry.value = null;
|
||
}
|
||
function onPanelEnter() {
|
||
if (hideTimer) {
|
||
clearTimeout(hideTimer);
|
||
hideTimer = void 0;
|
||
}
|
||
pointerInPanel.value = true;
|
||
}
|
||
function onPanelLeave() {
|
||
pointerInPanel.value = false;
|
||
scheduleHide();
|
||
}
|
||
let pendingItems = [];
|
||
let flushTimer;
|
||
function queueDetails(entries) {
|
||
const pending = entries.filter((entry) => !entry.detail);
|
||
if (!pending.length) return;
|
||
pendingItems.push(...pending.map((entry) => entry.item));
|
||
if (flushTimer) return;
|
||
flushTimer = setTimeout(() => {
|
||
flushTimer = void 0;
|
||
const batch = pendingItems;
|
||
pendingItems = [];
|
||
void ensureDetails(batch);
|
||
}, 50);
|
||
}
|
||
function onCardVisible(entry) {
|
||
queueDetails([entry]);
|
||
}
|
||
function onGroupVisible(entries) {
|
||
queueDetails(entries);
|
||
}
|
||
function openBook(entry) {
|
||
var _a;
|
||
hideHover(true);
|
||
const chapterId = ((_a = entry.detail) == null ? void 0 : _a.current_chapter_id) || entry.item.last_read_chapter_id;
|
||
const url = chapterId && chapterId !== "0" ? `https://fanqienovel.com/reader/${chapterId}` : `https://fanqienovel.com/page/${entry.item.book_id}`;
|
||
unsafeWindow.location.href = url;
|
||
}
|
||
function openGroup(group) {
|
||
hideHover(true);
|
||
openedGroupName.value = group.name;
|
||
}
|
||
const menuVisible = vue.ref(false);
|
||
const menuPos = vue.ref({ x: 0, y: 0 });
|
||
const menuEntry = vue.ref(null);
|
||
const toast = vue.ref(null);
|
||
let toastTimer;
|
||
function showToast(msg) {
|
||
toast.value = msg;
|
||
if (toastTimer) clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => {
|
||
toast.value = null;
|
||
toastTimer = void 0;
|
||
}, 2600);
|
||
}
|
||
const NO_GROUP_KEY = `${MOVE_PREFIX}`;
|
||
const menuItems = vue.computed(() => {
|
||
const entry = menuEntry.value;
|
||
if (!entry) return [];
|
||
const current = entry.item.group_name ?? "";
|
||
const targets = groups.value.filter((g2) => g2.name !== current).map((g2) => ({ key: `${MOVE_PREFIX}${g2.name}`, label: g2.name }));
|
||
if (current) targets.push({ key: NO_GROUP_KEY, label: "无分组" });
|
||
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);
|
||
}
|
||
function onScrollOrResize() {
|
||
if (hoverAnchor && (hoverVisible.value || hoverTimer)) {
|
||
if (hoverAnchor.isConnected) {
|
||
computePosition(hoverAnchor);
|
||
} else {
|
||
hideHover(true);
|
||
}
|
||
}
|
||
updateInk();
|
||
}
|
||
vue.onMounted(async () => {
|
||
await load2();
|
||
await vue.nextTick();
|
||
updateInk();
|
||
window.addEventListener("scroll", onScrollOrResize, true);
|
||
window.addEventListener("resize", onScrollOrResize);
|
||
if (typeof IntersectionObserver !== "undefined") {
|
||
pageObserver = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
|
||
visibleCount.value += PAGE_SIZE2;
|
||
}
|
||
},
|
||
{ rootMargin: "400px" }
|
||
);
|
||
vue.watch(
|
||
sentinel,
|
||
(el) => {
|
||
pageObserver == null ? void 0 : pageObserver.disconnect();
|
||
if (el) pageObserver == null ? void 0 : pageObserver.observe(el);
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
}
|
||
});
|
||
vue.onBeforeUnmount(() => {
|
||
if (hoverTimer) clearTimeout(hoverTimer);
|
||
if (hideTimer) clearTimeout(hideTimer);
|
||
if (flushTimer) clearTimeout(flushTimer);
|
||
pageObserver == null ? void 0 : pageObserver.disconnect();
|
||
pageObserver = null;
|
||
window.removeEventListener("scroll", onScrollOrResize, true);
|
||
window.removeEventListener("resize", onScrollOrResize);
|
||
});
|
||
return (_ctx, _cache) => {
|
||
return vue.openBlock(), vue.createElementBlock("div", _hoisted_1, [
|
||
vue.createElementVNode("div", _hoisted_2, [
|
||
_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", {
|
||
class: "fqa-btn",
|
||
disabled: vue.unref(loading),
|
||
onClick: refresh
|
||
}, vue.toDisplayString(vue.unref(loading) ? "刷新中…" : "刷新"), 9, _hoisted_5)
|
||
])
|
||
]),
|
||
vue.createElementVNode("div", {
|
||
ref_key: "tabsRef",
|
||
ref: tabsRef,
|
||
class: "fqa-tabs",
|
||
role: "tablist"
|
||
}, [
|
||
(vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(vue.unref(TABS), (tab) => {
|
||
return vue.openBlock(), vue.createElementBlock("div", {
|
||
key: tab.key,
|
||
class: vue.normalizeClass(["fqa-tab", { "fqa-tab-active": activeTab.value === tab.key }]),
|
||
role: "tab",
|
||
tabindex: "0",
|
||
"aria-selected": activeTab.value === tab.key,
|
||
onClick: ($event) => selectTab(tab.key),
|
||
onKeydown: vue.withKeys(vue.withModifiers(($event) => selectTab(tab.key), ["prevent"]), ["enter"])
|
||
}, [
|
||
vue.createTextVNode(vue.toDisplayString(tab.label), 1),
|
||
vue.createElementVNode("span", _hoisted_7, vue.toDisplayString(vue.unref(counts)[tab.key]), 1)
|
||
], 42, _hoisted_6);
|
||
}), 128)),
|
||
vue.createElementVNode("span", {
|
||
class: "fqa-tab-ink",
|
||
style: vue.normalizeStyle(inkStyle.value)
|
||
}, null, 4)
|
||
], 512),
|
||
openedGroup.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_8, [
|
||
vue.createElementVNode("button", {
|
||
class: "fqa-btn",
|
||
onClick: backToList
|
||
}, "← 返回"),
|
||
vue.createElementVNode("span", _hoisted_9, vue.toDisplayString(openedGroup.value.name), 1),
|
||
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[2] || (_cache[2] = vue.createElementVNode("div", { class: "fqa-status-title" }, "书架加载失败", -1)),
|
||
vue.createElementVNode("div", null, vue.toDisplayString(vue.unref(error)), 1),
|
||
vue.createElementVNode("button", {
|
||
class: "fqa-btn",
|
||
onClick: refresh
|
||
}, "重试")
|
||
])) : isEmpty.value ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_12, [
|
||
vue.createElementVNode("div", _hoisted_13, vue.toDisplayString(emptyText.value), 1)
|
||
])) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 3 }, [
|
||
vue.createElementVNode("div", _hoisted_14, [
|
||
vue.unref(loading) ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, vue.renderList(8, (n) => {
|
||
return vue.createElementVNode("div", {
|
||
key: `sk-${n}`,
|
||
class: "fqa-card"
|
||
}, [..._cache[3] || (_cache[3] = [
|
||
vue.createElementVNode("div", { class: "fqa-sk-cover fqa-sk-anim" }, null, -1),
|
||
vue.createElementVNode("div", {
|
||
class: "fqa-sk-line fqa-sk-anim",
|
||
style: { "width": "90%" }
|
||
}, null, -1),
|
||
vue.createElementVNode("div", {
|
||
class: "fqa-sk-line fqa-sk-anim",
|
||
style: { "width": "55%" }
|
||
}, null, -1)
|
||
])]);
|
||
}), 64)) : (vue.openBlock(true), vue.createElementBlock(vue.Fragment, { key: 1 }, vue.renderList(cells.value, (cell) => {
|
||
return vue.openBlock(), vue.createElementBlock(vue.Fragment, {
|
||
key: cell.key
|
||
}, [
|
||
cell.kind === "book" ? (vue.openBlock(), vue.createBlock(_sfc_main$4, {
|
||
key: 0,
|
||
entry: cell.entry,
|
||
onHover: onCardHover,
|
||
onLeave: scheduleHide,
|
||
onOpen: openBook,
|
||
onVisible: onCardVisible,
|
||
onContextmenu: onCardContextMenu
|
||
}, null, 8, ["entry"])) : (vue.openBlock(), vue.createBlock(_sfc_main$3, {
|
||
key: 1,
|
||
group: cell.group,
|
||
onOpen: openGroup,
|
||
onVisible: onGroupVisible
|
||
}, null, 8, ["group"]))
|
||
], 64);
|
||
}), 128))
|
||
]),
|
||
!vue.unref(loading) && hasMore.value ? (vue.openBlock(), vue.createElementBlock("div", {
|
||
key: 0,
|
||
ref_key: "sentinel",
|
||
ref: sentinel,
|
||
class: "fqa-loadmore"
|
||
}, "加载中…", 512)) : vue.createCommentVNode("", true)
|
||
], 64)),
|
||
(vue.openBlock(), vue.createBlock(vue.Teleport, { to: "body" }, [
|
||
vue.createVNode(_sfc_main$2, {
|
||
entry: hoverEntry.value,
|
||
x: hoverPos.value.x,
|
||
y: hoverPos.value.y,
|
||
height: hoverHeight.value,
|
||
visible: hoverVisible.value,
|
||
onPanelEnter,
|
||
onPanelLeave
|
||
}, null, 8, ["entry", "x", "y", "height", "visible"]),
|
||
vue.createVNode(_sfc_main$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\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";
|
||
let app = null;
|
||
let container = null;
|
||
let observer = null;
|
||
function injectStyle() {
|
||
if (document.getElementById(STYLE_ID)) return;
|
||
const style = document.createElement("style");
|
||
style.id = STYLE_ID;
|
||
style.textContent = bookshelfcss;
|
||
document.head.appendChild(style);
|
||
}
|
||
function hideOrigin(root = document) {
|
||
root.querySelectorAll(ORIGIN_SELECTOR).forEach((el) => {
|
||
if (el.id === CONTAINER_ID || el.closest(`#${CONTAINER_ID}`)) return;
|
||
el.classList.add("fqa-hide");
|
||
});
|
||
}
|
||
function isBookshelfPath(path) {
|
||
return path.startsWith("/bookshelf");
|
||
}
|
||
function unmount() {
|
||
var _a;
|
||
observer == null ? void 0 : observer.disconnect();
|
||
observer = null;
|
||
app == null ? void 0 : app.unmount();
|
||
app = null;
|
||
container == null ? void 0 : container.remove();
|
||
container = null;
|
||
(_a = document.getElementById("fqa-bookshelf-hover")) == null ? void 0 : _a.remove();
|
||
document.querySelectorAll(ORIGIN_SELECTOR).forEach((el) => {
|
||
el.classList.remove("fqa-hide");
|
||
});
|
||
}
|
||
async function mainHook(_previous) {
|
||
if (!isBookshelfPath(window.location.pathname)) {
|
||
unmount();
|
||
return;
|
||
}
|
||
if (app) {
|
||
hideOrigin();
|
||
return;
|
||
}
|
||
injectStyle();
|
||
const origin = await waitForElement(ORIGIN_SELECTOR);
|
||
if (!isBookshelfPath(window.location.pathname)) return;
|
||
if (app) return;
|
||
hideOrigin();
|
||
container = document.createElement("div");
|
||
container.id = CONTAINER_ID;
|
||
const anchor = origin ?? document.querySelector("#root") ?? document.body;
|
||
if (origin == null ? void 0 : origin.parentElement) {
|
||
origin.insertAdjacentElement("beforebegin", container);
|
||
} else {
|
||
anchor.appendChild(container);
|
||
}
|
||
app = vue.createApp(_sfc_main);
|
||
app.config.errorHandler = (err, _instance, info) => {
|
||
console.error(`[fqa:bookshelf] Vue error (${info}):`, err);
|
||
};
|
||
app.mount(container);
|
||
console.log("[fqa:bookshelf] 书架视图已挂载");
|
||
document.title = "我的书架 - 番茄小说";
|
||
observer = new MutationObserver((mutations) => {
|
||
var _a;
|
||
for (const mutation of mutations) {
|
||
for (const node of mutation.addedNodes) {
|
||
if (!(node instanceof HTMLElement)) continue;
|
||
if (node.id === CONTAINER_ID || node.closest(`#${CONTAINER_ID}`)) continue;
|
||
if ((_a = node.matches) == null ? void 0 : _a.call(node, ORIGIN_SELECTOR)) {
|
||
node.classList.add("fqa-hide");
|
||
} else {
|
||
hideOrigin(node);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
observer.observe(document.body, { childList: true, subtree: true });
|
||
}
|
||
function filter(path, _query, _hash) {
|
||
return (isBookshelfPath(path) || !!app) && userState.isLogin;
|
||
}
|
||
const _exports = [
|
||
{
|
||
id: "bookshelfHook_onload",
|
||
event: "load",
|
||
filter,
|
||
handler: mainHook
|
||
},
|
||
{
|
||
id: "bookshelfHook_onurlchange",
|
||
event: "onUrlChange",
|
||
filter,
|
||
handler: mainHook
|
||
}
|
||
];
|
||
const hooks = [
|
||
..._exports$3,
|
||
..._exports$2,
|
||
..._exports$1,
|
||
..._exports
|
||
];
|
||
async function onEvent(event, previous) {
|
||
console.log(`onEvent, event: ${event}, previous: ${previous}`);
|
||
const path = window.location.pathname;
|
||
const hash2 = window.location.hash;
|
||
const params = new URLSearchParams(window.location.search);
|
||
const tasks = [];
|
||
for (const hook of hooks) {
|
||
if (hook.event === event && hook.filter(path, params, hash2)) {
|
||
tasks.push(async () => {
|
||
try {
|
||
await hook.handler(previous);
|
||
} catch (err) {
|
||
console.error(`[hook:${hook.id}] handler failed:`, err);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
console.log(`tasks: `, tasks);
|
||
if (tasks.length > 0) {
|
||
await Promise.allSettled(tasks.map((task) => task()));
|
||
}
|
||
}
|
||
async function onUrlChange(previous) {
|
||
return await onEvent("onUrlChange", previous);
|
||
}
|
||
async function onHashChange(previous) {
|
||
return await onEvent("onHashChange", previous);
|
||
}
|
||
async function onLoad() {
|
||
return await onEvent("load");
|
||
}
|
||
const win = unsafeWindow;
|
||
let previousUrl = win.location.href;
|
||
let previousHash = win.location.hash;
|
||
function installNavigationHooks() {
|
||
for (const method of ["pushState", "replaceState"]) {
|
||
const original = win.history[method];
|
||
win.history[method] = function(...args) {
|
||
const result = original.apply(this, args);
|
||
console.debug(`history.${method} called with args:`, args);
|
||
void onUrlChange(previousUrl);
|
||
previousUrl = win.location.href;
|
||
return result;
|
||
};
|
||
}
|
||
win.addEventListener("popstate", () => {
|
||
void onUrlChange(previousUrl);
|
||
previousUrl = win.location.href;
|
||
});
|
||
win.addEventListener("hashchange", () => {
|
||
void onHashChange(previousHash);
|
||
previousHash = win.location.hash;
|
||
});
|
||
}
|
||
async function mainInit() {
|
||
console.log(`================================================`);
|
||
console.log(`== ${name} - ${version} ==`);
|
||
console.log(`================================================`);
|
||
installNavigationHooks();
|
||
initFontDecrypt();
|
||
await inject();
|
||
initUserStyle();
|
||
await ensureDevice();
|
||
await init();
|
||
void onLoad();
|
||
}
|
||
mainInit();
|
||
|
||
})(Vue, moment); |