diff --git a/dist/fanqie-assistant.user.js b/dist/fanqie-assistant.user.js index 94d50c5..9b53e63 100644 --- a/dist/fanqie-assistant.user.js +++ b/dist/fanqie-assistant.user.js @@ -133,6 +133,8 @@ decryptFont: true, blockReport: true, allowCopy: true, + shelfRemoveConfirm: true, + logoutConfirm: true, readerFont: "", customCssEnabled: false, customCss: "", @@ -1379,6 +1381,7 @@ const REGISTER_URL = "https://i.snssdk.com/service/2/device_register/?tt_data=a"; const READING_BASE = "https://reading.snssdk.com"; const USER_AGENT = "com.dragon.read"; + const APP_REQUEST = { credentials: "omit" }; function buildQuery(device, versionCode, extra) { return new URLSearchParams({ device_id: device.device_id, @@ -1399,6 +1402,7 @@ new TextEncoder().encode(JSON.stringify(body)).buffer ); const res = await apiFetch(REGISTER_URL, { + ...APP_REQUEST, method: "POST", headers: { "User-Agent": "okhttp/4.10.0", @@ -1431,6 +1435,7 @@ try { const headers = await signRequest(url, body); const res = await apiFetch(url, { + ...APP_REQUEST, method: "POST", headers: { ...headers, @@ -1475,6 +1480,7 @@ const gzipped = await gzip(plainBody); const headers = await signRequest(url, gzipped); const res = await apiFetch(url, { + ...APP_REQUEST, method: "POST", headers: { ...headers, @@ -1973,7 +1979,7 @@ 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 [null, null]; } return [items, items.map((it) => String(it.item_id))]; } @@ -1983,24 +1989,26 @@ const rj = response.json(); const d = rj.data; const allItems = d.allItemIds; - const volmap = {}; + const cs = []; const vname = d.volumeNameList; for (let i2 = 0; i2 < vname.length; i2++) { const volumeName = vname[i2]; if (volumeName !== void 0) { - volmap[volumeName] = d.chapterListWithVolume[i2]; + cs.push(...d.chapterListWithVolume[i2]); } } - return [volmap, allItems]; + return [cs, allItems]; } async function getCatalog(bookId2) { const r = await getCatalogRaw(bookId2); let catalogRaw = r[0]; let allItemIds = r[1]; + console.log("catalogRaw", catalogRaw, "allItemIds", allItemIds); if (!catalogRaw || !allItemIds) { const rw = await webCatalog(bookId2); catalogRaw = rw[0]; allItemIds = rw[1]; + console.log("webCatalog", catalogRaw, "allItemIds", allItemIds); } const vmap = {}; const chapters = []; @@ -4557,9 +4565,12 @@ color: var(--fqa-audio-text); } -/* 页面切到夜间模式时整体反色 */ -.muye-reader-dark .fqa-audio-root, -.fqa-audio-root.fqa-audio-dark { +/* + * 暗色反色。悬浮栏挂在 body 下,够不到 .muye-reader-dark 的后代选择器, + * 所以 audioPanel 在 #fqa-audio-root 容器上打 .fqa-audio-dark 类; + * 真正的 .fqa-audio-root(Vue 根)是容器的子节点,用后代选择器作用到它。 + */ +.fqa-audio-dark .fqa-audio-root { --fqa-audio-bg: #2b2b2b; --fqa-audio-text: #b3b3b3; --fqa-audio-sub: #7a7a7a; @@ -4939,10 +4950,547 @@ }; app$4.mount(container$4); } + const SHELF_BASE = "https://fanqienovel.com/reading/bookapi/bookshelf"; + async function getBookshelf() { + const responses = await Promise.all([ + fetch$1(SHELF_BASE + "/info/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700"), + fetch$1("https://fanqienovel.com/api/reader/book/progress") + ]); + const response = responses[0]; + const response2 = responses[1]; + const data = await response.json(); + const info = data.data.book_shelf_info; + const data_ = await response2.json(); + const data2 = data_.data; + const all_items = info.map((item) => item.book_id); + const response3 = await fetch$1("https://fanqienovel.com/api/book/simple/info", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + book_ids: all_items + }) + }); + const data__ = await response3.json(); + const data3 = data__.data.bookList; + const results = []; + for (let i2 of data.data.book_shelf_info) { + const progress = data2.find((it) => it.book_id === i2.book_id); + const simple = data3.find((it) => it.book_id === i2.book_id); + const item = { + book_id: i2.book_id, + last_operate_time: progress ? progress.read_timestamp : i2.last_operate_time, + add_shelf_time: i2.add_shelf_time, + group_name: i2.group_name, + last_read_timestamp: progress ? progress.read_timestamp : 0, + last_read_chapter_id: progress ? progress.item_id : "0", + is_publish: (simple == null ? void 0 : simple.genre) === "6" + }; + results.push(item); + } + return results; + } + async function multidetail(books) { + const response = await fetch$1("https://fanqienovel.com/api/bookshelf/multidetail", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + books: books.map((book) => { + return { + book_id: book.book_id, + item_id: book.last_read_chapter_id + }; + }) + }) + }); + const data = await response.json(); + const list = data.data.detail_list; + const results = []; + for (let i2 of list) { + const item = { + book_id: i2.book_id, + summary: i2.abstract, + title: i2.book_name, + author: i2.author_name, + cover_url: i2.thumb_url, + current_chapter_title: i2.item_show_title, + current_chapter_id: i2.item_id, + current_chapter_order: i2.real_chapter_order, + total_chapter_count: i2.serial_count, + last_chapter_update_time: i2.last_chapter_update_time * 1e3, + last_chapter_id: i2.last_chapter_item_id, + current_chapter_summary: i2.item_abstract, + // last_chapter_title: i.last_chapter_show_title, + status: i2.update_stop === "1" ? 4 : i2.creation_status, + update_status: i2.update_status + }; + results.push(item); + } + return results; + } + function identify(bookId2, modifyTime = 0) { + return { + asterisked: false, + book_id: bookId2, + book_type: 0, + modify_time: modifyTime + }; + } + async function shelfPost(path, body) { + const res = await fetch$1(`${SHELF_BASE}${path}/v?aid=1967`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(body) + }); + const json = await res.json().catch(() => null); + if (!res.ok || json && json.code !== 0 && json.code !== void 0) { + throw new Error(`书架操作失败(${path}): ${(json == null ? void 0 : json.message) ?? res.status}`); + } + return json; + } + async function addToBookshelf(bookId2) { + await shelfPost("/add", { + add_book_source: 0, + identify_data: [identify(bookId2)] + }); + } + async function removeFromBookshelf(bookId2) { + await shelfPost("/delete", { + identify_data: [identify(bookId2, Date.now())] + }); + } + async function moveToGroup(bookId2, groupName) { + await shelfPost("/update", { + book_data: [ + { + asterisked: false, + book_id: bookId2, + book_type: 0, + group_name: groupName, + has_shown: false, + is_pin: false, + modify_time: Date.now() + } + ] + }); + } + async function isInBookshelf(book_id) { + const response = await fetch$1(`https://fanqienovel.com/reading/bookapi/bookshelf/check/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700&book_id=${book_id}`); + const data = await response.json(); + return Boolean(data.data); + } + function addResponseModifier(modifier) { + modifiers.push(modifier); + return () => { + const i2 = modifiers.indexOf(modifier); + if (i2 >= 0) modifiers.splice(i2, 1); + }; + } + const modifiers = []; + const blackList = [ + "mcs.zijieapi.com", + "vcs.zijieapi.com/vc/setting", + "mon.zijieapi.com", + "mssdk.bytedance.com/web/common", + "hm.baidu.com" + ]; + const BLOCKED_BODY = JSON.stringify({ + e: 0, + sc: 10, + tc: 10 + }); + function checkBlack(url) { + if (!settings$1.blockReport) return false; + return blackList.some((black) => url.includes(black)); + } + function toLocalArrayBuffer(buf) { + const bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + const out = new ArrayBuffer(bytes.byteLength); + new Uint8Array(out).set(bytes); + return out; + } + function textToBody(text) { + return toLocalArrayBuffer(new TextEncoder().encode(text)); + } + function bodyToText(body) { + if (typeof body === "string") return body; + return new TextDecoder().decode(body); + } + function bodyToBuffer(body) { + if (typeof body === "string") return textToBody(body); + return body; + } + function resolveSimple(simple) { + return { ...simple, responseBody: bodyToBuffer(simple.responseBody) }; + } + function matchingModifiers(url) { + return modifiers.filter((m) => m.matcher(url)); + } + function applyModifiers(url, matched, simple) { + let current = simple; + for (const m of matched) { + if (m.modify_response) current = m.modify_response(url, current); + } + return current; + } + function responseAllowsBody(status) { + return status !== 204 && status !== 205 && status !== 304; + } + function simpleToResponse(simple) { + return new Response(responseAllowsBody(simple.statusCode) ? simple.responseBody : null, { + status: simple.statusCode, + headers: simple.responseHeaders + }); + } + async function responseToSimple(res) { + const body = toLocalArrayBuffer(await res.arrayBuffer()); + const headers = {}; + res.headers.forEach((value, key) => { + headers[key] = value; + }); + return { responseBody: body, statusCode: res.status, responseHeaders: headers }; + } + const originalFetch = unsafeWindow.fetch.bind(unsafeWindow); + unsafeWindow.fetch = async function fetch2(input, init2) { + let url; + if (input instanceof Request) { + url = input.url; + } else if (input instanceof URL) { + url = input.href; + } else { + url = input; + } + if (checkBlack(url)) { + console.log("blocked request: " + url); + return new Response(BLOCKED_BODY, { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + const matched = matchingModifiers(url); + if (matched.length === 0) return originalFetch(input, init2); + const breaker = matched.find((m) => m.should_break(url)); + if (breaker == null ? void 0 : breaker.make_response) { + return simpleToResponse(breaker.make_response(url)); + } + if (!matched.some((m) => m.modify_response)) return originalFetch(input, init2); + const res = await originalFetch(input, init2); + try { + const simple = await responseToSimple(res.clone()); + return simpleToResponse(applyModifiers(url, matched, simple)); + } catch (error) { + console.error("[fqa:fetch] 修改响应失败,回退原始响应:", error); + return res; + } + }; + const originalXMLHttpRequest = unsafeWindow.XMLHttpRequest; + const XHR_PROTO = originalXMLHttpRequest.prototype; + const XHR_DESC = { + status: Object.getOwnPropertyDescriptor(XHR_PROTO, "status"), + responseText: Object.getOwnPropertyDescriptor(XHR_PROTO, "responseText"), + response: Object.getOwnPropertyDescriptor(XHR_PROTO, "response") + }; + const XHR_GETALL = XHR_PROTO.getAllResponseHeaders; + const XHR_GETONE = XHR_PROTO.getResponseHeader; + function readXhrHeaders(self) { + const headers = {}; + const all = XHR_GETALL.call(self); + if (!all) return headers; + for (const line of all.trim().split(/[\r\n]+/)) { + const idx = line.indexOf(":"); + if (idx > 0) { + headers[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + } + return headers; + } + function readXhrBody(self) { + const type = self.responseType; + if (type === "arraybuffer") { + return toLocalArrayBuffer(XHR_DESC.response.get.call(self)); + } + if (type === "json") { + const parsed = XHR_DESC.response.get.call(self); + return textToBody(JSON.stringify(parsed)); + } + return textToBody(XHR_DESC.responseText.get.call(self)); + } + unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpRequest { + constructor() { + super(...arguments); + __publicField(this, "_blockedUrl"); + __publicField(this, "_fxaUrl", ""); + __publicField(this, "_fxaMatched", null); + __publicField(this, "_fxaBreaker", null); + __publicField(this, "_fxaApplied", false); + __publicField(this, "_fxaSimple", null); + } + open(method, url, async = true, user, password) { + this._fxaUrl = url; + this._fxaMatched = null; + this._fxaBreaker = null; + this._fxaApplied = false; + this._fxaSimple = null; + if (checkBlack(url)) { + console.log("blocked request: " + url); + this._blockedUrl = url; + return; + } + this._blockedUrl = void 0; + const matched = matchingModifiers(url); + const breaker = matched.find((m) => m.should_break(url)); + if (breaker == null ? void 0 : breaker.make_response) { + this._fxaBreaker = breaker; + this._fxaMatched = matched; + } else if (matched.some((m) => m.modify_response)) { + this._fxaMatched = matched; + } + super.open(method, url, async, user, password); + } + setRequestHeader(name2, value) { + if (this._blockedUrl !== void 0) return; + super.setRequestHeader(name2, value); + } + send(body) { + var _a, _b; + if (this._blockedUrl !== void 0) { + this._synthesizeFromSimple({ + responseBody: textToBody(BLOCKED_BODY), + statusCode: 200, + responseHeaders: { "content-type": "application/json" } + }); + return; + } + if ((_a = this._fxaBreaker) == null ? void 0 : _a.make_response) { + this._synthesizeFromSimple(this._fxaBreaker.make_response(this._fxaUrl)); + return; + } + if ((_b = this._fxaMatched) == null ? void 0 : _b.length) this._armTransform(); + super.send(body); + } + abort() { + if (this._blockedUrl !== void 0) return; + super.abort(); + } + getAllResponseHeaders() { + if (this._blockedUrl !== void 0) return "content-type: application/json\r\n"; + return super.getAllResponseHeaders(); + } + getResponseHeader(name2) { + if (this._blockedUrl !== void 0) { + return name2.toLowerCase() === "content-type" ? "application/json" : null; + } + return super.getResponseHeader(name2); + } + /** + * 中断请求后合成一个假响应并手动派发完成事件。 + * 与 fetch 的 make_response 对齐,也复用了黑名单那条原本的 setTimeout 派发逻辑。 + */ + _synthesizeFromSimple(raw) { + const url = this._blockedUrl ?? this._fxaUrl; + const simple = resolveSimple(raw); + const headers = simple.responseHeaders; + const shadow = (prop, value) => Object.defineProperty(this, prop, { configurable: true, get: () => value }); + setTimeout(() => { + const text = bodyToText(simple.responseBody); + shadow("readyState", 4); + shadow("status", simple.statusCode); + shadow("statusText", "OK"); + shadow("responseURL", url); + const type = this.responseType; + shadow("responseText", type === "" || type === "text" ? text : ""); + let value; + if (type === "json") { + try { + value = JSON.parse(text); + } catch { + value = text; + } + } else if (type === "arraybuffer") { + value = simple.responseBody; + } else if (type === "blob") { + value = new Blob([simple.responseBody]); + } else { + value = text; + } + shadow("response", value); + shadow( + "getAllResponseHeaders", + () => Object.entries(headers).map(([k, v]) => `${k}: ${v}\r +`).join("") + ); + shadow("getResponseHeader", (name2) => { + const lower = name2.toLowerCase(); + const key = Object.keys(headers).find((k) => k.toLowerCase() === lower); + return key ? headers[key] : null; + }); + this.dispatchEvent(new Event("readystatechange")); + this.dispatchEvent(new ProgressEvent("load")); + this.dispatchEvent(new ProgressEvent("loadend")); + }, 0); + } + /** + * 在实例上挂影子属性:等真实响应就绪(readyState=4), + * 页面第一次读取时才同步套用 modify_response 并缓存结果。 + * 这样不用跟页面的 onload / onreadystatechange 争先后顺序。 + */ + _armTransform() { + const type = this.responseType; + if (type === "blob" || type === "document") return; + const self = this; + const matched = this._fxaMatched; + const url = this._fxaUrl; + const ensure = () => { + if (self._fxaApplied || self.readyState !== 4) return; + self._fxaSimple = resolveSimple(applyModifiers(url, matched, { + responseBody: readXhrBody(self), + statusCode: XHR_DESC.status.get.call(self), + responseHeaders: readXhrHeaders(self) + })); + self._fxaApplied = true; + }; + Object.defineProperty(this, "status", { + configurable: true, + get() { + ensure(); + if (self._fxaApplied) return self._fxaSimple.statusCode; + return XHR_DESC.status.get.call(self); + } + }); + Object.defineProperty(this, "responseText", { + configurable: true, + get() { + ensure(); + const t = self.responseType; + if (!self._fxaApplied || t !== "" && t !== "text") { + return XHR_DESC.responseText.get.call(self); + } + return bodyToText(self._fxaSimple.responseBody); + } + }); + Object.defineProperty(this, "response", { + configurable: true, + get() { + ensure(); + if (!self._fxaApplied) return XHR_DESC.response.get.call(self); + const body = self._fxaSimple.responseBody; + switch (self.responseType) { + case "": + case "text": + return bodyToText(body); + case "json": + return JSON.parse(bodyToText(body)); + case "arraybuffer": + return body; + default: + return XHR_DESC.response.get.call(self); + } + } + }); + this.getAllResponseHeaders = function getAllResponseHeaders() { + ensure(); + if (!self._fxaApplied) return XHR_GETALL.call(self); + return Object.entries(self._fxaSimple.responseHeaders).map(([k, v]) => `${k}: ${v}\r +`).join(""); + }; + this.getResponseHeader = function getResponseHeader(name2) { + ensure(); + if (!self._fxaApplied) return XHR_GETONE.call(self, name2); + const lower = name2.toLowerCase(); + const key = Object.keys(self._fxaSimple.responseHeaders).find((k) => k.toLowerCase() === lower); + return key ? self._fxaSimple.responseHeaders[key] : null; + }; + } + }; + const _exports$5 = []; + let userState = { + isLogin: false, + userInfo: null + }; + if (read("userState")) { + userState = read("userState"); + } + console.log("userState:", userState); + async function getDetailedUserInfo() { + if (!(userState == null ? void 0 : userState.isLogin) || !(userState == null ? void 0 : userState.userInfo)) { + return null; + } + if (userState.userInfo.gender !== void 0 && userState.userInfo.recommend_gender !== void 0 && userState.userInfo.fans_num !== void 0 && userState.userInfo.following_num !== void 0 && userState.userInfo.is_author !== void 0 && userState.userInfo.author_desc !== void 0 && userState.userInfo.read_book_num !== void 0 && userState.userInfo.read_book_time !== void 0) { + return userState.userInfo; + } + const response = await fetch$1("https://fanqienovel.com/reading/user/basic_info/get/v?aid=1967"); + const j = await response.json(); + if (j == null ? void 0 : j.data) { + const data = j.data; + userState.userInfo.gender = data.profile_gender; + userState.userInfo.recommend_gender = data.gender; + userState.userInfo.fans_num = data.fans_num; + userState.userInfo.following_num = data.follow_user_num; + userState.userInfo.is_author = data.is_author; + userState.userInfo.author_desc = data.author_desc; + userState.userInfo.read_book_num = data.read_book_num; + userState.userInfo.read_book_time = BigInt(data.read_book_time); + return userState.userInfo; + } + write("userState", userState); + return userState.userInfo; + } + async function checkLogin() { + var _a, _b, _c, _d, _e; + const response = await fetch$1("https://fanqienovel.com/api/user/info/v2"); + const j = await response.json(); + const _userInfo = { + id: (_a = j == null ? void 0 : j.data) == null ? void 0 : _a.id, + username: (_b = j == null ? void 0 : j.data) == null ? void 0 : _b.name, + avatar: (_c = j == null ? void 0 : j.data) == null ? void 0 : _c.avatar, + desc: (_d = j == null ? void 0 : j.data) == null ? void 0 : _d.desc, + age: (_e = j == null ? void 0 : j.data) == null ? void 0 : _e.age + }; + if (j.code !== -1) { + userState.isLogin = true; + userState.userInfo = _userInfo; + write("userState", userState); + return true; + } else { + del("userState"); + return false; + } + } + async function init() { + var _a; + await checkLogin(); + if (userState.isLogin) { + console.log("Hello, ", (_a = userState == null ? void 0 : userState.userInfo) == null ? void 0 : _a.username); + } + } let currentBook = null; let latestItemId = null; + let currentChapterWithContent = null; const SCRIPT_CONTAINER_ID = "fqa-reader-content"; let comicObserver = null; + addResponseModifier({ + matcher: (url) => url.indexOf("/reading/bookapi/bookshelf/check/") !== -1 && location.pathname.startsWith("/reader"), + should_break: () => true, + make_response: () => { + const body = JSON.stringify({ + code: 0, + message: "SUCCESS", + data: 0 + }); + return { + responseBody: body, + statusCode: 200, + responseHeaders: { + "content-type": "application/json; charset=utf-8", + "content-length": String(new TextEncoder().encode(body).length) + } + }; + } + }); function ensureScriptContainer(readerContainer, comic) { let scriptContainer = document.getElementById(SCRIPT_CONTAINER_ID); if (!scriptContainer) { @@ -4960,7 +5508,7 @@ return scriptContainer; } async function insertContent() { - var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j; + var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p; const itemId2 = ((_a = window.location.pathname.split("/").pop()) == null ? void 0 : _a.substring(0, 19)) || ""; if (!itemId2) { console.warn("No item_id found in URL"); @@ -4977,6 +5525,7 @@ return; } console.log("Chapter:", chapter); + currentChapterWithContent = chapter; const pageState = unsafeWindow.__INITIAL_STATE__; const chapterTitle = ((_b = chapter.novel_data) == null ? void 0 : _b.title) || ((_d = (_c = pageState == null ? void 0 : pageState.reader) == null ? void 0 : _c.chapterData) == null ? void 0 : _d.title); if (typeof chapter.content === "string") { @@ -5121,15 +5670,36 @@ } } let updateTimeSpans = (muyeReaderSubtitle == null ? void 0 : muyeReaderSubtitle.querySelectorAll("span.desc-item")) || []; - if (updateTimeSpans.length >= 2) { + console.log("spans len", updateTimeSpans.length, "assertIsOffshelf", updateTimeSpans.length < 3); + if (updateTimeSpans.length >= 3) { + console.log("if"); let updateTimeSpan = updateTimeSpans[updateTimeSpans.length - 1]; let uttspan = updateTimeSpan.firstChild; uttspan == null ? void 0 : uttspan.remove(); updateTimeSpan.innerHTML = "更新时间:" + currentChapterItem.update_time; } else { - let updateTimeSpan = document.createElement("span"); + console.log("else"); + (_k = updateTimeSpans[updateTimeSpans.length - 1]) == null ? void 0 : _k.remove(); + const updateTimeSpan = document.createElement("span"); updateTimeSpan.className = "desc-item"; updateTimeSpan.textContent = `更新时间:${currentChapterItem.update_time}`; + console.log("assert equal", currentChapterItem.item_id === ((_l = currentChapterWithContent == null ? void 0 : currentChapterWithContent.novel_data) == null ? void 0 : _l.item_id)); + console.log("wordcnt", (_m = currentChapterWithContent == null ? void 0 : currentChapterWithContent.novel_data) == null ? void 0 : _m.chapter_word_number); + const c = document.getElementById("fqa-current-chapter-volume"); + let b = null; + if (currentChapterItem.item_id === ((_n = currentChapterWithContent == null ? void 0 : currentChapterWithContent.novel_data) == null ? void 0 : _n.item_id)) { + const wordCntSpan = document.createElement("span"); + wordCntSpan.className = "desc-item"; + wordCntSpan.textContent = `本章字数:${(_o = currentChapterWithContent == null ? void 0 : currentChapterWithContent.novel_data) == null ? void 0 : _o.chapter_word_number}字`; + console.log(wordCntSpan); + if (c) { + console.log("insert wordcnt"); + c.insertAdjacentElement("afterend", wordCntSpan); + } + b = wordCntSpan; + } + console.log("insert update time"); + (_p = b || c) == null ? void 0 : _p.insertAdjacentElement("afterend", updateTimeSpan); } } } @@ -5163,6 +5733,13 @@ } async function onLoad$1() { var _a; + async function fetchBookInfo() { + var _a2, _b; + const pageState = unsafeWindow.__INITIAL_STATE__; + const bid = (_b = (_a2 = pageState == null ? void 0 : pageState.reader) == null ? void 0 : _a2.chapterData) == null ? void 0 : _b.bookId; + currentBook = await getBookInfoAndCatalog(bid); + } + void fetchBookInfo(); const toolbar = document.querySelector("div.reader-toolbar > div"); const toolbarButton = document.querySelector("div.reader-toolbar > div > div:nth-child(3)"); if (toolbarButton && toolbar) { @@ -5184,13 +5761,53 @@ c.addEventListener("click", () => void startAudioPlay()); toolbar.appendChild(c); } + const bookshelfButton = document.querySelector("div.reader-toolbar-item"); + if (userState.isLogin && bookshelfButton && bookshelfButton.innerHTML.includes("书架")) { + const shelf = cloneElement(bookshelfButton); + bookshelfButton.replaceWith(shelf); + let inShelf = false; + let ready = false; + let busy = false; + async function ensureBookshelfState() { + while (!currentBook) { + await sleep(50); + } + inShelf = await isInBookshelf(currentBook.book_id); + shelf.classList.toggle("reader-toolbar-item-disabled", inShelf); + const label = shelf.lastChild; + if (label) label.textContent = inShelf ? "已在书架" : "加入书架"; + ready = true; + } + void ensureBookshelfState(); + shelf.addEventListener("click", async () => { + const book = currentBook; + if (!ready || busy || !book) return; + busy = true; + try { + if (inShelf) { + if (settings$1.shelfRemoveConfirm) { + if (!unsafeWindow.confirm(`确定要把《${book.title ?? "这本书"}》从书架移出吗?`)) return; + } + await removeFromBookshelf(book.book_id); + } else { + await addToBookshelf(book.book_id); + } + await ensureBookshelfState(); + } catch (error) { + console.error("[fqa:reader] 书架操作失败:", error); + unsafeWindow.alert(error instanceof Error ? error.message : "书架操作失败"); + } finally { + busy = false; + } + }); + } document.querySelector("div.muye-reader-btns"); await insertContent(); } function readerFilter(path, _query, _hash) { return path.startsWith("/reader") || path.startsWith("reader"); } - const _exports$5 = [ + const _exports$4 = [ { id: "readerHook_load", event: "load", @@ -5210,161 +5827,8 @@ 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$1.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$4 = []; - 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 = '\r\n \r\n \r\n \r\n \r\n \r\n'; - const settings = '\r\n\r\n \r\n \r\n'; + const settings = '\r\n\r\n \r\n \r\n'; const name = "fanqie-assistant"; const version = "0.0.6"; const _hoisted_1$9 = { @@ -5379,72 +5843,74 @@ const _hoisted_5$8 = { class: "fqa-set-row" }; const _hoisted_6$8 = { class: "fqa-set-row" }; const _hoisted_7$6 = { class: "fqa-set-row" }; - const _hoisted_8$6 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_8$6 = { class: "fqa-set-row" }; const _hoisted_9$6 = { class: "fqa-set-row" }; const _hoisted_10$6 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_11$5 = ["disabled"]; + const _hoisted_11$5 = { class: "fqa-set-row" }; const _hoisted_12$5 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_13$4 = { class: "fqa-set-radios" }; - const _hoisted_14$4 = { class: "fqa-set-radio" }; - const _hoisted_15$2 = { class: "fqa-set-radio" }; - const _hoisted_16$1 = { class: "fqa-set-row" }; - const _hoisted_17$1 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_18$1 = { - class: "fqa-set-row", - style: { "padding-top": "0", "border-bottom": "none" } - }; - const _hoisted_19$1 = { class: "fqa-set-row" }; - const _hoisted_20 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_21 = { class: "fqa-set-radios" }; - const _hoisted_22 = { class: "fqa-set-radio" }; - const _hoisted_23 = { class: "fqa-set-radio" }; - const _hoisted_24 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_25 = { class: "fqa-set-radios" }; - const _hoisted_26 = { class: "fqa-set-radio" }; - const _hoisted_27 = { class: "fqa-set-radio" }; - const _hoisted_28 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_29 = { - class: "fqa-set-row", - style: { "padding-top": "0", "border-bottom": "none" } - }; - const _hoisted_30 = { + const _hoisted_13$4 = ["disabled"]; + const _hoisted_14$4 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_15$2 = { class: "fqa-set-radios" }; + const _hoisted_16$1 = { class: "fqa-set-radio" }; + const _hoisted_17$1 = { class: "fqa-set-radio" }; + const _hoisted_18$1 = { class: "fqa-set-row" }; + const _hoisted_19$1 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_20 = { class: "fqa-set-row", style: { "padding-top": "0", "border-bottom": "none" } }; + const _hoisted_21 = { class: "fqa-set-row" }; + const _hoisted_22 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_23 = { class: "fqa-set-radios" }; + const _hoisted_24 = { class: "fqa-set-radio" }; + const _hoisted_25 = { class: "fqa-set-radio" }; + const _hoisted_26 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_27 = { class: "fqa-set-radios" }; + const _hoisted_28 = { class: "fqa-set-radio" }; + const _hoisted_29 = { class: "fqa-set-radio" }; + const _hoisted_30 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_31 = { class: "fqa-set-row", style: { "padding-top": "0", "border-bottom": "none" } }; - const _hoisted_32 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_33 = { class: "fqa-set-field" }; - const _hoisted_34 = { class: "fqa-set-field" }; + const _hoisted_32 = { + class: "fqa-set-row", + style: { "padding-top": "0", "border-bottom": "none" } + }; + const _hoisted_33 = { + class: "fqa-set-row", + style: { "padding-top": "0", "border-bottom": "none" } + }; + const _hoisted_34 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_35 = { class: "fqa-set-field" }; - const _hoisted_36 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_37 = { class: "fqa-set-radios" }; - const _hoisted_38 = { class: "fqa-set-radio" }; - const _hoisted_39 = { class: "fqa-set-radio" }; - const _hoisted_40 = { class: "fqa-set-row" }; - const _hoisted_41 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_42 = { class: "fqa-set-radios" }; - const _hoisted_43 = { class: "fqa-set-radio" }; - const _hoisted_44 = { class: "fqa-set-radio" }; - const _hoisted_45 = { class: "fqa-set-row fqa-set-row-col" }; - const _hoisted_46 = { class: "fqa-set-field" }; - const _hoisted_47 = ["placeholder"]; + const _hoisted_36 = { class: "fqa-set-field" }; + const _hoisted_37 = { class: "fqa-set-field" }; + const _hoisted_38 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_39 = { class: "fqa-set-radios" }; + const _hoisted_40 = { class: "fqa-set-radio" }; + const _hoisted_41 = { class: "fqa-set-radio" }; + const _hoisted_42 = { class: "fqa-set-row" }; + const _hoisted_43 = { class: "fqa-set-row fqa-set-row-col" }; + const _hoisted_44 = { class: "fqa-set-radios" }; + const _hoisted_45 = { class: "fqa-set-radio" }; + const _hoisted_46 = { class: "fqa-set-radio" }; + const _hoisted_47 = { class: "fqa-set-row fqa-set-row-col" }; const _hoisted_48 = { class: "fqa-set-field" }; const _hoisted_49 = ["placeholder"]; const _hoisted_50 = { class: "fqa-set-field" }; const _hoisted_51 = ["placeholder"]; - const _hoisted_52 = { class: "fqa-set-actions" }; - const _hoisted_53 = ["disabled"]; - const _hoisted_54 = { + const _hoisted_52 = { class: "fqa-set-field" }; + const _hoisted_53 = ["placeholder"]; + const _hoisted_54 = { class: "fqa-set-actions" }; + const _hoisted_55 = ["disabled"]; + const _hoisted_56 = { key: 0, class: "fqa-set-note" }; - const _hoisted_55 = { class: "fqa-set-note" }; - const _hoisted_56 = { class: "fqa-set-links" }; - const _hoisted_57 = { class: "fqa-set-link-row" }; - const _hoisted_58 = ["href"]; + const _hoisted_57 = { class: "fqa-set-note" }; + const _hoisted_58 = { class: "fqa-set-links" }; + const _hoisted_59 = { class: "fqa-set-link-row" }; + const _hoisted_60 = ["href"]; const GREASYFORK = "https://greasyfork.org/zh-CN/scripts/589115-%E7%95%AA%E8%8C%84%E5%B0%8F%E8%AF%B4%E5%8A%A9%E6%89%8B"; const GITHUB = "https://github.com/naiyQAQ/fanqie-assistant"; const _sfc_main$9 = /* @__PURE__ */ vue.defineComponent({ @@ -5515,7 +5981,7 @@ onClick: close2 }, "✕"), vue.createElementVNode("nav", _hoisted_2$9, [ - _cache[29] || (_cache[29] = vue.createElementVNode("div", { class: "fqa-set-side-title" }, "助手设置", -1)), + _cache[31] || (_cache[31] = vue.createElementVNode("div", { class: "fqa-set-side-title" }, "助手设置", -1)), (vue.openBlock(), vue.createElementBlock(vue.Fragment, null, vue.renderList(SECTIONS, (s) => { return vue.createElementVNode("div", { key: s.key, @@ -5529,9 +5995,9 @@ ]), vue.createElementVNode("section", _hoisted_4$8, [ active.value === "general" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, [ - _cache[33] || (_cache[33] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "常规", -1)), + _cache[37] || (_cache[37] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "常规", -1)), vue.createElementVNode("label", _hoisted_5$8, [ - _cache[30] || (_cache[30] = vue.createElementVNode("span", { class: "fqa-set-label" }, "解密网页端混淆字体", -1)), + _cache[32] || (_cache[32] = vue.createElementVNode("span", { class: "fqa-set-label" }, "解密网页端混淆字体", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => vue.unref(settings$1).decryptFont = $event), type: "checkbox", @@ -5541,7 +6007,7 @@ ]) ]), vue.createElementVNode("label", _hoisted_6$8, [ - _cache[31] || (_cache[31] = vue.createElementVNode("span", { class: "fqa-set-label" }, "拦截网页事件上报", -1)), + _cache[33] || (_cache[33] = vue.createElementVNode("span", { class: "fqa-set-label" }, "拦截网页事件上报", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[1] || (_cache[1] = ($event) => vue.unref(settings$1).blockReport = $event), type: "checkbox", @@ -5551,7 +6017,7 @@ ]) ]), vue.createElementVNode("label", _hoisted_7$6, [ - _cache[32] || (_cache[32] = vue.createElementVNode("span", { class: "fqa-set-label" }, "允许阅读器复制文本", -1)), + _cache[34] || (_cache[34] = vue.createElementVNode("span", { class: "fqa-set-label" }, "允许阅读器复制文本", -1)), vue.withDirectives(vue.createElementVNode("input", { "onUpdate:modelValue": _cache[2] || (_cache[2] = ($event) => vue.unref(settings$1).allowCopy = $event), type: "checkbox", @@ -5559,199 +6025,219 @@ }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).allowCopy] ]) + ]), + vue.createElementVNode("label", _hoisted_8$6, [ + _cache[35] || (_cache[35] = vue.createElementVNode("span", { class: "fqa-set-label" }, "移出书架时二次确认", -1)), + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[3] || (_cache[3] = ($event) => vue.unref(settings$1).shelfRemoveConfirm = $event), + type: "checkbox", + class: "fqa-set-switch" + }, null, 512), [ + [vue.vModelCheckbox, vue.unref(settings$1).shelfRemoveConfirm] + ]) + ]), + vue.createElementVNode("label", _hoisted_9$6, [ + _cache[36] || (_cache[36] = vue.createElementVNode("span", { class: "fqa-set-label" }, "退出登录时二次确认", -1)), + vue.withDirectives(vue.createElementVNode("input", { + "onUpdate:modelValue": _cache[4] || (_cache[4] = ($event) => vue.unref(settings$1).logoutConfirm = $event), + type: "checkbox", + class: "fqa-set-switch" + }, null, 512), [ + [vue.vModelCheckbox, vue.unref(settings$1).logoutConfirm] + ]) ]) ], 64)) : active.value === "ui" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 1 }, [ - _cache[42] || (_cache[42] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "界面", -1)), - vue.createElementVNode("div", _hoisted_8$6, [ - _cache[34] || (_cache[34] = vue.createElementVNode("span", { class: "fqa-set-label" }, "阅读器字体", -1)), + _cache[46] || (_cache[46] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "界面", -1)), + vue.createElementVNode("div", _hoisted_10$6, [ + _cache[38] || (_cache[38] = vue.createElementVNode("span", { class: "fqa-set-label" }, "阅读器字体", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[3] || (_cache[3] = ($event) => vue.unref(settings$1).readerFont = $event), + "onUpdate:modelValue": _cache[5] || (_cache[5] = ($event) => vue.unref(settings$1).readerFont = $event), class: "fqa-set-input", type: "text", placeholder: "留空表示使用默认字体" }, null, 512), [ [vue.vModelText, vue.unref(settings$1).readerFont] ]), - _cache[35] || (_cache[35] = vue.createElementVNode("p", { class: "fqa-set-note" }, "填写字体名称,例如「思源宋体」。留空则跟随网页默认。", -1)) + _cache[39] || (_cache[39] = vue.createElementVNode("p", { class: "fqa-set-note" }, "填写字体名称,例如「思源宋体」。留空则跟随网页默认。", -1)) ]), - vue.createElementVNode("div", _hoisted_9$6, [ - _cache[36] || (_cache[36] = vue.createElementVNode("span", { class: "fqa-set-label" }, "自定义 CSS", -1)), + vue.createElementVNode("div", _hoisted_11$5, [ + _cache[40] || (_cache[40] = vue.createElementVNode("span", { class: "fqa-set-label" }, "自定义 CSS", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[4] || (_cache[4] = ($event) => vue.unref(settings$1).customCssEnabled = $event), + "onUpdate:modelValue": _cache[6] || (_cache[6] = ($event) => vue.unref(settings$1).customCssEnabled = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).customCssEnabled] ]) ]), - vue.createElementVNode("div", _hoisted_10$6, [ + vue.createElementVNode("div", _hoisted_12$5, [ vue.withDirectives(vue.createElementVNode("textarea", { - "onUpdate:modelValue": _cache[5] || (_cache[5] = ($event) => vue.unref(settings$1).customCss = $event), + "onUpdate:modelValue": _cache[7] || (_cache[7] = ($event) => vue.unref(settings$1).customCss = $event), class: "fqa-set-textarea", disabled: !vue.unref(settings$1).customCssEnabled, spellcheck: "false", placeholder: "/* 自定义 CSS */" - }, null, 8, _hoisted_11$5), [ + }, null, 8, _hoisted_13$4), [ [vue.vModelText, vue.unref(settings$1).customCss] ]), - _cache[37] || (_cache[37] = vue.createElementVNode("p", { class: "fqa-set-note" }, "关闭开关后内容会保留,只是不再应用。", -1)) + _cache[41] || (_cache[41] = vue.createElementVNode("p", { class: "fqa-set-note" }, "关闭开关后内容会保留,只是不再应用。", -1)) ]), - vue.createElementVNode("div", _hoisted_12$5, [ - _cache[40] || (_cache[40] = vue.createElementVNode("span", { class: "fqa-set-label" }, "书架单击书本", -1)), - vue.createElementVNode("div", _hoisted_13$4, [ - vue.createElementVNode("label", _hoisted_14$4, [ + vue.createElementVNode("div", _hoisted_14$4, [ + _cache[44] || (_cache[44] = vue.createElementVNode("span", { class: "fqa-set-label" }, "书架单击书本", -1)), + vue.createElementVNode("div", _hoisted_15$2, [ + vue.createElementVNode("label", _hoisted_16$1, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[6] || (_cache[6] = ($event) => vue.unref(settings$1).bookshelfClickAction = $event), + "onUpdate:modelValue": _cache[8] || (_cache[8] = ($event) => vue.unref(settings$1).bookshelfClickAction = $event), type: "radio", value: "read" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).bookshelfClickAction] ]), - _cache[38] || (_cache[38] = vue.createElementVNode("span", null, "继续阅读", -1)) + _cache[42] || (_cache[42] = vue.createElementVNode("span", null, "继续阅读", -1)) ]), - vue.createElementVNode("label", _hoisted_15$2, [ + vue.createElementVNode("label", _hoisted_17$1, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[7] || (_cache[7] = ($event) => vue.unref(settings$1).bookshelfClickAction = $event), + "onUpdate:modelValue": _cache[9] || (_cache[9] = ($event) => vue.unref(settings$1).bookshelfClickAction = $event), type: "radio", value: "detail" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).bookshelfClickAction] ]), - _cache[39] || (_cache[39] = vue.createElementVNode("span", null, "查看详情", -1)) + _cache[43] || (_cache[43] = vue.createElementVNode("span", null, "查看详情", -1)) ]) ]), - _cache[41] || (_cache[41] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 按住 Ctrl 单击去往另一项;中键在新标签页打开,Ctrl+中键同样反向。 选「继续阅读」但这本书还没读过时,会退回详情页。 ", -1)) + _cache[45] || (_cache[45] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 按住 Ctrl 单击去往另一项;中键在新标签页打开,Ctrl+中键同样反向。 选「继续阅读」但这本书还没读过时,会退回详情页。 ", -1)) ]) ], 64)) : active.value === "search" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 2 }, [ - _cache[46] || (_cache[46] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "搜索", -1)), - vue.createElementVNode("label", _hoisted_16$1, [ - _cache[43] || (_cache[43] = vue.createElementVNode("span", { class: "fqa-set-label" }, "接管搜索界面", -1)), + _cache[50] || (_cache[50] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "搜索", -1)), + vue.createElementVNode("label", _hoisted_18$1, [ + _cache[47] || (_cache[47] = vue.createElementVNode("span", { class: "fqa-set-label" }, "接管搜索界面", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[8] || (_cache[8] = ($event) => vue.unref(settings$1).enhanceSearch = $event), + "onUpdate:modelValue": _cache[10] || (_cache[10] = ($event) => vue.unref(settings$1).enhanceSearch = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).enhanceSearch] ]) ]), - vue.createElementVNode("div", _hoisted_17$1, [ - vue.createElementVNode("label", _hoisted_18$1, [ - _cache[44] || (_cache[44] = vue.createElementVNode("span", { class: "fqa-set-label" }, "个人化推荐", -1)), + vue.createElementVNode("div", _hoisted_19$1, [ + vue.createElementVNode("label", _hoisted_20, [ + _cache[48] || (_cache[48] = vue.createElementVNode("span", { class: "fqa-set-label" }, "个人化推荐", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[9] || (_cache[9] = ($event) => vue.unref(settings$1).searchPersonalized = $event), + "onUpdate:modelValue": _cache[11] || (_cache[11] = ($event) => vue.unref(settings$1).searchPersonalized = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).searchPersonalized] ]) ]), - _cache[45] || (_cache[45] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 开启后搜索走同源请求,由浏览器自动带上你的登录 Cookie,番茄据此按阅读偏好排序。 凭据不经过脚本,也不会发往番茄以外的任何地方。关闭时走匿名请求。 ", -1)) + _cache[49] || (_cache[49] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 开启后搜索走同源请求,由浏览器自动带上你的登录 Cookie,番茄据此按阅读偏好排序。 凭据不经过脚本,也不会发往番茄以外的任何地方。关闭时走匿名请求。 ", -1)) ]) ], 64)) : active.value === "download" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 3 }, [ - _cache[67] || (_cache[67] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "下载", -1)), - vue.createElementVNode("label", _hoisted_19$1, [ - _cache[47] || (_cache[47] = vue.createElementVNode("span", { class: "fqa-set-label" }, "显示下载入口", -1)), + _cache[71] || (_cache[71] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "下载", -1)), + vue.createElementVNode("label", _hoisted_21, [ + _cache[51] || (_cache[51] = vue.createElementVNode("span", { class: "fqa-set-label" }, "显示下载入口", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[10] || (_cache[10] = ($event) => vue.unref(settings$1).enableDownload = $event), + "onUpdate:modelValue": _cache[12] || (_cache[12] = ($event) => vue.unref(settings$1).enableDownload = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).enableDownload] ]) ]), - vue.createElementVNode("div", _hoisted_20, [ - _cache[50] || (_cache[50] = vue.createElementVNode("span", { class: "fqa-set-label" }, "默认格式", -1)), - vue.createElementVNode("div", _hoisted_21, [ - vue.createElementVNode("label", _hoisted_22, [ + vue.createElementVNode("div", _hoisted_22, [ + _cache[54] || (_cache[54] = vue.createElementVNode("span", { class: "fqa-set-label" }, "默认格式", -1)), + vue.createElementVNode("div", _hoisted_23, [ + vue.createElementVNode("label", _hoisted_24, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[11] || (_cache[11] = ($event) => vue.unref(settings$1).downloadFormat = $event), + "onUpdate:modelValue": _cache[13] || (_cache[13] = ($event) => vue.unref(settings$1).downloadFormat = $event), type: "radio", value: "epub" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadFormat] ]), - _cache[48] || (_cache[48] = vue.createElementVNode("span", null, "EPUB", -1)) + _cache[52] || (_cache[52] = vue.createElementVNode("span", null, "EPUB", -1)) ]), - vue.createElementVNode("label", _hoisted_23, [ + vue.createElementVNode("label", _hoisted_25, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[12] || (_cache[12] = ($event) => vue.unref(settings$1).downloadFormat = $event), + "onUpdate:modelValue": _cache[14] || (_cache[14] = ($event) => vue.unref(settings$1).downloadFormat = $event), type: "radio", value: "txt" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadFormat] ]), - _cache[49] || (_cache[49] = vue.createElementVNode("span", null, "TXT", -1)) + _cache[53] || (_cache[53] = vue.createElementVNode("span", null, "TXT", -1)) ]) ]), - _cache[51] || (_cache[51] = vue.createElementVNode("p", { class: "fqa-set-note" }, " EPUB 保留原始排版、插图与分卷目录;TXT 是纯文本。右键菜单里可以单次指定格式。 ", -1)) + _cache[55] || (_cache[55] = vue.createElementVNode("p", { class: "fqa-set-note" }, " EPUB 保留原始排版、插图与分卷目录;TXT 是纯文本。右键菜单里可以单次指定格式。 ", -1)) ]), - vue.createElementVNode("div", _hoisted_24, [ - _cache[54] || (_cache[54] = vue.createElementVNode("span", { class: "fqa-set-label" }, "TXT 编码", -1)), - vue.createElementVNode("div", _hoisted_25, [ - vue.createElementVNode("label", _hoisted_26, [ + vue.createElementVNode("div", _hoisted_26, [ + _cache[58] || (_cache[58] = vue.createElementVNode("span", { class: "fqa-set-label" }, "TXT 编码", -1)), + vue.createElementVNode("div", _hoisted_27, [ + vue.createElementVNode("label", _hoisted_28, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[13] || (_cache[13] = ($event) => vue.unref(settings$1).downloadCharset = $event), + "onUpdate:modelValue": _cache[15] || (_cache[15] = ($event) => vue.unref(settings$1).downloadCharset = $event), type: "radio", value: "utf-8" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadCharset] ]), - _cache[52] || (_cache[52] = vue.createElementVNode("span", null, "UTF-8", -1)) + _cache[56] || (_cache[56] = vue.createElementVNode("span", null, "UTF-8", -1)) ]), - vue.createElementVNode("label", _hoisted_27, [ + vue.createElementVNode("label", _hoisted_29, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[14] || (_cache[14] = ($event) => vue.unref(settings$1).downloadCharset = $event), + "onUpdate:modelValue": _cache[16] || (_cache[16] = ($event) => vue.unref(settings$1).downloadCharset = $event), type: "radio", value: "gbk" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).downloadCharset] ]), - _cache[53] || (_cache[53] = vue.createElementVNode("span", null, "GBK", -1)) + _cache[57] || (_cache[57] = vue.createElementVNode("span", null, "GBK", -1)) ]) ]), - _cache[55] || (_cache[55] = vue.createElementVNode("p", { class: "fqa-set-note" }, "EPUB 固定使用 UTF-8。老设备或部分阅读器可能需要 GBK。", -1)) + _cache[59] || (_cache[59] = vue.createElementVNode("p", { class: "fqa-set-note" }, "EPUB 固定使用 UTF-8。老设备或部分阅读器可能需要 GBK。", -1)) ]), - vue.createElementVNode("div", _hoisted_28, [ - _cache[59] || (_cache[59] = vue.createElementVNode("span", { class: "fqa-set-label" }, "EPUB 选项", -1)), - vue.createElementVNode("label", _hoisted_29, [ - _cache[56] || (_cache[56] = vue.createElementVNode("span", { class: "fqa-set-label" }, "下载正文插图", -1)), + vue.createElementVNode("div", _hoisted_30, [ + _cache[63] || (_cache[63] = vue.createElementVNode("span", { class: "fqa-set-label" }, "EPUB 选项", -1)), + vue.createElementVNode("label", _hoisted_31, [ + _cache[60] || (_cache[60] = vue.createElementVNode("span", { class: "fqa-set-label" }, "下载正文插图", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[15] || (_cache[15] = ($event) => vue.unref(settings$1).downloadImages = $event), + "onUpdate:modelValue": _cache[17] || (_cache[17] = ($event) => vue.unref(settings$1).downloadImages = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadImages] ]) ]), - vue.createElementVNode("label", _hoisted_30, [ - _cache[57] || (_cache[57] = vue.createElementVNode("span", { class: "fqa-set-label" }, "保留书籍排版样式", -1)), + vue.createElementVNode("label", _hoisted_32, [ + _cache[61] || (_cache[61] = vue.createElementVNode("span", { class: "fqa-set-label" }, "保留书籍排版样式", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[16] || (_cache[16] = ($event) => vue.unref(settings$1).downloadBookCss = $event), + "onUpdate:modelValue": _cache[18] || (_cache[18] = ($event) => vue.unref(settings$1).downloadBookCss = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadBookCss] ]) ]), - vue.createElementVNode("label", _hoisted_31, [ - _cache[58] || (_cache[58] = vue.createElementVNode("span", { class: "fqa-set-label" }, "为每卷生成卷页", -1)), + vue.createElementVNode("label", _hoisted_33, [ + _cache[62] || (_cache[62] = vue.createElementVNode("span", { class: "fqa-set-label" }, "为每卷生成卷页", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[17] || (_cache[17] = ($event) => vue.unref(settings$1).downloadVolumePage = $event), + "onUpdate:modelValue": _cache[19] || (_cache[19] = ($event) => vue.unref(settings$1).downloadVolumePage = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).downloadVolumePage] ]) ]), - _cache[60] || (_cache[60] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 插图逐张下载,图多的书会明显变慢、文件也更大;关闭后正文里仍保留图片地址,联网可看。 ", -1)) + _cache[64] || (_cache[64] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 插图逐张下载,图多的书会明显变慢、文件也更大;关闭后正文里仍保留图片地址,联网可看。 ", -1)) ]), - vue.createElementVNode("div", _hoisted_32, [ - _cache[64] || (_cache[64] = vue.createElementVNode("span", { class: "fqa-set-label" }, "请求节奏", -1)), - _cache[65] || (_cache[65] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 接口对批量正文有限制,调得太激进会导致大量章节抓不到甚至触发风控。不清楚就别改。 ", -1)), - vue.createElementVNode("label", _hoisted_33, [ - _cache[61] || (_cache[61] = vue.createElementVNode("span", null, "每批章节数(1-30)", -1)), + vue.createElementVNode("div", _hoisted_34, [ + _cache[68] || (_cache[68] = vue.createElementVNode("span", { class: "fqa-set-label" }, "请求节奏", -1)), + _cache[69] || (_cache[69] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 接口对批量正文有限制,调得太激进会导致大量章节抓不到甚至触发风控。不清楚就别改。 ", -1)), + vue.createElementVNode("label", _hoisted_35, [ + _cache[65] || (_cache[65] = vue.createElementVNode("span", null, "每批章节数(1-30)", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[18] || (_cache[18] = ($event) => vue.unref(settings$1).downloadBatchSize = $event), + "onUpdate:modelValue": _cache[20] || (_cache[20] = ($event) => vue.unref(settings$1).downloadBatchSize = $event), class: "fqa-set-input", type: "number", min: "1", @@ -5765,10 +6251,10 @@ ] ]) ]), - vue.createElementVNode("label", _hoisted_34, [ - _cache[62] || (_cache[62] = vue.createElementVNode("span", null, "批次间隔 (ms)", -1)), + vue.createElementVNode("label", _hoisted_36, [ + _cache[66] || (_cache[66] = vue.createElementVNode("span", null, "批次间隔 (ms)", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[19] || (_cache[19] = ($event) => vue.unref(settings$1).downloadInterval = $event), + "onUpdate:modelValue": _cache[21] || (_cache[21] = ($event) => vue.unref(settings$1).downloadInterval = $event), class: "fqa-set-input", type: "number", min: "0", @@ -5783,10 +6269,10 @@ ] ]) ]), - vue.createElementVNode("label", _hoisted_35, [ - _cache[63] || (_cache[63] = vue.createElementVNode("span", null, "重试轮数", -1)), + vue.createElementVNode("label", _hoisted_37, [ + _cache[67] || (_cache[67] = vue.createElementVNode("span", null, "重试轮数", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[20] || (_cache[20] = ($event) => vue.unref(settings$1).downloadRetries = $event), + "onUpdate:modelValue": _cache[22] || (_cache[22] = ($event) => vue.unref(settings$1).downloadRetries = $event), class: "fqa-set-input", type: "number", min: "0", @@ -5800,7 +6286,7 @@ ] ]) ]), - _cache[66] || (_cache[66] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 实测单次请求最多返回 30 章正文,间隔小于约 750ms 会被限流成每次 1 章。 ", -1)), + _cache[70] || (_cache[70] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 实测单次请求最多返回 30 章正文,间隔小于约 750ms 会被限流成每次 1 章。 ", -1)), vue.createElementVNode("div", { class: "fqa-set-actions" }, [ vue.createElementVNode("button", { class: "fqa-set-btn", @@ -5809,123 +6295,123 @@ ]) ]) ], 64)) : active.value === "audiobook" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 4 }, [ - _cache[73] || (_cache[73] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "听书", -1)), - vue.createElementVNode("div", _hoisted_36, [ - _cache[70] || (_cache[70] = vue.createElementVNode("span", { class: "fqa-set-label" }, "读完一章后", -1)), - vue.createElementVNode("div", _hoisted_37, [ - vue.createElementVNode("label", _hoisted_38, [ + _cache[77] || (_cache[77] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "听书", -1)), + vue.createElementVNode("div", _hoisted_38, [ + _cache[74] || (_cache[74] = vue.createElementVNode("span", { class: "fqa-set-label" }, "读完一章后", -1)), + vue.createElementVNode("div", _hoisted_39, [ + vue.createElementVNode("label", _hoisted_40, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[21] || (_cache[21] = ($event) => vue.unref(settings$1).audiobookChapterEnd = $event), + "onUpdate:modelValue": _cache[23] || (_cache[23] = ($event) => vue.unref(settings$1).audiobookChapterEnd = $event), type: "radio", value: "next" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).audiobookChapterEnd] ]), - _cache[68] || (_cache[68] = vue.createElementVNode("span", null, "自动下一章", -1)) + _cache[72] || (_cache[72] = vue.createElementVNode("span", null, "自动下一章", -1)) ]), - vue.createElementVNode("label", _hoisted_39, [ + vue.createElementVNode("label", _hoisted_41, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[22] || (_cache[22] = ($event) => vue.unref(settings$1).audiobookChapterEnd = $event), + "onUpdate:modelValue": _cache[24] || (_cache[24] = ($event) => vue.unref(settings$1).audiobookChapterEnd = $event), type: "radio", value: "stop" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).audiobookChapterEnd] ]), - _cache[69] || (_cache[69] = vue.createElementVNode("span", null, "停止播放", -1)) + _cache[73] || (_cache[73] = vue.createElementVNode("span", null, "停止播放", -1)) ]) ]), - _cache[71] || (_cache[71] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 自动下一章会点击页面上的「下一章」,读到全书最后一章时自动停下。 ", -1)) + _cache[75] || (_cache[75] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 自动下一章会点击页面上的「下一章」,读到全书最后一章时自动停下。 ", -1)) ]), - vue.createElementVNode("label", _hoisted_40, [ - _cache[72] || (_cache[72] = vue.createElementVNode("span", { class: "fqa-set-label" }, "高亮并跟随朗读段落", -1)), + vue.createElementVNode("label", _hoisted_42, [ + _cache[76] || (_cache[76] = vue.createElementVNode("span", { class: "fqa-set-label" }, "高亮并跟随朗读段落", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[23] || (_cache[23] = ($event) => vue.unref(settings$1).audiobookFollow = $event), + "onUpdate:modelValue": _cache[25] || (_cache[25] = ($event) => vue.unref(settings$1).audiobookFollow = $event), type: "checkbox", class: "fqa-set-switch" }, null, 512), [ [vue.vModelCheckbox, vue.unref(settings$1).audiobookFollow] ]) ]), - _cache[74] || (_cache[74] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 关闭后仍会高亮当前段落,但不再自动滚动页面。点击任意段落可跳到该处朗读。 ", -1)) + _cache[78] || (_cache[78] = vue.createElementVNode("p", { class: "fqa-set-note" }, " 关闭后仍会高亮当前段落,但不再自动滚动页面。点击任意段落可跳到该处朗读。 ", -1)) ], 64)) : active.value === "protocol" ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 5 }, [ - _cache[84] || (_cache[84] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "协议", -1)), - vue.createElementVNode("div", _hoisted_41, [ - _cache[77] || (_cache[77] = vue.createElementVNode("span", { class: "fqa-set-label" }, "API 偏好", -1)), - vue.createElementVNode("div", _hoisted_42, [ - vue.createElementVNode("label", _hoisted_43, [ + _cache[88] || (_cache[88] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "协议", -1)), + vue.createElementVNode("div", _hoisted_43, [ + _cache[81] || (_cache[81] = vue.createElementVNode("span", { class: "fqa-set-label" }, "API 偏好", -1)), + vue.createElementVNode("div", _hoisted_44, [ + vue.createElementVNode("label", _hoisted_45, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[24] || (_cache[24] = ($event) => vue.unref(settings$1).apiPreference = $event), + "onUpdate:modelValue": _cache[26] || (_cache[26] = ($event) => vue.unref(settings$1).apiPreference = $event), type: "radio", value: "app" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).apiPreference] ]), - _cache[75] || (_cache[75] = vue.createElementVNode("span", null, "番茄 APP", -1)) + _cache[79] || (_cache[79] = vue.createElementVNode("span", null, "番茄 APP", -1)) ]), - vue.createElementVNode("label", _hoisted_44, [ + vue.createElementVNode("label", _hoisted_46, [ vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[25] || (_cache[25] = ($event) => vue.unref(settings$1).apiPreference = $event), + "onUpdate:modelValue": _cache[27] || (_cache[27] = ($event) => vue.unref(settings$1).apiPreference = $event), type: "radio", value: "redcandle" }, null, 512), [ [vue.vModelRadio, vue.unref(settings$1).apiPreference] ]), - _cache[76] || (_cache[76] = vue.createElementVNode("span", null, "红烛 APP", -1)) + _cache[80] || (_cache[80] = vue.createElementVNode("span", null, "红烛 APP", -1)) ]) ]), - _cache[78] || (_cache[78] = vue.createElementVNode("p", { class: "fqa-set-note" }, "如果某协议数据不全,脚本可能会选择其他接口作为补充。", -1)) + _cache[82] || (_cache[82] = vue.createElementVNode("p", { class: "fqa-set-note" }, "如果某协议数据不全,脚本可能会选择其他接口作为补充。", -1)) ]), - vue.createElementVNode("div", _hoisted_45, [ - _cache[82] || (_cache[82] = vue.createElementVNode("span", { class: "fqa-set-label" }, "设备信息", -1)), - _cache[83] || (_cache[83] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 如果不知道这是什么,请保持默认。乱填可能导致脚本功能异常。 ", -1)), - vue.createElementVNode("label", _hoisted_46, [ - _cache[79] || (_cache[79] = vue.createElementVNode("span", null, "device_id", -1)), + vue.createElementVNode("div", _hoisted_47, [ + _cache[86] || (_cache[86] = vue.createElementVNode("span", { class: "fqa-set-label" }, "设备信息", -1)), + _cache[87] || (_cache[87] = vue.createElementVNode("p", { class: "fqa-set-warn" }, " 如果不知道这是什么,请保持默认。乱填可能导致脚本功能异常。 ", -1)), + vue.createElementVNode("label", _hoisted_48, [ + _cache[83] || (_cache[83] = vue.createElementVNode("span", null, "device_id", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[26] || (_cache[26] = ($event) => vue.unref(settings$1).deviceId = $event), + "onUpdate:modelValue": _cache[28] || (_cache[28] = ($event) => vue.unref(settings$1).deviceId = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.device_id || "自动注册" - }, null, 8, _hoisted_47), [ + }, null, 8, _hoisted_49), [ [vue.vModelText, vue.unref(settings$1).deviceId] ]) ]), - vue.createElementVNode("label", _hoisted_48, [ - _cache[80] || (_cache[80] = vue.createElementVNode("span", null, "install_id (iid)", -1)), + vue.createElementVNode("label", _hoisted_50, [ + _cache[84] || (_cache[84] = vue.createElementVNode("span", null, "install_id (iid)", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[27] || (_cache[27] = ($event) => vue.unref(settings$1).installId = $event), + "onUpdate:modelValue": _cache[29] || (_cache[29] = ($event) => vue.unref(settings$1).installId = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.install_id || "自动注册" - }, null, 8, _hoisted_49), [ + }, null, 8, _hoisted_51), [ [vue.vModelText, vue.unref(settings$1).installId] ]) ]), - vue.createElementVNode("label", _hoisted_50, [ - _cache[81] || (_cache[81] = vue.createElementVNode("span", null, "device_type", -1)), + vue.createElementVNode("label", _hoisted_52, [ + _cache[85] || (_cache[85] = vue.createElementVNode("span", null, "device_type", -1)), vue.withDirectives(vue.createElementVNode("input", { - "onUpdate:modelValue": _cache[28] || (_cache[28] = ($event) => vue.unref(settings$1).deviceType = $event), + "onUpdate:modelValue": _cache[30] || (_cache[30] = ($event) => vue.unref(settings$1).deviceType = $event), class: "fqa-set-input", type: "text", placeholder: currentDevice.value.device_type || "自动注册" - }, null, 8, _hoisted_51), [ + }, null, 8, _hoisted_53), [ [vue.vModelText, vue.unref(settings$1).deviceType] ]) ]), - vue.createElementVNode("div", _hoisted_52, [ + vue.createElementVNode("div", _hoisted_54, [ vue.createElementVNode("button", { class: "fqa-set-btn", disabled: registering.value, onClick: reRegister - }, vue.toDisplayString(registering.value ? "注册中…" : "重新注册"), 9, _hoisted_53), - registerMsg.value ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_54, vue.toDisplayString(registerMsg.value), 1)) : vue.createCommentVNode("", true) + }, vue.toDisplayString(registering.value ? "注册中…" : "重新注册"), 9, _hoisted_55), + registerMsg.value ? (vue.openBlock(), vue.createElementBlock("span", _hoisted_56, vue.toDisplayString(registerMsg.value), 1)) : vue.createCommentVNode("", true) ]) ]) ], 64)) : (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 6 }, [ - _cache[89] || (_cache[89] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "关于", -1)), - vue.createElementVNode("p", _hoisted_55, "番茄小说助手 v" + vue.toDisplayString(vue.unref(version)), 1), - vue.createElementVNode("div", _hoisted_56, [ + _cache[93] || (_cache[93] = vue.createElementVNode("h3", { class: "fqa-set-h" }, "关于", -1)), + vue.createElementVNode("p", _hoisted_57, "番茄小说助手 v" + vue.toDisplayString(vue.unref(version)), 1), + vue.createElementVNode("div", _hoisted_58, [ vue.createElementVNode("div", { class: "fqa-set-link-row" }, [ - _cache[85] || (_cache[85] = vue.createElementVNode("span", null, "GreasyFork 地址:", -1)), + _cache[89] || (_cache[89] = vue.createElementVNode("span", null, "GreasyFork 地址:", -1)), vue.createElementVNode("a", { href: GREASYFORK, target: "_blank", @@ -5933,31 +6419,31 @@ }, "跳转") ]), vue.createElementVNode("div", { class: "fqa-set-link-row" }, [ - _cache[86] || (_cache[86] = vue.createElementVNode("span", null, "GitHub 地址:", -1)), + _cache[90] || (_cache[90] = vue.createElementVNode("span", null, "GitHub 地址:", -1)), vue.createElementVNode("a", { href: GITHUB, target: "_blank", rel: "noreferrer noopener" }, "跳转") ]), - vue.createElementVNode("div", _hoisted_57, [ - _cache[88] || (_cache[88] = vue.createElementVNode("span", null, "问题反馈:", -1)), + vue.createElementVNode("div", _hoisted_59, [ + _cache[92] || (_cache[92] = vue.createElementVNode("span", null, "问题反馈:", -1)), vue.createElementVNode("a", { href: FEEDBACK, target: "_blank", rel: "noreferrer noopener" }, "GreasyFork"), vue.createElementVNode("span", null, [ - _cache[87] || (_cache[87] = vue.createTextVNode(" 或 ", -1)), + _cache[91] || (_cache[91] = vue.createTextVNode(" 或 ", -1)), vue.createElementVNode("a", { href: `${GITHUB}/issues`, target: "_blank", rel: "noreferrer noopener" - }, " GitHub Issues ", 8, _hoisted_58) + }, " GitHub Issues ", 8, _hoisted_60) ]) ]) ]), - _cache[90] || (_cache[90] = vue.createElementVNode("div", { class: "fqa-set-license" }, [ + _cache[94] || (_cache[94] = vue.createElementVNode("div", { class: "fqa-set-license" }, [ vue.createElementVNode("p", null, " 本脚本基于 GNU General Public License 3.0 授权,完全开源且免费,修改/二次开发请注意遵守开源协议。 "), vue.createElementVNode("p", null, "本脚本使用 TypeScript + Vue 开发,请避免直接修改编译产物。") ], -1)) @@ -6058,6 +6544,20 @@ }); secondDiv.insertAdjacentElement("afterend", thirdDiv); menuInner.appendChild(createMenuItem("助手设置", settings, openSettings)); + menuInner.childNodes.forEach((node) => { + var _a, _b; + if (((_b = (_a = node.lastChild) == null ? void 0 : _a.textContent) == null ? void 0 : _b.trim()) === "退出登录") { + const original = node; + const replaced = cloneElement(node); + original.classList.add("fqa-hide"); + original.insertAdjacentElement("afterend", replaced); + replaced.addEventListener("click", (_) => { + if (!settings$1.logoutConfirm || unsafeWindow.confirm("确认退出登录吗?")) { + original.click(); + } + }); + } + }); }; const scan = (root) => { if (root.classList.contains("arco-menu-inner")) { @@ -6742,134 +7242,6 @@ }; } }); - const SHELF_BASE = "https://fanqienovel.com/reading/bookapi/bookshelf"; - async function getBookshelf() { - const responses = await Promise.all([ - fetch$1(SHELF_BASE + "/info/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700"), - fetch$1("https://fanqienovel.com/api/reader/book/progress") - ]); - const response = responses[0]; - const response2 = responses[1]; - const data = await response.json(); - const info = data.data.book_shelf_info; - const data_ = await response2.json(); - const data2 = data_.data; - const all_items = info.map((item) => item.book_id); - const response3 = await fetch$1("https://fanqienovel.com/api/book/simple/info", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - book_ids: all_items - }) - }); - const data__ = await response3.json(); - const data3 = data__.data.bookList; - const results = []; - for (let i2 of data.data.book_shelf_info) { - const progress = data2.find((it) => it.book_id === i2.book_id); - const simple = data3.find((it) => it.book_id === i2.book_id); - const item = { - book_id: i2.book_id, - last_operate_time: progress ? progress.read_timestamp : i2.last_operate_time, - add_shelf_time: i2.add_shelf_time, - group_name: i2.group_name, - last_read_timestamp: progress ? progress.read_timestamp : 0, - last_read_chapter_id: progress ? progress.item_id : "0", - is_publish: (simple == null ? void 0 : simple.genre) === "6" - }; - results.push(item); - } - return results; - } - async function multidetail(books) { - const response = await fetch$1("https://fanqienovel.com/api/bookshelf/multidetail", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - books: books.map((book) => { - return { - book_id: book.book_id, - item_id: book.last_read_chapter_id - }; - }) - }) - }); - const data = await response.json(); - const list = data.data.detail_list; - const results = []; - for (let i2 of list) { - const item = { - book_id: i2.book_id, - summary: i2.abstract, - title: i2.book_name, - author: i2.author_name, - cover_url: i2.thumb_url, - current_chapter_title: i2.item_show_title, - current_chapter_id: i2.item_id, - current_chapter_order: i2.real_chapter_order, - total_chapter_count: i2.serial_count, - last_chapter_update_time: i2.last_chapter_update_time * 1e3, - last_chapter_id: i2.last_chapter_item_id, - current_chapter_summary: i2.item_abstract, - // last_chapter_title: i.last_chapter_show_title, - status: i2.update_stop === "1" ? 4 : i2.creation_status, - update_status: i2.update_status - }; - results.push(item); - } - return results; - } - function identify(bookId2, modifyTime = 0) { - return { - asterisked: false, - book_id: bookId2, - book_type: 0, - modify_time: modifyTime - }; - } - async function shelfPost(path, body) { - const res = await fetch$1(`${SHELF_BASE}${path}/v?aid=1967`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify(body) - }); - const json = await res.json().catch(() => null); - if (!res.ok || json && json.code !== 0 && json.code !== void 0) { - throw new Error(`书架操作失败(${path}): ${(json == null ? void 0 : json.message) ?? res.status}`); - } - return json; - } - async function addToBookshelf(bookId2) { - await shelfPost("/add", { - add_book_source: 0, - identify_data: [identify(bookId2)] - }); - } - async function removeFromBookshelf(bookId2) { - await shelfPost("/delete", { - identify_data: [identify(bookId2, Date.now())] - }); - } - async function moveToGroup(bookId2, groupName) { - await shelfPost("/update", { - book_data: [ - { - asterisked: false, - book_id: bookId2, - book_type: 0, - group_name: groupName, - has_shown: false, - is_pin: false, - modify_time: Date.now() - } - ] - }); - } const DETAIL_BATCH_SIZE = 20; const PAGE_SIZE = 24; const TABS = [ @@ -10191,8 +10563,8 @@ ${body} } ]; const hooks = [ - ..._exports$5, ..._exports$4, + ..._exports$5, ..._exports$3, ..._exports$2, ..._exports$1, diff --git a/src/api/bookshelf.ts b/src/api/bookshelf.ts index e08543e..0c4d7a5 100644 --- a/src/api/bookshelf.ts +++ b/src/api/bookshelf.ts @@ -148,3 +148,9 @@ export async function moveToGroup(bookId: string, groupName: string): Promise { + const response = await fetch(`https://fanqienovel.com/reading/bookapi/bookshelf/check/v:version/?aid=1967&iid=0&version_code=57700&update_version_code=57700&book_id=${book_id}`) + const data = await response.json() + return Boolean(data.data) +} \ No newline at end of file diff --git a/src/api/catalog.ts b/src/api/catalog.ts index f7ef1ff..faca8c4 100644 --- a/src/api/catalog.ts +++ b/src/api/catalog.ts @@ -14,7 +14,7 @@ export async function getCatalogRaw(bookId: string): Promise> { const j: any = response.json() const items = j?.data?.item_data_list if (j?.code !== 0 || !Array.isArray(items) || items.length === 0) { - throw new Error('Empty catalog') + return [null, null] } return [items, items.map((it: any) => String(it.item_id))] } @@ -39,25 +39,29 @@ export async function webCatalog(bookId: string): Promise> { "firstPassTime": "1749281928" }, */ - const volmap: Record> = {} + const cs: Array = [] const vname: string[] = d.volumeNameList for (let i = 0; i < vname.length; i++) { const volumeName = vname[i] if (volumeName !== undefined) { - volmap[volumeName] = d.chapterListWithVolume[i] + // volmap[volumeName] = d.chapterListWithVolume[i] + cs.push(...d.chapterListWithVolume[i]) } } - return [volmap, allItems] + return [cs, allItems] } export async function getCatalog(bookId: string): Promise { const r = await getCatalogRaw(bookId) let catalogRaw = r[0] as any[] // item_data_list let allItemIds = r[1] as string[] // all item ids + console.log('catalogRaw', catalogRaw, 'allItemIds', allItemIds) if (!catalogRaw || !allItemIds) { const rw = await webCatalog(bookId) catalogRaw = rw[0] allItemIds = rw[1] + console.log('webCatalog', catalogRaw, 'allItemIds', allItemIds) + // console.log('catalogRaw', catalogRaw, 'allItemIds', allItemIds) } const vmap: Record = {} // title -> VolumeItem const chapters: Array = [] diff --git a/src/api/device.ts b/src/api/device.ts index 757b35b..70c62b1 100644 --- a/src/api/device.ts +++ b/src/api/device.ts @@ -13,6 +13,20 @@ 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' +/** + * 设备协议请求一律不带 Cookie。 + * + * GM_xmlhttpRequest 默认会带上扩展上下文里属于这些域名的 Cookie + * (store-region、install_id、ttreq 之类),那是浏览器的痕迹,APP 的 okhttp 不带。 + * 而且「注册一台新设备」还带着已有的 install_id cookie 本身自相矛盾。 + * anonymous 让 GM 不发 Cookie —— 这一点在页面里用 fetch/XHR 反而做不到。 + * + * 去不掉的另一些:Sec-Fetch-*、Priority、Accept-Encoding、Accept-Language + * 是 Chromium 按请求上下文自己加的禁止头,JS(含扩展)都改不了, + * 要完全消除只能让请求离开浏览器(原生客户端或服务端代理)。 + */ +const APP_REQUEST = { credentials: 'omit' } as const + /** 设备注册结果 */ export interface RegisteredDevice { device_id: string; @@ -56,6 +70,7 @@ export async function registerDevice(): Promise { ) const res = await apiFetch(REGISTER_URL, { + ...APP_REQUEST, method: 'POST', headers: { 'User-Agent': 'okhttp/4.10.0', @@ -107,6 +122,7 @@ export async function activatePremium(device: RegisteredDevice): Promise try { const headers = await signRequest(url, body) const res = await apiFetch(url, { + ...APP_REQUEST, method: 'POST', headers: { ...headers, @@ -166,6 +182,7 @@ export async function registerKey(device: RegisteredDevice): Promise { // 签名针对 gzip 之后的字节,与 Go 里 SignedRequest 收到压缩流的行为一致 const headers = await signRequest(url, gzipped) const res = await apiFetch(url, { + ...APP_REQUEST, method: 'POST', headers: { ...headers, diff --git a/src/api/user.ts b/src/api/user.ts index e017814..fabcbd5 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -77,7 +77,7 @@ export async function checkLogin() { desc: j?.data?.desc, age: j?.data?.age, } - if (j?.data?.id > 1) { + if (j.code !== -1) { userState.isLogin = true userState.userInfo = _userInfo write('userState', userState) diff --git a/src/assets/audiobook.css b/src/assets/audiobook.css index 49298b3..a041468 100644 --- a/src/assets/audiobook.css +++ b/src/assets/audiobook.css @@ -17,9 +17,12 @@ color: var(--fqa-audio-text); } -/* 页面切到夜间模式时整体反色 */ -.muye-reader-dark .fqa-audio-root, -.fqa-audio-root.fqa-audio-dark { +/* + * 暗色反色。悬浮栏挂在 body 下,够不到 .muye-reader-dark 的后代选择器, + * 所以 audioPanel 在 #fqa-audio-root 容器上打 .fqa-audio-dark 类; + * 真正的 .fqa-audio-root(Vue 根)是容器的子节点,用后代选择器作用到它。 + */ +.fqa-audio-dark .fqa-audio-root { --fqa-audio-bg: #2b2b2b; --fqa-audio-text: #b3b3b3; --fqa-audio-sub: #7a7a7a; diff --git a/src/assets/settings.svg b/src/assets/settings.svg index 927674c..2477f76 100644 --- a/src/assets/settings.svg +++ b/src/assets/settings.svg @@ -2,8 +2,8 @@ + fill="currentColor" /> + fill="currentColor" /> \ No newline at end of file diff --git a/src/hooks/fetchHook.ts b/src/hooks/fetchHook.ts index 8ba21a2..c1fa337 100644 --- a/src/hooks/fetchHook.ts +++ b/src/hooks/fetchHook.ts @@ -1,6 +1,51 @@ import type { HookConfig } from "../config" import { settings } from "../settings" +// --------------------------------------------------------------------------- +// 功能性的请求拦截 / 响应改写层。 +// +// 与下面的 blockReport 黑名单不同:这里是「有目的、主动」的拦截, +// 由模块自己往 modifiers 里注册规则,不随设置开关而变化。 +// 每一条规则可以: +// - should_break:不把请求发给服务器,直接用 make_response 造一个本地响应; +// - modify_response:照常发请求,等真实响应回来后按需改写(body/status/headers)。 +// 两者按 modifiers 声明顺序叠加,前一条的输出是后一条的输入。 +// --------------------------------------------------------------------------- + +export interface SimpleResponse { + /** + * 响应体。给字符串就按 UTF-8 编码,省得每条规则都自己 TextEncoder 一遍。 + * 读进来的(modify_response 的入参)一律是 ArrayBuffer, + * 要按文本处理用下面的 bodyToText。 + */ + responseBody: ArrayBuffer | string + statusCode: number + responseHeaders: Record +} + +/** 内部流转用:responseBody 已经归一成 ArrayBuffer */ +interface ResolvedResponse extends SimpleResponse { + responseBody: ArrayBuffer +} + +export interface ResponseModifier { + matcher: (url: string) => boolean // 匹配规则 + should_break: (url: string) => boolean // 是否中断请求(不发给服务器) + make_response?: (url: string) => SimpleResponse // 中断请求后该返回什么响应(should_break为true时) + modify_response?: (url: string, response: SimpleResponse) => SimpleResponse // 服务器响应后应该怎么改(should_break为false时) +} + +/** 注册一条响应修改规则。返回一个取消函数,方便需要临时挂载的模块 */ +export function addResponseModifier(modifier: ResponseModifier): () => void { + modifiers.push(modifier) + return () => { + const i = modifiers.indexOf(modifier) + if (i >= 0) modifiers.splice(i, 1) + } +} + +export const modifiers: ResponseModifier[] = [] + // disable report const blackList = [ 'mcs.zijieapi.com', @@ -22,9 +67,79 @@ function checkBlack(url: string): boolean { return blackList.some(black => url.includes(black)) } +// ----------------------------- 工具 ------------------------------------- + +/** 拷贝到一个本地 realm 的精确尺寸 ArrayBuffer,避免跨 realm 共享底块 */ +function toLocalArrayBuffer(buf: ArrayBuffer | ArrayBufferView): ArrayBuffer { + const bytes = + buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) + const out = new ArrayBuffer(bytes.byteLength) + new Uint8Array(out).set(bytes) + return out +} + +function textToBody(text: string): ArrayBuffer { + return toLocalArrayBuffer(new TextEncoder().encode(text)) +} + +/** 响应体转文本。给 modifier 作者用,免得自己判断联合类型 */ +export function bodyToText(body: ArrayBuffer | string): string { + if (typeof body === 'string') return body + return new TextDecoder().decode(body) +} + +/** 响应体转 ArrayBuffer。字符串按 UTF-8 编码 */ +export function bodyToBuffer(body: ArrayBuffer | string): ArrayBuffer { + if (typeof body === 'string') return textToBody(body) + return body +} + +/** 把规则产出的响应归一成内部形态,只在这里做一次编码 */ +function resolveSimple(simple: SimpleResponse): ResolvedResponse { + return { ...simple, responseBody: bodyToBuffer(simple.responseBody) } +} + +/** 命中当前 URL 的全部修饰器 */ +function matchingModifiers(url: string): ResponseModifier[] { + return modifiers.filter(m => m.matcher(url)) +} + +/** 依次套用 modify_response(只对定义了它的修饰器) */ +function applyModifiers(url: string, matched: ResponseModifier[], simple: SimpleResponse): SimpleResponse { + let current = simple + for (const m of matched) { + if (m.modify_response) current = m.modify_response(url, current) + } + return current +} + +/** 状态码不允许带 body 的响应,构造 Response 时给 null 避免抛错 */ +function responseAllowsBody(status: number): boolean { + return status !== 204 && status !== 205 && status !== 304 +} + +function simpleToResponse(simple: SimpleResponse): Response { + return new Response(responseAllowsBody(simple.statusCode) ? simple.responseBody : null, { + status: simple.statusCode, + headers: simple.responseHeaders, + }) +} + +/** 真实 Response -> SimpleResponse(不消费原 body,调用方自行决定是否要克隆) */ +async function responseToSimple(res: Response): Promise { + const body = toLocalArrayBuffer(await res.arrayBuffer()) + const headers: Record = {} + res.headers.forEach((value, key) => { + headers[key] = value + }) + return { responseBody: body, statusCode: res.status, responseHeaders: headers } +} + +// --------------------------------- fetch -------------------------------- + const originalFetch = unsafeWindow.fetch.bind(unsafeWindow) -unsafeWindow.fetch = function fetch(input: RequestInfo | URL, init?: RequestInit): Promise { +unsafeWindow.fetch = async function fetch(input: RequestInfo | URL, init?: RequestInit): Promise { let url: string if (input instanceof Request) { @@ -37,27 +152,109 @@ unsafeWindow.fetch = function fetch(input: RequestInfo | URL, init?: RequestInit if (checkBlack(url)) { console.log('blocked request: ' + url) - return Promise.resolve(new Response(BLOCKED_BODY, { + return new Response(BLOCKED_BODY, { status: 200, headers: { 'Content-Type': 'application/json' }, - })) + }) } - return originalFetch(input, init) + const matched = matchingModifiers(url) + if (matched.length === 0) return originalFetch(input, init) + + // 中断请求:不发给服务器,就地造一个响应 + const breaker = matched.find(m => m.should_break(url)) + if (breaker?.make_response) { + return simpleToResponse(breaker.make_response(url)) + } + + // 没有命中任何改写规则时就原样透传,避免为无关请求多读一遍 body + if (!matched.some(m => m.modify_response)) return originalFetch(input, init) + + const res = await originalFetch(input, init) + try { + // 克隆后再读,原响应保留给页面(或改写失败时回退用) + const simple = await responseToSimple(res.clone()) + return simpleToResponse(applyModifiers(url, matched, simple)) + } catch (error) { + console.error('[fqa:fetch] 修改响应失败,回退原始响应:', error) + return res + } } +// ----------------------------- XMLHttpRequest ----------------------------- + const originalXMLHttpRequest = unsafeWindow.XMLHttpRequest +const XHR_PROTO = originalXMLHttpRequest.prototype + +// 把原型上的访问器/方法先抄一份,实例上做影子属性时用它绕开自身递归 +const XHR_DESC = { + status: Object.getOwnPropertyDescriptor(XHR_PROTO, 'status')!, + responseText: Object.getOwnPropertyDescriptor(XHR_PROTO, 'responseText')!, + response: Object.getOwnPropertyDescriptor(XHR_PROTO, 'response')!, +} +const XHR_GETALL = XHR_PROTO.getAllResponseHeaders as () => string +const XHR_GETONE = XHR_PROTO.getResponseHeader as (name: string) => string | null + +/** 用原型方法读原始响应头,避开实例上的影子方法 */ +function readXhrHeaders(self: XMLHttpRequest): Record { + const headers: Record = {} + const all = XHR_GETALL.call(self) + if (!all) return headers + for (const line of all.trim().split(/[\r\n]+/)) { + const idx = line.indexOf(':') + if (idx > 0) { + headers[line.slice(0, idx).trim()] = line.slice(idx + 1).trim() + } + } + return headers +} + +/** 用原型方法读原始响应体并按 responseType 归一成 ArrayBuffer */ +function readXhrBody(self: XMLHttpRequest): ArrayBuffer { + const type = self.responseType + if (type === 'arraybuffer') { + return toLocalArrayBuffer(XHR_DESC.response.get!.call(self) as ArrayBuffer) + } + if (type === 'json') { + // responseType=json 时 responseText 会抛错,只能拿解析后的对象再序列化 + const parsed = XHR_DESC.response.get!.call(self) + return textToBody(JSON.stringify(parsed)) + } + return textToBody(XHR_DESC.responseText.get!.call(self) as string) +} unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpRequest { private _blockedUrl?: string + private _fxaUrl = '' + private _fxaMatched: ResponseModifier[] | null = null + private _fxaBreaker: ResponseModifier | null = null + private _fxaApplied = false + private _fxaSimple: ResolvedResponse | null = null open(method: string, url: string, async: boolean = true, user?: string | null, password?: string | null): void { + this._fxaUrl = url + this._fxaMatched = null + this._fxaBreaker = null + this._fxaApplied = false + this._fxaSimple = null + if (checkBlack(url)) { console.log('blocked request: ' + url) this._blockedUrl = url return } this._blockedUrl = undefined + + // 功能性的拦截/改写与 blockReport 无关,独立判定 + const matched = matchingModifiers(url) + const breaker = matched.find(m => m.should_break(url)) + if (breaker?.make_response) { + this._fxaBreaker = breaker + this._fxaMatched = matched + } else if (matched.some(m => m.modify_response)) { + this._fxaMatched = matched + } + super.open(method, url, async, user, password) } @@ -67,29 +264,20 @@ unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpReques } send(body?: Document | XMLHttpRequestBodyInit | null): void { - if (this._blockedUrl === undefined) { - super.send(body) + if (this._blockedUrl !== undefined) { + this._synthesizeFromSimple({ + responseBody: textToBody(BLOCKED_BODY), + statusCode: 200, + responseHeaders: { 'content-type': 'application/json' }, + }) return } - const url = this._blockedUrl - const shadow = (prop: string, value: unknown) => - 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) + if (this._fxaBreaker?.make_response) { + this._synthesizeFromSimple(this._fxaBreaker.make_response(this._fxaUrl)) + return + } + if (this._fxaMatched?.length) this._armTransform() + super.send(body) } abort(): void { @@ -108,6 +296,149 @@ unsafeWindow.XMLHttpRequest = class XMLHttpRequest extends originalXMLHttpReques } return super.getResponseHeader(name) } + + /** + * 中断请求后合成一个假响应并手动派发完成事件。 + * 与 fetch 的 make_response 对齐,也复用了黑名单那条原本的 setTimeout 派发逻辑。 + */ + private _synthesizeFromSimple(raw: SimpleResponse): void { + const url = this._blockedUrl ?? this._fxaUrl + // 提前归一,下面 arraybuffer / blob 分支要的是真正的字节 + const simple = resolveSimple(raw) + const headers = simple.responseHeaders + const shadow = (prop: string, value: unknown) => + Object.defineProperty(this, prop, { configurable: true, get: () => value }) + + setTimeout(() => { + const text = bodyToText(simple.responseBody) + shadow('readyState', 4) + shadow('status', simple.statusCode) + shadow('statusText', 'OK') + shadow('responseURL', url) + const type = this.responseType + shadow('responseText', type === '' || type === 'text' ? text : '') + + let value: unknown + if (type === 'json') { + try { + value = JSON.parse(text) + } catch { + value = text + } + } else if (type === 'arraybuffer') { + value = simple.responseBody + } else if (type === 'blob') { + value = new Blob([simple.responseBody]) + } else { + value = text + } + shadow('response', value) + + shadow('getAllResponseHeaders', () => + Object.entries(headers) + .map(([k, v]) => `${k}: ${v}\r\n`) + .join('') + ) + shadow('getResponseHeader', (name: string) => { + const lower = name.toLowerCase() + const key = Object.keys(headers).find(k => k.toLowerCase() === lower) + return key ? headers[key]! : null + }) + + this.dispatchEvent(new Event('readystatechange')) + this.dispatchEvent(new ProgressEvent('load')) + this.dispatchEvent(new ProgressEvent('loadend')) + }, 0) + } + + /** + * 在实例上挂影子属性:等真实响应就绪(readyState=4), + * 页面第一次读取时才同步套用 modify_response 并缓存结果。 + * 这样不用跟页面的 onload / onreadystatechange 争先后顺序。 + */ + private _armTransform(): void { + const type = this.responseType + // 这几类拿不到同步的 ArrayBuffer,或语义不明,无法改写,原样放行 + if (type === 'blob' || type === 'document') return + + const self = this + const matched = this._fxaMatched! + const url = this._fxaUrl + + const ensure = () => { + if (self._fxaApplied || self.readyState !== 4) return + // 归一一次并缓存:重复读 response 时要拿到同一个 ArrayBuffer 对象, + // 每次现编会让 xhr.response === xhr.response 变成 false + self._fxaSimple = resolveSimple(applyModifiers(url, matched, { + responseBody: readXhrBody(self), + statusCode: XHR_DESC.status.get!.call(self) as number, + responseHeaders: readXhrHeaders(self), + })) + self._fxaApplied = true + } + + // status + Object.defineProperty(this, 'status', { + configurable: true, + get() { + ensure() + if (self._fxaApplied) return self._fxaSimple!.statusCode + return XHR_DESC.status.get!.call(self) as number + }, + }) + + // responseText + Object.defineProperty(this, 'responseText', { + configurable: true, + get() { + ensure() + const t = self.responseType + if (!self._fxaApplied || (t !== '' && t !== 'text')) { + return XHR_DESC.responseText.get!.call(self) + } + return bodyToText(self._fxaSimple!.responseBody) + }, + }) + + // response + Object.defineProperty(this, 'response', { + configurable: true, + get() { + ensure() + if (!self._fxaApplied) return XHR_DESC.response.get!.call(self) + const body = self._fxaSimple!.responseBody + switch (self.responseType) { + case '': + case 'text': + return bodyToText(body) + case 'json': + return JSON.parse(bodyToText(body)) + case 'arraybuffer': + return body + default: + return XHR_DESC.response.get!.call(self) + } + }, + }) + + // getAllResponseHeaders + this.getAllResponseHeaders = function getAllResponseHeaders(): string { + ensure() + if (!self._fxaApplied) return XHR_GETALL.call(self) + return Object.entries(self._fxaSimple!.responseHeaders) + .map(([k, v]) => `${k}: ${v}\r\n`) + .join('') + } + + // getResponseHeader + this.getResponseHeader = function getResponseHeader(name: string): string | null { + ensure() + if (!self._fxaApplied) return XHR_GETONE.call(self, name) + const lower = name.toLowerCase() + const key = Object.keys(self._fxaSimple!.responseHeaders).find(k => k.toLowerCase() === lower) + return key ? self._fxaSimple!.responseHeaders[key]! : null + } + } } // 执行期hook,所以不用显式声明事件类hook diff --git a/src/hooks/readerHook.ts b/src/hooks/readerHook.ts index 19363d1..4e2b9f0 100644 --- a/src/hooks/readerHook.ts +++ b/src/hooks/readerHook.ts @@ -5,11 +5,14 @@ import { applyBookCss } from '../api/bookcss' import { processFootnotes, bindFootnoteInteraction } from '../utils/footnote' import { settings } from '../settings' // import defaultcss from '../assets/default.css?raw'; -import { cloneElement } from '../utils'; +import { cloneElement, sleep } from '../utils'; import { fetchArrayBuffer } from '../utils/request'; import { type Book } from '../types' import { decryptComicImage } from '../crypto/content'; import { initAudioPanel } from '../audioPanel' +import { addToBookshelf, removeFromBookshelf, isInBookshelf } from '../api/bookshelf' +import { addResponseModifier } from './fetchHook' +import { userState } from '../api/user' import { closeAudiobook, openAudiobook, @@ -23,12 +26,77 @@ let currentBook: Book | null = null let latestItemId: string | null = null +let currentChapterWithContent: any = null + /** 脚本接管的正文容器 id。文字章与漫画章共用同一个,切章时复用 */ const SCRIPT_CONTAINER_ID = 'fqa-reader-content' /** 漫画懒加载观察器,切章时要换掉旧的 */ let comicObserver: IntersectionObserver | null = null +/* +content-length +39 +content-type +application/json; charset=utf-8 +date +Wed, 16 Sep 2026 08:58:41 GMT +server +volc-dcdn +server-timing +inner; dur=49, cdn-cache;desc=MISS, origin;dur=63, edge;dur=14, cdn-cache;desc=MISS, tt_agw; dur=45 +strict-transport-security +max-age=63072000; includeSubDomains; preload +tt-idc-switch +10000@20260825024420 +tt_stable +1 +via +n172-005-142.sjzmp-agg.Creative,n173-115-143.nxzhongwei-mp01-cu.Creative +x-agw-info +tZz4wrQl57dt0wY2pTAgR2f48x1mv_TN71v9oOmqjxWS7rul6BjF9t8eZSMnCnTsdvrqH1P6-ELR41Ql_zMwMP_kZavRq2UkjiwjSI7ptI8EHk6qozYlJnJbaqiDWW2sO1RAbsmbFxPsUzXSsp3aUmh7JEMxEBn9_MgD_N49ZXPnqH1EJbjVod0JRshPQPmIzfsU_1PzITtNH20Wb96wrScoWjk= +x-dsa-origin-status +200 +x-dsa-trace-id +17895491212d5366f9d0c2c959ffbc745c0148e43f +x-request-ip +2408:847a:718:cd2:20ed:81:8e36:a911 +x-tt-agw-login +1 +x-tt-logid +20260916165841775622D9BB198F0EF9C9 +x-tt-trace-host +014917046478eae01e5ce9f4b5991634e2c2ac3d6c4ea331b6945fee3151af03e55859798c9e8c898e5508e08f78fb380fa48cd479a7ff5f96a2ef74435c7f420dcf4eb744b2aaca36db660c94c334414c30ce2bd99c166030189372edcc8a00b77f30aabb4f0447fece571b3a3c22a3de373bf420de4c924468d754783af3d783 +x-tt-trace-id +00-260916165841775622D9BB198F0EF9C9-3157EADB1F76429E-00 +x-tt-trace-tag +id=5 +*/ + +// 确保脚本clone的书架按钮是干净不受账号状态影响的 +// 没登录不会请求这个api,所以不影响 +addResponseModifier({ + matcher: (url: string) => + url.indexOf('/reading/bookapi/bookshelf/check/') !== -1 + && location.pathname.startsWith('/reader'), + should_break: () => true, + make_response: () => { + const body = JSON.stringify({ + code: 0, + message: 'SUCCESS', + data: 0 + }) + return { + responseBody: body, + statusCode: 200, + responseHeaders: { + 'content-type': 'application/json; charset=utf-8', + 'content-length': String(new TextEncoder().encode(body).length) + } + } + }, +}) + /** * 取到脚本容器:没有就克隆一个插到原容器前面,有就复用并清空。 * @@ -75,6 +143,7 @@ async function insertContent() { return } console.log('Chapter:', chapter) + currentChapterWithContent = chapter const pageState: any = (unsafeWindow as typeof unsafeWindow & { __INITIAL_STATE__?: { reader?: { chapterData?: { title?: string } } } @@ -292,7 +361,9 @@ async function insertContent() { } // 写入更精确的更新时间 let updateTimeSpans = muyeReaderSubtitle?.querySelectorAll('span.desc-item') || [] - if (updateTimeSpans.length >= 2 /* 应该是始终为3 */) { + console.log('spans len', updateTimeSpans.length, 'assertIsOffshelf', updateTimeSpans.length < 3) + if (updateTimeSpans.length >= 3 /* 应该是始终为3 */) { + console.log('if') /* 最后一个一般是更新时间 */ let updateTimeSpan = updateTimeSpans[updateTimeSpans.length - 1] as HTMLSpanElement let uttspan = updateTimeSpan.firstChild as HTMLSpanElement @@ -303,11 +374,31 @@ async function insertContent() { '更新时间:' + currentChapterItem.update_time } else { + console.log('else') // 网页端屏蔽章节。只有一个本章字数 + 前面插入的卷名 - let updateTimeSpan = document.createElement('span') + // 有个默认显示的 0 字,给它删了 + updateTimeSpans[updateTimeSpans.length - 1]?.remove() + const updateTimeSpan = document.createElement('span') // let updateTimeSubSpan = document.createElement('span') updateTimeSpan.className = 'desc-item' updateTimeSpan.textContent = `更新时间:${currentChapterItem.update_time}` + console.log('assert equal', currentChapterItem.item_id === currentChapterWithContent?.novel_data?.item_id) + console.log('wordcnt', currentChapterWithContent?.novel_data?.chapter_word_number) + const c = document.getElementById('fqa-current-chapter-volume') + let b: HTMLSpanElement | null = null; + if (currentChapterItem.item_id === currentChapterWithContent?.novel_data?.item_id) { + const wordCntSpan = document.createElement('span') + wordCntSpan.className = 'desc-item' + wordCntSpan.textContent = `本章字数:${currentChapterWithContent?.novel_data?.chapter_word_number}字` + console.log(wordCntSpan) + if (c) { + console.log('insert wordcnt') + c.insertAdjacentElement('afterend', wordCntSpan) + } + b = wordCntSpan + } + console.log('insert update time'); + (b || c)?.insertAdjacentElement('afterend', updateTimeSpan) } } } @@ -352,6 +443,14 @@ async function onHashChange(_previous?: string): Promise { // TODO: 支持从hash里解析并跳转到指定行 } async function onLoad(): Promise { + async function fetchBookInfo(): Promise { + const pageState: any = (unsafeWindow as typeof unsafeWindow & { + __INITIAL_STATE__?: { reader?: { chapterData?: { title?: string } } } + }).__INITIAL_STATE__ + const bid = pageState?.reader?.chapterData?.bookId + currentBook = await getBookInfoAndCatalog(bid) + } + void fetchBookInfo() // 后台继续执行 /* if (!document.getElementById('fqa-inject-css-reader')) { const style = document.createElement('style') @@ -382,6 +481,54 @@ async function onLoad(): Promise { c.addEventListener('click', () => void startAudioPlay()) toolbar.appendChild(c) } + const bookshelfButton = document.querySelector("div.reader-toolbar-item") // 第一个按钮一定是书架状态 + // 没登录的话就走网页原线路,会要求登录 + if (userState.isLogin && bookshelfButton && bookshelfButton.innerHTML.includes('书架')) { + const shelf = cloneElement(bookshelfButton) + bookshelfButton.replaceWith(shelf) + + let inShelf = false + let ready = false + let busy = false + + async function ensureBookshelfState(): Promise { + while (!currentBook) { + // 等 fetchBookInfo 飞回来 + await sleep(50) + } + inShelf = await isInBookshelf(currentBook.book_id) + shelf.classList.toggle('reader-toolbar-item-disabled', inShelf) + const label = shelf.lastChild + if (label) label.textContent = inShelf ? '已在书架' : '加入书架' + ready = true + } + void ensureBookshelfState() + + shelf.addEventListener('click', async () => { + const book = currentBook + // 状态还没同步好 / 书籍信息还没到,先不响应 + if (!ready || busy || !book) return + busy = true + try { + if (inShelf) { + // 已加书架,按设置决定是否带确认移出书架 + if (settings.shelfRemoveConfirm) { + if (!unsafeWindow.confirm(`确定要把《${book.title ?? '这本书'}》从书架移出吗?`)) return + } + await removeFromBookshelf(book.book_id) + } else { + // 未加书架,直接加入书架 + await addToBookshelf(book.book_id) + } + await ensureBookshelfState() + } catch (error) { + console.error('[fqa:reader] 书架操作失败:', error) + unsafeWindow.alert(error instanceof Error ? error.message : '书架操作失败') + } finally { + busy = false + } + }) + } const btns = document.querySelector("div.muye-reader-btns") // single div if (btns) { // TODO: 直接覆写按钮行为,切章节由脚本完成 diff --git a/src/hooks/userHook.ts b/src/hooks/userHook.ts index f5ff130..d6e7fae 100644 --- a/src/hooks/userHook.ts +++ b/src/hooks/userHook.ts @@ -4,6 +4,7 @@ import bookshelf from '../assets/bookshelf.svg?raw' import settings from '../assets/settings.svg?raw' import { openSettings } from '../settingsPanel' import { cloneElement } from '../utils' +import { settings as userSettings } from '../settings' function formatReadingTime(readBookTime: bigint): string { let minutes = readBookTime / 60_000n @@ -77,6 +78,19 @@ async function mainHook(_previous?: string): Promise { secondDiv.insertAdjacentElement('afterend', thirdDiv) // 助手设置固定放在菜单最后 menuInner.appendChild(createMenuItem('助手设置', settings, openSettings)) + menuInner.childNodes.forEach((node) => { + if (node.lastChild?.textContent?.trim() === '退出登录') { + const original = node as HTMLElement + const replaced = cloneElement(node as Element) + original.classList.add('fqa-hide') + original.insertAdjacentElement('afterend', replaced) + replaced.addEventListener('click', (_) => { + if (!userSettings.logoutConfirm || unsafeWindow.confirm('确认退出登录吗?')) { + original.click() + } + }) + } + }) } const scan = (root: HTMLElement) => { diff --git a/src/settings.ts b/src/settings.ts index 05de70b..e15c8e2 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -26,6 +26,10 @@ export interface Settings { blockReport: boolean /** 允许阅读器复制文本 */ allowCopy: boolean + /** 阅读页移出书架前弹确认框 */ + shelfRemoveConfirm: boolean + /** 退出登录前弹确认框 */ + logoutConfirm: boolean /* --- 界面 --- */ /** 阅读器字体,空字符串表示跟随页面默认 */ @@ -84,6 +88,8 @@ export const DEFAULT_SETTINGS: Settings = { decryptFont: true, blockReport: true, allowCopy: true, + shelfRemoveConfirm: true, + logoutConfirm: true, readerFont: '', customCssEnabled: false, diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue index 2ccac2d..83e1fdc 100644 --- a/src/views/SettingsView.vue +++ b/src/views/SettingsView.vue @@ -121,6 +121,16 @@ const GITHUB = 'https://github.com/naiyQAQ/fanqie-assistant' 允许阅读器复制文本 + + + +