// ==UserScript== // @name 番🍅茄小说阅读助手 // @namespace fanqie-reader // @version 1.0.0 // @description 试读章节不干预(站点原生渲染);锁定章节自动取全文(外部源+本地缓存);卷结构目录;读点感知缓存;批量下载 TXT // @match https://fanqienovel.com/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant unsafeWindow // @connect fanqienovel.com // @connect 101.35.133.34 // @run-at document-start // @license MIT // ==/UserScript== (function () { 'use strict'; // ============================================================ // 0. 常量与设置 // ============================================================ const VERSION = '1.0.0'; const TAG = '[fq]'; const DEFAULT_API_BASE = 'http://101.35.133.34:5000'; const DEFAULT_SETTINGS = { cacheMax: 2000, // 章节缓存上限(章),按书淘汰 keepBehind: 20, // 读点之前保留多少章 protectDays: 7, // 近几天读过的书不被整本清理 prefetchAhead: 5, // 向后预取章数(0 = 关闭) poolMB: 5, // 进度 + 目录 共用 localStorage 池上限 apiBase: DEFAULT_API_BASE }; function getSettings() { try { const saved = JSON.parse(GM_getValue('f3_settings', '{}') || '{}'); return Object.assign({}, DEFAULT_SETTINGS, saved); // prefetchAhead=0 即关闭,不做 ||5 兜底 } catch (e) { return Object.assign({}, DEFAULT_SETTINGS); } } function saveSettingsObj(s) { try { GM_setValue('f3_settings', JSON.stringify(s)); } catch (e) { /* ignore */ } } function apiBase() { return (getSettings().apiBase || DEFAULT_API_BASE).replace(/\/+$/, ''); } // ============================================================ // 1. 小工具 // ============================================================ function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function safeJSON(text, fallback) { try { return JSON.parse(text); } catch (e) { return fallback; } } function extractChapterId(url) { const u = url || location.href; let m = u.match(/\/reader\/(\d+)/); if (m) return m[1]; m = u.match(/[?&]item_id=(\d+)/); return m ? m[1] : null; } function whenReady(fn) { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fn, { once: true }); } else { fn(); } } function saveAs(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'; (document.body || document.documentElement).appendChild(a); a.click(); setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 1500); } function toast(msg) { try { let t = document.getElementById('f3-toast'); if (!t) { t = document.createElement('div'); t.id = 'f3-toast'; t.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:99999;background:#1f2329;color:#fff;padding:8px 14px;border-radius:8px;font-size:12px;opacity:.92'; (document.body || document.documentElement).appendChild(t); } t.textContent = msg; t.style.display = 'block'; clearTimeout(toast._timer); toast._timer = setTimeout(() => { t.style.display = 'none'; }, 5000); } catch (e) { /* ignore */ } } // ============================================================ // 2. 阅读信息(读点 + 最近阅读时间,淘汰策略的数据基础) // ============================================================ function getBookInfo() { return safeJSON(GM_getValue('f3_bookinfo', '{}') || '{}', {}); } function saveBookInfo(o) { try { GM_setValue('f3_bookinfo', JSON.stringify(o)); } catch (e) { /* ignore */ } } function touchBook(bookId, idx) { if (!bookId) return; const o = getBookInfo(); const rec = o[bookId] || { ts: 0, idx: -1 }; rec.ts = Date.now(); if (typeof idx === 'number' && idx >= 0) rec.idx = idx; o[bookId] = rec; saveBookInfo(o); } // 章节元数据:每章属于哪本书、书内序号、最后访问时间(清理策略依据) function getMeta() { return safeJSON(GM_getValue('f3_meta', '{}') || '{}', {}); } let metaSaveTimer = null; const metaPending = {}; function setMeta(id, bookId, idx) { metaPending[id] = { b: String(bookId || '?'), i: (typeof idx === 'number' && idx >= 0) ? idx : -1, t: Date.now() }; if (!metaSaveTimer) { metaSaveTimer = setTimeout(() => { metaSaveTimer = null; const m = getMeta(); Object.assign(m, metaPending); for (const k in metaPending) delete metaPending[k]; try { GM_setValue('f3_meta', JSON.stringify(m)); } catch (e) { /* ignore */ } }, 500); } } function flushMeta() { if (metaSaveTimer) { clearTimeout(metaSaveTimer); metaSaveTimer = null; } if (!Object.keys(metaPending).length) return; const m = getMeta(); Object.assign(m, metaPending); for (const k in metaPending) delete metaPending[k]; try { GM_setValue('f3_meta', JSON.stringify(m)); } catch (e) { /* ignore */ } } // ============================================================ // 3. 章节正文缓存(内存 + GM 持久化)与淘汰 // ① 当前书读点前后 keepBehind 章以外的缓存删掉(每本书存量 ≤ 2×keepBehind+1) // ② 总量超 cacheMax:按书整本淘汰,最久没读的先删; // 近 protectDays 天读过的书受保护(只在提示里说明,不静默删) // ============================================================ const CACHE_PREFIX = 'fq4_ch_'; let cacheEpoch = 0; // 清空/清书后,在途请求回来不再入缓存(防"清了又回来") let lastEvictRun = 0; const ChapterCache = { memory: new Map(), get(id) { if (!id) return null; if (this.memory.has(id)) return this.memory.get(id); try { const raw = GM_getValue(CACHE_PREFIX + id); if (raw) { this.memory.set(id, raw); return raw; } } catch (e) { /* ignore */ } return null; }, set(id, html) { if (!id || !html) return; this.memory.set(id, html); try { GM_setValue(CACHE_PREFIX + id, html); } catch (e) { /* ignore */ } }, has(id) { return !!this.get(id); }, drop(id) { this.memory.delete(id); try { GM_deleteValue(CACHE_PREFIX + id); } catch (e) { /* ignore */ } } }; function runEviction() { const now = Date.now(); if (now - lastEvictRun < 15000) return; lastEvictRun = now; flushMeta(); // 先落盘 500ms 合并窗内的待写 meta,防止刚删的条目被待写账复活 const s = getSettings(); const info = getBookInfo(); const meta = getMeta(); let ids = Object.keys(meta); // ① 当前书读点前后 keepBehind 章以外的缓存都删掉(前后对称, // 跳读/回读后旧的窗口不会滞留;软上限:合并落盘有 500ms 窗口,不追求严格即时) ids.forEach((id) => { const m = meta[id]; const bi = info[m.b]; if (bi && typeof bi.idx === 'number' && m.i >= 0 && Math.abs(m.i - bi.idx) > s.keepBehind) { ChapterCache.drop(id); delete meta[id]; } }); ids = Object.keys(meta); if (ids.length <= s.cacheMax) { saveMetaNow(meta); return; } // ② 按书分组淘汰:最久没读的书先删,保护期内跳过 const byBook = {}; ids.forEach((id) => { const b = meta[id].b || '?'; (byBook[b] = byBook[b] || []).push(id); }); const need = ids.length - s.cacheMax; let removed = 0; const protectedBooks = []; const bookList = Object.keys(byBook).map((b) => ({ b: b, ts: (info[b] && info[b].ts) || 0, ids: byBook[b] })).sort((a, b) => a.ts - b.ts); const protectMs = Math.max(0, s.protectDays) * 86400000; for (const grp of bookList) { if (removed >= need) break; if (now - grp.ts < protectMs) { protectedBooks.push(grp.b); continue; } grp.ids.forEach((id) => { ChapterCache.drop(id); delete meta[id]; removed++; }); } saveMetaNow(meta); if (protectedBooks.length) { const msg = '缓存已满:近 ' + s.protectDays + ' 天读过的书受保护未清理(' + protectedBooks.length + ' 本)。可在设置里调大上限或手动清理。'; toast('[fq] ' + msg); console.log(TAG, msg, protectedBooks); } console.log(TAG, '清理完成:本轮删除', removed, '章,剩余', Object.keys(meta).length, '章'); } function saveMetaNow(meta) { try { GM_setValue('f3_meta', JSON.stringify(meta)); } catch (e) { /* ignore */ } } // ============================================================ // 4. 阅读进度(独立于章节缓存;与目录共用 localStorage 池) // ============================================================ const Progress = { key(bookId) { return 'fq4_progress_' + bookId; }, load(bookId) { return safeJSON(localStorage.getItem(this.key(bookId)) || 'null', null) || { read: {}, last: null }; }, save(bookId, data) { try { localStorage.setItem(this.key(bookId), JSON.stringify(data)); } catch (e) { this.purge(); // 存储满:先回收,再重试一次 try { localStorage.setItem(this.key(bookId), JSON.stringify(data)); } catch (e2) { /* ignore */ } return; } this.purge(); }, markRead(bookId, chapterId, title) { if (!bookId || !chapterId) return; const data = this.load(bookId); data.read[chapterId] = { title: title || '', ts: Date.now() }; data.last = { chapterId, title: title || '', ts: Date.now() }; // 单书已读记录过长时精简(保留最近 2000 条) const keys = Object.keys(data.read); if (keys.length > 2000) { const arr = keys.map((k) => ({ k: k, ts: data.read[k].ts || 0 })) .sort((a, b) => b.ts - a.ts); const keep = {}; arr.slice(0, 2000).forEach((x) => { keep[x.k] = data.read[x.k]; }); data.read = keep; } this.save(bookId, data); }, last(bookId) { return this.load(bookId).last; }, readSet(bookId) { return this.load(bookId).read || {}; }, purge() { try { const keys = Object.keys(localStorage).filter( (k) => k.indexOf('fq4_progress_') === 0 || k.indexOf('fq4_toc_') === 0 ); if (!keys.length) return; let total = 0; const items = keys.map((k) => { const v = localStorage.getItem(k) || ''; total += k.length + v.length; let ts = 0; const o = safeJSON(v, null); if (o) ts = (o.last && o.last.ts) || o.ts || 0; return { k: k, ts: ts, size: k.length + v.length }; }); let mb = 5; try { mb = Math.max(1, parseInt(getSettings().poolMB, 10) || 5); } catch (e) { /* ignore */ } const cap = mb * 1024 * 1024; if (total <= cap) return; items.sort((a, b) => a.ts - b.ts); // 最久未更新的记录先清 for (const it of items) { if (total <= cap * 0.75) break; localStorage.removeItem(it.k); total -= it.size; } } catch (e) { /* ignore */ } } }; // ============================================================ // 5. 目录持久缓存(V2 卷结构) // ============================================================ const TocStore = { key(bookId) { return 'fq4_toc_' + bookId; }, save(bookId, volumes) { if (!bookId || !volumes || !volumes.length) return; try { localStorage.setItem(this.key(bookId), JSON.stringify({ v: 2, ts: Date.now(), bookId: String(bookId), volumes: volumes })); } catch (e) { /* ignore */ } Progress.purge(); }, load(bookId) { if (!bookId) return null; const o = safeJSON(localStorage.getItem(this.key(bookId)) || 'null', null); if (!(o && Array.isArray(o.volumes) && o.volumes.length)) return null; // 归一化:旧数据可能缺 globalIndex/volumeIndex(下载器依赖),重算补齐 let gi = 0; o.volumes.forEach((vol, vi) => { if (!vol || !Array.isArray(vol.chapters)) return; vol.chapters.forEach((ch) => { ch.volumeIndex = vi + 1; ch.globalIndex = gi++; }); }); return o.volumes; } }; // ============================================================ // 6. 页面实时状态(零请求):沙盒 window -> unsafeWindow -> 内联脚本解析 // ============================================================ function readLiveState() { try { if (window.__INITIAL_STATE__) return window.__INITIAL_STATE__; } catch (e) { /* ignore */ } try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow.__INITIAL_STATE__) { return unsafeWindow.__INITIAL_STATE__; } } catch (e) { /* ignore */ } try { for (const s of document.querySelectorAll('script')) { const t = s.textContent || ''; const m = t.match(/window\.__INITIAL_STATE__\s*=\s*/); if (!m) continue; const start = m.index + m[0].length; let depth = 0, end = -1, inStr = false, escNext = false; for (let i = start; i < t.length; i++) { const ch = t[i]; if (inStr) { if (escNext) escNext = false; else if (ch === '\\') escNext = true; else if (ch === '"') inStr = false; } else { if (ch === '"') inStr = true; else if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) { end = i + 1; break; } } } } if (end > 0) return JSON.parse(t.slice(start, end)); } } catch (e) { /* ignore */ } return null; } // 只有当 state 的 chapterData 与当前章一致时才可信(SPA 换书瞬间 state 可能还是旧书) function freshChapterData(chapterId) { try { const st = readLiveState(); const cd = st && st.reader && st.reader.chapterData; if (cd && String(cd.itemId || '') === String(chapterId)) return cd; } catch (e) { /* ignore */ } return null; } // 书籍页 state 归属校验:SPA 书→书跳转瞬间 state 可能还是上一本书的。 // 深搜 st.page 里所有 bookId 字段:任一与 URL 一致 → 可信; // 全部不一致 → 旧包,不可信;一个都找不到 → 无法核验,按可信处理 // (避免因字段形状变化漏采权威目录) function bookPageStateVerified(st) { if (!st || !st.page || typeof st.page !== 'object') return false; // 顶层 bookId 存在时精确比对优先(relatedBooks 里其他书的 id 不构成误判) if (st.page.bookId != null && /^\d+$/.test(String(st.page.bookId))) { return String(st.page.bookId) === String(ctx.bookId); } const found = []; let budget = 20000; const walk = (node, depth) => { if (budget <= 0 || !node || typeof node !== 'object' || depth > 5) return; budget--; if (Array.isArray(node)) { node.forEach((n) => walk(n, depth + 1)); return; } for (const k of Object.keys(node)) { if (/^book_?id$/i.test(k) && node[k] != null && /^\d+$/.test(String(node[k]))) { found.push(String(node[k])); } } Object.keys(node).forEach((k) => walk(node[k], depth + 1)); }; walk(st.page, 0); if (!found.length) return true; return found.indexOf(String(ctx.bookId)) !== -1; } function getDiv() { return ( document.querySelector('.muye-reader-content.noselect') || document.querySelector('.muye-reader-content') || document.querySelector('[class*="reader-content"]') ); } function currentTitle() { const el = document.querySelector('.reader-chapter-title') || document.querySelector('.chapter-title') || document.querySelector('h1'); return el ? el.textContent.trim() : ''; } // 卷结构化目录:书籍页 SSR 的 page.chapterListWithVolume 是权威全量 function getVolumeToc(state) { const page = (state && state.page) || {}; const lv = page.chapterListWithVolume; if (!Array.isArray(lv) || !lv.length) return null; const volumes = []; let globalIndex = 0; lv.forEach((vol, vi) => { if (!Array.isArray(vol)) return; const volTitle = (vol[0] && (vol[0].volumeTitle || vol[0].volume_name || vol[0].volumeName)) || '第' + (vi + 1) + '卷'; const chapters = []; vol.forEach((ch) => { if (!ch || typeof ch !== 'object') return; // 畸形项防御,避免 capture 轮询抛错空转 const itemId = ch.itemId || ch.item_id || ch.id; if (!itemId) return; chapters.push({ itemId: String(itemId), title: ch.title || ch.chapterTitle || '第' + (globalIndex + 1) + '章', // 与捕获路径对齐:字段缺失按锁定处理(宁可多取一章,不让用户干等) isChapterLock: ch.isChapterLock !== false, volumeTitle: volTitle, volumeIndex: vi + 1, globalIndex: globalIndex++ }); }); if (chapters.length) volumes.push({ volumeTitle: volTitle, chapters: chapters }); }); return volumes.length ? volumes : null; } // 通用章节列表探测(用于站内目录请求捕获):数组直接用; // 卷列表(元素全是数组)展平拼接后整体作为候选,多卷书不会丢卷 function findChapterList(state) { if (!state || typeof state !== 'object') return []; let best = null; const isChapterItem = (it) => it && typeof it === 'object' && (it.itemId || it.item_id) && !it.bookId && it.title !== undefined && /^\d{15,}$/.test(String(it.itemId || it.item_id)); const walk = (node, depth) => { if (!node || typeof node !== 'object' || depth > 6) return; if (Array.isArray(node)) { if (node.length >= 5 && node.length > (best ? best.length : 0)) { const okCount = node.filter(isChapterItem).length; if (okCount === node.length) best = node; } if (node.length >= 1 && node.every(Array.isArray)) { const flat = node.flat(); if (flat.length >= 5 && flat.length > (best ? best.length : 0) && flat.every(isChapterItem)) { best = flat; } } node.forEach((it) => walk(it, depth + 1)); } else { Object.keys(node).forEach((k) => walk(node[k], depth + 1)); } }; walk(state, 0); if (!best) return []; return best .map((ch, i) => ({ itemId: String(ch.itemId || ch.item_id), title: ch.title || ch.chapterTitle || '第' + (i + 1) + '章', volumeIndex: 1, volumeTitle: '', globalIndex: i, isChapterLock: ch.isChapterLock !== false })) .filter((c) => /^\d{15,}$/.test(c.itemId)); } // ============================================================ // 7. 正文净化与归一化 // ============================================================ function sanitizeHtml(html) { return String(html || '') .replace(//gi, '') // 仅在标签内部剥离事件属性(含无引号形态),不碰正文文本 .replace(/<[a-z][^>]*>/gi, (tag) => tag.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')) .replace(/javascript:/gi, ''); } function normalizeHtml(raw) { let content = String(raw || ''); const article = content.match(/
([\s\S]*?)<\/article>/); if (article) content = article[1]; else content = content.replace(/]*>[\s\S]*?<\/h1>/gi, ''); content = content.replace(/]*data-fanqie-type="image"[^>]*>[\s\S]*?<\/div>/gi, ''); content = sanitizeHtml(content); content = content.replace(/ style="[^"]*"/gi, ''); if (!content.includes(' (line.trim() ? '

' + line.trim() + '

' : '')) .join('\n'); } return content; } function decodeEntities(s) { const t = document.createElement('textarea'); t.innerHTML = s; // textarea 内容不执行脚本,仅做实体解码 return t.value; } function htmlToText(html) { return decodeEntities(String(html || '') .replace(/]*>[\s\S]*?<\/h1>/gi, '') .replace(/]*>/gi, '') .replace(/<\/p>/gi, '\n') .replace(/<[^>]+>/g, '')) .trim(); } // ============================================================ // 8. 外部源 API(单章 / 批量)。纯取文,不碰缓存—— // 缓存写入只发生在阅读路径(Fetching),下载绝不污染阅读缓存 // ============================================================ const Api = { chapter(id) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: apiBase() + '/api/content?item_id=' + id + '&tab=' + encodeURIComponent('小说'), timeout: 30000, onload: (res) => { const json = safeJSON(res.responseText, null); if (json && json.code === 200 && json.data && json.data.content) { resolve(normalizeHtml(json.data.content)); } else { console.log(TAG, '外部源失败:', id, 'HTTP', res.status, 'code', json && json.code); resolve(null); } }, onerror: () => { console.log(TAG, '外部源失败:', id, '网络错误'); resolve(null); }, ontimeout: () => { console.log(TAG, '外部源失败:', id, '超时(30s)'); resolve(null); } }); }); }, // 批量:源官方建议单批 ≤100。返回 [{itemId, html}](与请求顺序一致)或 null batch(ids, bookId) { return new Promise((resolve) => { const url = apiBase() + '/api/content?tab=' + encodeURIComponent('批量') + '&item_ids=' + ids.join(',') + '&book_id=' + encodeURIComponent(bookId); GM_xmlhttpRequest({ method: 'GET', url: url, timeout: Math.min(600000, 30000 + ids.length * 12000), // 服务端逐章处理,超时随批大小放宽 onload: (res) => { const json = safeJSON(res.responseText, null); const chs = json && json.code === 200 && json.data && json.data.chapters; if (!Array.isArray(chs) || chs.length !== ids.length) { console.log(TAG, '批量取文失败: 章数不匹配', chs && chs.length, '/', ids.length); resolve(null); return; } // 响应按请求顺序返回(实测),按位置对应 resolve(chs.map((c, i) => ({ itemId: ids[i], html: normalizeHtml(c.content || '') }))); }, onerror: () => resolve(null), ontimeout: () => resolve(null) }); }); } }; // 阅读取文入口:缓存 -> 在途去重 -> 请求并落缓存(代际校验,清缓存后在途结果不入库) const Fetching = { inflight: new Map(), get(id) { const cached = ChapterCache.get(id); if (cached) return Promise.resolve(cached); if (this.inflight.has(id)) return this.inflight.get(id); const ep = cacheEpoch; const bid = ctx.bookId || '?'; // 发起时的书身份:无条件记账用它,解析时不再读 ctx const p = Api.chapter(id).then((html) => { this.inflight.delete(id); if (!html) return null; if (ep === cacheEpoch) { ChapterCache.set(id, html); // 无条件记账(身份未知挂 '?'):缓存条目必须必有 meta, // 否则淘汰与"清空全部"都会漏掉这份孤儿缓存 setMeta(id, bid, -1); runEviction(); } return html; }).catch(() => { this.inflight.delete(id); return null; }); this.inflight.set(id, p); return p; } }; // ============================================================ // 9. 会话上下文(全书共享:身份 / 书名 / 目录) // ============================================================ const ctx = { bookId: null, bookName: null, chapterId: null, pending: false, // true = 阅读页身份未确认,宁可不记录,不可记错书 toc: [], // 扁平目录 volumeToc: [] // 卷分组目录 }; // 身份确认:state 的 chapterData 与当前章一致时才采信其 bookId。 // 换书时重置会话目录与书名,防止旧书目录/书名串到新书 function syncBookFromState(cd) { if (!cd || !cd.bookId) return false; const b = String(cd.bookId); const wasPending = ctx.pending; let changed = false; if (String(ctx.bookId) !== b) { ctx.bookId = b; ctx.bookName = null; // 旧书名一并作废,否则下载文件名会串书 ctx.toc = []; ctx.volumeToc = []; changed = true; } if (!ctx.bookName && cd.bookName) ctx.bookName = String(cd.bookName); ctx.pending = false; if (changed || wasPending) Sidebar.refresh(); return true; } // ============================================================ // 10. 目录:书籍页实时状态是权威全量(每次进入都刷新持久缓存); // 阅读页用持久缓存 + 站内目录请求捕获;绝不主动发书籍页请求 // ============================================================ const Toc = { _ensureP: null, ensure() { if (this._ensureP) return this._ensureP; try { this._ensureP = Promise.resolve(this.ensureInner()); } catch (e) { console.error(TAG, '目录获取异常:', e); this._ensureP = Promise.resolve(ctx.toc || []); } this._ensureP.finally(() => { this._ensureP = null; }); return this._ensureP; }, ensureInner() { const path = location.pathname; // 榜单/书库/首页等页面没有目录 if (!/\/(reader|page)\//.test(path)) return ctx.toc || []; if (/\/page\//.test(path)) { const st = readLiveState(); // state 归属未核实(SPA 换书旧包)时不采信、不落盘,等 capture 轮询修正 if (!bookPageStateVerified(st)) return ctx.toc || []; if (st.page.bookName) ctx.bookName = String(st.page.bookName); const vt = getVolumeToc(st); if (vt) { ctx.volumeToc = vt; ctx.toc = vt.flatMap((v) => v.chapters); if (ctx.bookId) TocStore.save(ctx.bookId, vt); } return ctx.toc || []; } // 阅读页:卷结构缺失时从持久缓存补齐 if (!(ctx.volumeToc && ctx.volumeToc.length)) { const sv = TocStore.load(ctx.bookId); if (sv) { ctx.volumeToc = sv; ctx.toc = sv.flatMap((v) => v.chapters); } } if (!ctx.bookName) { const cd = ctx.chapterId && freshChapterData(ctx.chapterId); if (cd && cd.bookName) ctx.bookName = String(cd.bookName); } return ctx.toc || []; }, // 站内目录请求捕获的数据只进会话内存(按 itemId 合并去重,只增不减),永不落盘 adopt(cands) { if (!cands || cands.length < 5) return false; const byId = {}; (ctx.toc || []).forEach((c) => { byId[c.itemId] = c; }); const merged = []; const seen = {}; let added = 0; // 以捕获列表的先后为序(站点目录响应全量有序); // 已有条目沿用原对象(保留卷结构信息),缺失的用捕获条目补 (cands || []).forEach((c) => { if (!c || !c.itemId) return; const id = String(c.itemId); if (seen[id]) return; seen[id] = true; if (!byId[id]) added++; merged.push(byId[id] || c); }); // 会话里有、捕获里没有的挂在末尾,不丢章 (ctx.toc || []).forEach((c) => { if (!seen[c.itemId]) merged.push(c); }); if (!added) return false; merged.forEach((c, i) => { c.globalIndex = i; }); ctx.toc = merged; console.log(TAG, '目录捕获合并: 新增', added, '章,会话共', merged.length, '章(不落盘)'); Sidebar.renderTocSafe(); return true; }, // 书籍页:权威目录落盘(state 在 document-start 时可能还没注入,轮询等它) captureFromBookPage() { const bid = ctx.bookId; if (!bid) return; let n = 0; const timer = setInterval(() => { if (String(ctx.bookId) !== String(bid)) { clearInterval(timer); return; } n++; try { const st = readLiveState(); if (!bookPageStateVerified(st)) return; // 旧包不落盘,等 state 更新到本书 if (st.page.bookName) ctx.bookName = String(st.page.bookName); const vt = getVolumeToc(st); if (vt) { ctx.volumeToc = vt; ctx.toc = vt.flatMap((v) => v.chapters); TocStore.save(bid, vt); Sidebar.renderTocSafe(); clearInterval(timer); return; } } catch (e) { console.log(TAG, '书籍页目录采集异常:', e && e.message); } if (n >= 20) clearInterval(timer); }, 300); }, reset() { ctx.toc = []; ctx.volumeToc = []; } }; // 捕获站点自身的目录请求:用户点站内目录时,响应直接复用,零额外请求 function installTocCapture() { const inspect = (text) => { try { if (!text || text.length < 200) return; if (text.indexOf('itemId') === -1 && text.indexOf('chapterList') === -1) return; const data = JSON.parse(text); if (Toc.adopt(findChapterList(data))) return; // 目录接口有时直接是章节数组包一层 const walk = (node, d) => { if (d > 4 || !node || typeof node !== 'object') return; if (Array.isArray(node) && node.length >= 5 && node[0] && node[0].itemId) { Toc.adopt(node.map((ch, i) => ({ itemId: String(ch.itemId || ch.item_id || ch.id), title: ch.title || ch.chapterTitle || '第' + (i + 1) + '章', volumeIndex: 1, volumeTitle: '', globalIndex: i, isChapterLock: ch.isChapterLock !== false }))); return; } Object.keys(node).forEach((k) => walk(node[k], d + 1)); }; walk(data, 0); } catch (e) { /* ignore */ } }; const origFetch = window.fetch; if (origFetch) { window.fetch = function () { const p = origFetch.apply(this, arguments); try { p.then((res) => { try { res.clone().text().then((t) => inspect(t)).catch(() => {}); } catch (e) { /* ignore */ } }).catch(() => {}); } catch (e) { /* ignore */ } return p; }; } const origOpen = XMLHttpRequest.prototype.open; const origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url) { this.__f3url = String(url || ''); return origOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function () { const xhr = this; xhr.addEventListener('load', function () { try { if (xhr.__f3url && xhr.__f3url.indexOf('fanqienovel') !== -1) { inspect(xhr.responseText); } } catch (e) { /* ignore */ } }); return origSend.apply(this, arguments); }; } // ============================================================ // 11. 阅读模块:判定(试读/锁定)→ 取文 → 画入 → 防覆盖 → 预取 // ============================================================ const Reader = { seq: 0, painted: null, // {id, html},html 为画入后含内联样式的快照 decideTimer: null, watchTimer: null, _lastPrefetchId: null, _pfTimer: null, onChapter(id) { if (!id) { this.cancelPending(); return; } ctx.chapterId = id; const mySeq = ++this.seq; // 宣告上一章的全部异步流程作废(缓存命中路径同样需要) this.cancelPending(); // 身份确认 + 进度记录:独立轮询,不阻塞缓存命中时的正文画入 this.confirmBook(id, mySeq); const cached = ChapterCache.get(id); if (cached) { console.log(TAG, id, '命中缓存,直接画入'); this.paint(id, cached); this.prefetchAround(id); return; } // 判定循环:等页面 state 与本章一致(最多 30 × 250ms = 7.5s) let tries = 0; this.decideTimer = setInterval(() => { if (mySeq !== this.seq) { this.cancelPending(); return; } tries++; const cd = freshChapterData(id); if (cd) syncBookFromState(cd); // 判定依据: // ① state 与本章一致时看 isChapterLock(最可靠) // ② 渲染字数达标(state 的字数 / 或绝对值 1200 字)→ 试读 // ③ 有付费墙元素且正文渲染很短 → 锁定(不依赖 state,SPA 旧包也能判) // ④ 全都没有 → 等下一拍;30 拍后按锁定兜底,保证读得到 let decision = null; const div0 = getDiv(); const rendered0 = div0 ? (div0.innerText || '').replace(/\s+/g, '').length : 0; const paywalled = !!document.querySelector( '.muye-to-vip, .muye-to-vip-mask, .reader-unlock, .reader-lock-overlay'); if (cd && cd.isChapterLock === true) { decision = 'locked'; } else if (cd) { const wordN = parseInt(cd.chapterWordNumber, 10) || 0; if (wordN > 0 && rendered0 >= wordN * 0.7) decision = 'trial'; } if (!decision) { if (rendered0 >= 1200) decision = 'trial'; else if (paywalled) decision = 'locked'; } if (!decision && tries >= 30) decision = 'locked'; // 拿不准按锁定处理,保证读得到 if (!decision) return; this.cancelPending(); if (decision === 'trial') { console.log(TAG, id, '试读章节,站点原生渲染,不干预;预取后续锁定章节'); this.prefetchAround(id); return; } console.log(TAG, id, '锁定章节,取全文'); Toc.ensure().then((toc) => { if (mySeq !== this.seq) return null; const idx = toc.findIndex((ch) => String(ch.itemId) === String(id)); if (idx >= 0 && ctx.bookId) touchBook(ctx.bookId, idx); return Fetching.get(id).then((html) => ({ html: html, idx: idx })); }).then((r) => { if (mySeq !== this.seq || !r) return; if (!r.html) { toast('[fq] 本章获取失败:外部源超时或被限流,稍后重开本章会自动重试'); return; } // 取文成功且确实入缓存后才记 meta,失败章不留虚账 if (r.idx >= 0 && ctx.bookId && ChapterCache.has(id)) setMeta(id, ctx.bookId, r.idx); this.paint(id, r.html); this.prefetchAround(id); }).catch(() => { /* ignore */ }); }, 250); }, cancelPending() { if (this.decideTimer) { clearInterval(this.decideTimer); this.decideTimer = null; } }, // 身份确认循环:state 与本章一致时确定 bookId,然后记进度 confirmBook(id, mySeq) { let n = 0; const timer = setInterval(() => { if (mySeq !== this.seq) { clearInterval(timer); return; } n++; const ok = syncBookFromState(freshChapterData(id)); if (ok || n >= 30) { clearInterval(timer); if (!ok) return; // 超时仍未知书籍身份:不记录进度(宁可漏记,不可记错书) this.recordProgress(id); } }, 250); }, recordProgress(id) { if (!ctx.bookId || ctx.pending) return; const bookId = ctx.bookId; // 固定进入时刻的书籍身份,防回调执行时已换书 Progress.markRead(bookId, id, currentTitle()); Toc.ensure().then((toc) => { const idx = toc.findIndex((ch) => String(ch.itemId) === String(id)); touchBook(bookId, idx); // 只有确实进了章节缓存才记 meta:试读章不缓存, // 记了会让"已清除本书 N 章缓存"的计数虚高 if (idx >= 0 && ChapterCache.has(id)) setMeta(id, bookId, idx); }).catch(() => { touchBook(bookId); }); }, paint(id, html) { const doPaint = () => { if (ctx.chapterId !== id) return true; // 已翻章,放弃 const div = getDiv(); if (!div) return false; div.innerHTML = html; div.setAttribute('data-fq', id); this.styleParagraphs(div); this.unlock(div); // 快照在加完样式之后取:长度比对才不会永久误判"被覆盖" this.painted = { id: id, html: div.innerHTML }; this.ensureWatch(); return true; }; if (doPaint()) return; // 容器可能晚挂载(慢设备/慢网络),重试窗口拉长到 ~10s let n = 0; const t = setInterval(() => { if (doPaint() || ++n >= 40) clearInterval(t); }, 250); }, styleParagraphs(div) { div.querySelectorAll('p, div, span').forEach((el) => { el.style.setProperty('opacity', '1', 'important'); el.style.setProperty('color', 'inherit', 'important'); el.style.setProperty('filter', 'none', 'important'); }); }, // 页面自身渲染会用受限内容覆盖画入的全文,持续监视并画回 ensureWatch() { if (this.watchTimer) return; this.watchTimer = setInterval(() => { const p = this.painted; if (!p) return; if (extractChapterId() !== p.id) return; const div = getDiv(); if (!div) return; const clobbered = div.getAttribute('data-fq') !== p.id || Math.abs((div.innerHTML || '').length - p.html.length) > 64; if (clobbered) { if (!this._watchLogged) { this._watchLogged = true; console.log(TAG, '检测到页面覆盖,已画回全文(后续静默处理)'); } div.innerHTML = p.html; // 快照已含内联样式,画回即可 div.setAttribute('data-fq', p.id); this.unlock(div); } }, 1500); }, // 解锁视觉限制:滤镜/蒙层/锁定浮层/底部渐变引导 unlock(div) { const box = document.querySelector('.muye-reader-box'); if (box) { box.style.filter = ''; box.style.backdropFilter = ''; box.classList.remove('serial-filter-gray'); } document.querySelectorAll('.muye-to-vip, .muye-to-vip-mask').forEach((el) => el.remove()); if (!div) return; div.style.filter = ''; div.style.webkitFilter = ''; div.style.webkitMaskImage = ''; div.style.maskImage = ''; div.style.maxHeight = ''; div.style.overflow = ''; div.classList.remove('noselect'); // 按 class 分词精确匹配遮罩类元素,避免子串误伤 content-block/clock 等 div.querySelectorAll('[class]').forEach((el) => { if (el.tagName === 'P') return; let hit = false; (el.classList || []).forEach((t) => { if (/^(?:[a-z0-9]+[-_])?(?:mask|blur|lock|unlock)/i.test(t)) hit = true; }); if (hit) el.remove(); }); // 渐变遮罩:站点在正文底部盖 linear-gradient 引导去APP,找到就清 try { const rect = div.getBoundingClientRect(); const scope = div.closest('.muye-reader-box') || div.parentElement; if (scope) { scope.querySelectorAll('div, span, section').forEach((el) => { if (el === div || div.contains(el) || el.contains(div)) return; let cs; try { cs = getComputedStyle(el); } catch (e) { return; } const bg = cs.backgroundImage || ''; if (bg.indexOf('gradient') === -1) return; const r = el.getBoundingClientRect(); const overlap = !(r.right < rect.left || r.left > rect.right || r.bottom < rect.top || r.top > rect.bottom); if (overlap && r.width > 50) el.remove(); }); } } catch (e) { /* ignore */ } // 祖先链:禁用伪元素渐变,容器渐变背景一并清除 try { let el = div; for (let i = 0; i < 5 && el && el !== document.body; i++) { el.classList.add('f3-nopseudo'); const cs = getComputedStyle(el); if ((cs.backgroundImage || '').indexOf('gradient') !== -1) { el.style.setProperty('background-image', 'none', 'important'); } el = el.parentElement; } } catch (e) { /* ignore */ } }, // 闩锁在窗口真正算出后才落:身份/目录未就绪时不闩,本章还有重试机会 prefetchAround(currentId) { const key = String(currentId); if (this._lastPrefetchId === key) return; if (this._pfTimer) clearTimeout(this._pfTimer); this._pfTimer = setTimeout(() => { const aheadN = Math.max(0, parseInt(getSettings().prefetchAhead, 10) || 0); if (!aheadN) { this._lastPrefetchId = key; return; } const bookId = ctx.bookId ? String(ctx.bookId) : null; if (!bookId) return; // 身份未知:不闩,稍后重试 const ep = cacheEpoch; // 清缓存后在途批量回包不得回填 Toc.ensure().then((toc) => { if (String(ctx.bookId) !== bookId) return; // 已换书 const idx = toc.findIndex((ch) => String(ch.itemId) === String(currentId)); if (idx < 0) { this.prefetchFallback(currentId, bookId); return; } // 窗口 = 读点之后最近的 aheadN 个锁定章(锁定章计数,跳过试读)。 // 选窗口时不能看"是否已缓存": // 加上未缓存条件 → 窗口每读一章就往前爬 aheadN 个(缓存暴涨的根因); // 严格按位置 (idx, idx+aheadN] → 试读段完全不预取,读到第一个锁定章要干等。 // 稳定窗口的语义:试读 1~10 章 + 锁定从 11 开始时, // 读 1~10 期间窗口始终是 11~15(收齐后零请求); // 读到 11 窗口才滑到 12~16,新增的只有 16 const windowIds = []; for (let i = idx + 1; i < toc.length && windowIds.length < aheadN; i++) { const next = toc[i]; if (next.isChapterLock === false) continue; // 试读章不占窗口名额 windowIds.push(String(next.itemId)); } this._lastPrefetchId = key; // 本章预取事务已定型,此后不再重复 const targets = windowIds.filter((nid) => !ChapterCache.has(nid)); if (!targets.length) return; console.log(TAG, '批量预取', targets.length, '章'); Api.batch(targets, bookId).then((arr) => { if (!arr) { this.prefetchFallback(currentId, bookId); return; } if (ep !== cacheEpoch || String(ctx.bookId) !== bookId) return; // 已清缓存/已换书,丢弃 let got = 0; arr.forEach((r) => { if (r.html && !ChapterCache.has(r.itemId)) { ChapterCache.set(r.itemId, r.html); got++; const ti = toc.findIndex((c) => String(c.itemId) === String(r.itemId)); setMeta(r.itemId, bookId, ti); // 找不到序号也记账(-1),不留孤儿缓存 } }); runEviction(); console.log(TAG, '批量预取完成:', got, '/', targets.length, '章入缓存'); }); }).catch(() => { /* ignore */ }); }, 500); }, // 批量失败时的兜底:只补紧邻的下一章 prefetchFallback(currentId, bookId) { try { const cd = freshChapterData(currentId); const nid = cd && cd.nextItemId ? String(cd.nextItemId) : null; if (!nid || ChapterCache.has(nid)) return; Fetching.get(nid).then((html) => { if (!html) return; if (String(ctx.bookId) !== bookId) return; // 已换书:不得把新书的章记到旧书 Toc.ensure().then((toc) => { const ti = toc.findIndex((c) => String(c.itemId) === nid); setMeta(nid, bookId, ti >= 0 ? ti : -1); }).catch(() => setMeta(nid, bookId, -1)); }); } catch (e) { /* ignore */ } } }; // ============================================================ // 12. 阅读页样式(去滤镜/蒙层/渐变) // ============================================================ function injectReaderCSS() { const style = document.createElement('style'); style.id = 'f3-reader-css'; style.textContent = ` .muye-reader-content.noselect::after, .muye-reader-content.noselect::before { display: none !important; content: none !important; } .muye-reader-content { filter: none !important; -webkit-filter: none !important; -webkit-mask-image: none !important; mask-image: none !important; max-height: none !important; overflow: visible !important; } .muye-reader-content::after, .muye-reader-content::before { display: none !important; content: none !important; } .muye-reader-content p, .muye-reader-content div, .muye-reader-content span { opacity: 1 !important; filter: none !important; -webkit-mask-image: none !important; mask-image: none !important; -webkit-mask: none !important; mask: none !important; } .f3-nopseudo::after, .f3-nopseudo::before { display: none !important; content: none !important; background: none !important; } .muye-to-vip, .muye-to-vip-mask, .reader-unlock, .reader-lock-overlay { display: none !important; } `; (document.head || document.documentElement).appendChild(style); } // ============================================================ // 13. 侧边栏 UI // ============================================================ const Sidebar = { mounted: false, mount() { if (this.mounted || document.getElementById('f3-sidebar')) return; if (!document.body) return; this.mounted = true; this.injectCSS(); const root = document.createElement('div'); root.id = 'f3-sidebar'; root.innerHTML = ` `; document.body.appendChild(root); const bind = (id, fn) => { const el = document.getElementById(id); if (!el) { console.error(TAG, '缺少元素', id); return; } el.addEventListener('click', fn); }; bind('f3SbCollapse', () => document.getElementById('f3Sb').classList.add('collapsed')); bind('f3SbTab', () => document.getElementById('f3Sb').classList.remove('collapsed')); bind('f3GearBtn', () => { this.showView('settings'); SettingsUI.load(); }); bind('f3SetBack', () => this.showView('main')); bind('f3Resume', () => this.gotoLast()); bind('f3DlBtn', () => { const pop = document.getElementById('f3DlPop'); const open = pop.style.display !== 'block'; pop.style.display = open ? 'block' : 'none'; this.updateDlBtn(); if (open) { this.refresh(); SettingsUI.load(); const log = document.getElementById('f3DlLog'); if (log && log.childElementCount) log.style.display = 'block'; } }); bind('f3DlClose', () => { document.getElementById('f3DlPop').style.display = 'none'; this.updateDlBtn(); }); bind('f3DlGo', () => { if (ctx.bookId) Downloader.startDownload(ctx.bookId); }); bind('f3DlCancel', () => Downloader.cancel()); bind('f3SetSave', () => SettingsUI.save()); bind('f3SetClearBook', () => { if (!ctx.bookId) { Downloader.setStatus('当前页面不在书籍/章节内,无法确定要清除哪本书'); return; } Downloader.clearBookData(ctx.bookId); }); bind('f3SetClearCh', () => Downloader.clearChapterCache()); bind('f3SetClearProg', () => Downloader.clearProgress()); console.log(TAG, '侧边栏就绪 (' + VERSION + ')'); this.refresh(); }, injectCSS() { if (document.getElementById('f3-sb-css')) return; const style = document.createElement('style'); style.id = 'f3-sb-css'; style.textContent = ` .f3-sb { position: fixed; top: 0; right: 0; z-index: 99990; height: 100vh; font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; color: #1f2329; } .f3-sb-tab { display: none; position: absolute; top: 50%; right: 0; transform: translateY(-50%); width: 36px; height: 72px; border-radius: 10px 0 0 10px; background: #1f2329; color: #fff; align-items: center; justify-content: center; cursor: pointer; writing-mode: vertical-rl; letter-spacing: 4px; font-size: 13px; box-shadow: -4px 0 16px rgba(15, 23, 42, .18); } .f3-sb.collapsed .f3-sb-tab { display: flex; } .f3-sb.collapsed .f3-sb-panel { display: none; } .f3-sb-panel { width: 300px; height: 100vh; background: #fff; border-left: 1px solid #eef0f3; box-shadow: -8px 0 28px rgba(15, 23, 42, .08); display: flex; flex-direction: column; } .f3-sb-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 14px 12px; border-bottom: 1px solid #f1f3f5; } .f3-sb-brand strong { font-size: 14px; font-weight: 650; } .f3-sb-ver { margin-left: 6px; color: #98a2b3; font-size: 11px; } .f3-sb-icon { width: 28px; height: 28px; border: none; border-radius: 8px; background: #f5f6f8; color: #667085; cursor: pointer; font-size: 16px; line-height: 1; } .f3-sb-icon:hover { background: #eceff3; color: #1f2329; } .f3-sb-sec { padding: 12px 14px; border-bottom: 1px solid #f5f6f8; } .f3-sb-label { font-size: 12px; color: #8b95a1; margin-bottom: 8px; } .f3-sb-row { display: flex; justify-content: space-between; align-items: center; } .f3-sb-count { font-size: 11px; color: #98a2b3; } .f3-resume { width: 100%; text-align: left; border: 1px solid #eef0f3; background: #fff8f5; border-radius: 10px; padding: 10px 12px; cursor: pointer; } .f3-resume:hover { border-color: #ffb08a; } .f3-resume-title { font-size: 13px; font-weight: 600; color: #1f2329; margin-bottom: 2px; } .f3-resume-sub { font-size: 11px; color: #8b95a1; } .f3-sb-head-btns { display: flex; gap: 6px; } .f3-view { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; } .f3-setview { display: none; flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 12px 14px; } .f3-set-back { font-size: 12px; color: #8b95a1; cursor: pointer; margin-bottom: 10px; } .f3-set-back:hover { color: #1f2329; } .f3-set-item { margin-bottom: 14px; } .f3-set-item label { display: block; font-size: 12px; font-weight: 600; color: #1f2329; margin-bottom: 4px; } .f3-set-item input { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid #e0e3e8; border-radius: 6px; font-size: 12px; } .f3-set-desc { font-size: 11px; color: #98a2b3; margin-top: 4px; line-height: 1.5; } .f3-toc-sec { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; border-bottom: none; } .f3-toc { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 2px; } .f3-toc-vol { font-weight: 600; color: #667085; padding: 8px 6px 4px; font-size: 11px; cursor: pointer; } .f3-toc-vol:hover { color: #ff5000; } .f3-toc-item { display: flex; align-items: center; gap: 8px; padding: 7px 6px; border-radius: 8px; cursor: pointer; font-size: 12px; color: #344054; } .f3-toc-item:hover { background: #f8fafc; } .f3-toc-item.current { background: #fff1ea; color: #ff5000; font-weight: 600; } .f3-toc-item .dot { width: 14px; height: 14px; border-radius: 50%; border: 1px solid #d0d5dd; flex: 0 0 14px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #fff; background: #fff; } .f3-toc-item.read .dot { background: #ff5000; border-color: #ff5000; } .f3-toc-item .title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .f3-dl-footer { position: relative; padding: 10px 14px; border-top: 1px solid #f1f3f5; background: #fff; } .f3-dl-pop { display: none; margin-top: 8px; background: #fff; border: 1px solid #eef0f3; border-radius: 12px; box-shadow: 0 8px 28px rgba(15, 23, 42, .12); padding: 10px; max-height: 70vh; overflow-y: auto; } .f3-dl-pop-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 12px; font-weight: 600; color: #1f2329; } .f3-dl-pop-head button { border: none; background: #f5f6f8; color: #667085; width: 22px; height: 22px; border-radius: 6px; cursor: pointer; font-size: 14px; line-height: 1; } .f3-dl-pop-head button:hover { background: #eceff3; color: #1f2329; } .f3-dl-row { display: flex; gap: 8px; margin-bottom: 8px; } .f3-dl-field { flex: 1; min-width: 0; } .f3-dl-field label { display: block; font-size: 11px; color: #8b95a1; margin-bottom: 3px; } .f3-dl-field input { width: 100%; padding: 6px 8px; border: 1px solid #e0e3e8; border-radius: 6px; font-size: 12px; box-sizing: border-box; } .f3-btn { width: 100%; border: none; border-radius: 10px; background: #1f2329; color: #fff; padding: 10px 12px; font-size: 12px; cursor: pointer; } .f3-btn:hover { background: #0f172a; } .f3-btn:disabled { opacity: .6; cursor: default; } .f3-btn-cancel { background: #b3261e !important; margin-top: 6px; } .f3-btn-cancel:hover { background: #8f1e17 !important; } .f3-progress { margin-top: 8px; } .f3-progress-bar { height: 4px; background: #eceff3; border-radius: 2px; overflow: hidden; } .f3-progress-fill { height: 100%; width: 0%; background: #ff5000; transition: width .3s; } .f3-progress-text { display: flex; justify-content: space-between; font-size: 11px; color: #8b95a1; margin-top: 3px; } .f3-log { margin-top: 8px; max-height: 130px; overflow-y: auto; background: #1f2329; color: #d5dbe3; border-radius: 6px; padding: 6px 8px; font-size: 11px; font-family: Consolas, monospace; line-height: 1.6; } .f3-log .err { color: #ff8a80; } .f3-log .ok { color: #8bd97f; } .f3-set-btns { display: flex; gap: 6px; margin-top: 8px; } .f3-set-btns button { flex: 1; border: 1px solid #e0e3e8; background: #fff; color: #344054; border-radius: 6px; padding: 6px 4px; font-size: 11px; cursor: pointer; } .f3-set-btns button:hover { background: #f5f6f8; } .f3-set-status { font-size: 11px; color: #12805c; margin-top: 6px; min-height: 14px; } .f3-empty { color: #98a2b3; font-size: 12px; padding: 12px 4px; } `; document.head.appendChild(style); }, showView(v) { const main = document.getElementById('f3MainView'); const set = document.getElementById('f3SetView'); if (!main || !set) return; main.style.display = v === 'settings' ? 'none' : 'flex'; set.style.display = v === 'settings' ? 'block' : 'none'; }, updateDlBtn() { const pop = document.getElementById('f3DlPop'); const btn = document.getElementById('f3DlBtn'); if (!btn) return; const popOpen = pop && pop.style.display === 'block'; btn.textContent = popOpen ? '⬆ 收起下载面板' : (Downloader.running ? '⬇ 下载中…' : '⬇ 下载全本 TXT'); }, refresh() { const box = document.getElementById('f3Toc'); if (!box) return; // 换书瞬间书籍身份未确认:不渲染旧书的续读/已读/目录,避免串书闪烁 if (ctx.pending) { box.innerHTML = '
正在加载…
'; return; } const read = ctx.bookId ? Progress.readSet(ctx.bookId) : {}; const last = ctx.bookId ? Progress.last(ctx.bookId) : null; const resumeTitle = document.getElementById('f3ResumeTitle'); const resumeSub = document.getElementById('f3ResumeSub'); if (last && last.chapterId) { resumeTitle.textContent = last.title || '第' + last.chapterId + '章'; resumeSub.textContent = '上次读到这里 · 点击继续'; } else { resumeTitle.textContent = '暂无记录'; resumeSub.textContent = '打开任意章节后会自动记录'; } document.getElementById('f3ReadCount').textContent = '已读 ' + Object.keys(read).length; Toc.ensure(); if (ctx.toc.length) { this.renderToc(); } else { box.innerHTML = '
本页暂无目录数据:打开书籍详情页、' + '或点开站内右侧「目录」按钮后会自动加载 · 刷新
'; const r = document.getElementById('f3TocRefresh'); if (r) r.addEventListener('click', () => this.refresh()); } }, renderTocSafe() { const box = document.getElementById('f3Toc'); if (box && ctx.toc.length) this.renderToc(); }, renderToc() { const box = document.getElementById('f3Toc'); if (!box) return; const currentId = extractChapterId(); const read = ctx.bookId ? Progress.readSet(ctx.bookId) : {}; if (!ctx.toc.length) { box.innerHTML = '
本页无目录
'; return; } const item = (ch) => { const id = String(ch.itemId); const isRead = !!read[id]; const isCur = id === String(currentId); return ( '
' + '' + (isRead ? '✓' : '') + '' + '' + esc(ch.title || id) + '' + '
' ); }; // 折叠状态持久化(按书记忆,阅读页/书籍页共用) let folded = {}; try { folded = JSON.parse(localStorage.getItem('f3_fold_' + ctx.bookId) || '{}') || {}; } catch (e) { /* ignore */ } let html = ''; if (ctx.volumeToc && ctx.volumeToc.length) { for (const vol of ctx.volumeToc) { const isFolded = folded[vol.volumeTitle] === true; html += '
' + (isFolded ? '▸ ' : '▾ ') + esc(vol.volumeTitle) + ' · ' + vol.chapters.length + '章
'; if (!isFolded) html += vol.chapters.map(item).join(''); } } else { html = ctx.toc.map(item).join(''); } box.innerHTML = html; box.querySelectorAll('.f3-toc-vol[data-vol]').forEach((el) => { el.addEventListener('click', () => { const t = el.getAttribute('data-vol'); folded[t] = !(folded[t] === true); try { localStorage.setItem('f3_fold_' + ctx.bookId, JSON.stringify(folded)); } catch (e) { /* ignore */ } this.renderToc(); }); }); box.querySelectorAll('.f3-toc-item').forEach((el) => { el.addEventListener('click', () => { const id = el.getAttribute('data-id'); if (id) location.href = 'https://fanqienovel.com/reader/' + id; }); }); const cur = box.querySelector('.f3-toc-item.current'); if (cur) cur.scrollIntoView({ block: 'nearest' }); }, gotoLast() { const last = ctx.bookId ? Progress.last(ctx.bookId) : null; if (last && last.chapterId) { location.href = 'https://fanqienovel.com/reader/' + last.chapterId; } } }; // ============================================================ // 14. 设置面板读写 // ============================================================ const SettingsUI = { load() { const s = getSettings(); const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; }; set('f3SetMax', s.cacheMax); set('f3SetKeep', s.keepBehind); set('f3SetAhead', s.prefetchAhead); set('f3SetDays', s.protectDays); set('f3SetPool', s.poolMB); set('f3SetApi', s.apiBase); }, save() { const s = getSettings(); const num = (id) => { const el = document.getElementById(id); return el ? parseInt(el.value, 10) : NaN; }; const max = num('f3SetMax'); const keep = num('f3SetKeep'); const days = num('f3SetDays'); const ahead = num('f3SetAhead'); const pool = num('f3SetPool'); const apiEl = document.getElementById('f3SetApi'); const api = ((apiEl && apiEl.value) || '').trim().replace(/\/+$/, ''); if (max >= 100) s.cacheMax = max; if (keep >= 0) s.keepBehind = keep; if (days >= 0) s.protectDays = days; if (ahead >= 0 && ahead <= 10) s.prefetchAhead = ahead; if (pool >= 1) s.poolMB = pool; if (api && /^https?:\/\//.test(api)) s.apiBase = api; saveSettingsObj(s); Downloader.setStatus('设置已保存 ✓'); setTimeout(() => Downloader.setStatus(''), 3000); } }; // ============================================================ // 15. 下载:范围 / 分批 / 进度 / 取消 / 缺章补取 / 分卷 TXT // 下载全程不写阅读缓存,只按顺序取文拼 TXT // ============================================================ const Downloader = { running: false, cancelFlag: false, log(msg, type) { const logDiv = document.getElementById('f3DlLog'); if (!logDiv) return; logDiv.style.display = 'block'; const entry = document.createElement('div'); entry.className = type || ''; entry.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg; logDiv.appendChild(entry); while (logDiv.childElementCount > 60) logDiv.removeChild(logDiv.firstChild); logDiv.scrollTop = logDiv.scrollHeight; }, setStatus(msg) { const el = document.getElementById('f3SetStatus'); if (el) el.textContent = msg || ''; }, progress(done, total, fail, t0) { const fill = document.getElementById('f3DlFill'); const status = document.getElementById('f3DlStatus'); const speed = document.getElementById('f3DlSpeed'); const pct = total ? Math.round((done / total) * 100) : 0; if (fill) fill.style.width = pct + '%'; if (status) status.textContent = done + '/' + total + (fail ? '(失败' + fail + ')' : ''); if (speed) { const sec = (Date.now() - t0) / 1000; speed.textContent = (sec > 0 ? (done / sec) : 0).toFixed(1) + '章/秒'; } const btn = document.getElementById('f3DlBtn'); const pop = document.getElementById('f3DlPop'); const popOpen = pop && pop.style.display === 'block'; if (btn && !popOpen) btn.textContent = '⬇ 下载中 ' + pct + '%'; }, cancel() { if (this.running) { this.cancelFlag = true; this.log('已请求取消,等待在途章节完成…'); } }, clearBookData(bookId) { bookId = String(bookId); cacheEpoch++; flushMeta(); const meta = getMeta(); let n = 0; Object.keys(meta).forEach((id) => { if (meta[id].b === bookId) { ChapterCache.drop(id); delete meta[id]; n++; } }); saveMetaNow(meta); try { localStorage.removeItem(TocStore.key(bookId)); } catch (e) { /* ignore */ } try { localStorage.removeItem(Progress.key(bookId)); } catch (e) { /* ignore */ } try { localStorage.removeItem('f3_fold_' + bookId); } catch (e) { /* ignore */ } try { const info = getBookInfo(); if (info[bookId]) { delete info[bookId]; saveBookInfo(info); } } catch (e) { /* ignore */ } if (String(ctx.bookId) === bookId) Toc.reset(); Reader._lastPrefetchId = null; Reader.painted = null; this.setStatus('已清除本书 ' + n + ' 章缓存、目录与阅读进度 ✓'); setTimeout(() => this.setStatus(''), 3000); }, clearChapterCache() { cacheEpoch++; flushMeta(); const meta = getMeta(); const n = Object.keys(meta).length; Object.keys(meta).forEach((id) => ChapterCache.drop(id)); try { GM_deleteValue('f3_meta'); } catch (e) { /* ignore */ } // 目录缓存一并清空(防止旧版本存的残缺目录继续作祟) let tocN = 0; try { Object.keys(localStorage) .filter((k) => k.indexOf('fq4_toc_') === 0) .forEach((k) => { localStorage.removeItem(k); tocN++; }); } catch (e) { /* ignore */ } Toc.reset(); Reader._lastPrefetchId = null; Reader.painted = null; this.setStatus('已清除 ' + n + ' 章缓存 + ' + tocN + ' 本书目录 ✓'); setTimeout(() => this.setStatus(''), 3000); }, clearProgress() { try { const keys = Object.keys(localStorage).filter((k) => k.indexOf('fq4_progress_') === 0); keys.forEach((k) => localStorage.removeItem(k)); this.setStatus('已清除 ' + keys.length + ' 本书的阅读进度 ✓'); } catch (e) { this.setStatus('清除失败'); } setTimeout(() => this.setStatus(''), 3000); }, async startDownload(bookId) { if (this.running) return; this.running = true; this.cancelFlag = false; const self = this; const startBtn = document.getElementById('f3DlGo'); const cancelBtn = document.getElementById('f3DlCancel'); const prog = document.getElementById('f3DlProgress'); const pop = document.getElementById('f3DlPop'); if (startBtn) startBtn.disabled = true; if (cancelBtn) cancelBtn.style.display = 'block'; if (prog) prog.style.display = 'none'; if (pop) pop.style.display = 'block'; const finish = () => { if (startBtn) startBtn.disabled = false; if (cancelBtn) cancelBtn.style.display = 'none'; Sidebar.updateDlBtn(); self.running = false; }; // 目录:书籍页/持久缓存/站内捕获,任一来源都行 Toc.ensure(); const toc = ctx.toc || []; if (!toc.length) { this.log('未取到章节列表:请先打开书籍详情页(或站内「目录」按钮)加载目录后再下载', 'err'); finish(); return; } if (!ctx.bookName) { const cd = ctx.chapterId ? freshChapterData(ctx.chapterId) : null; if (cd && cd.bookName) ctx.bookName = String(cd.bookName); } const bookName = ctx.bookName || 'novel'; const startInput = document.getElementById('f3DlStart'); const endInput = document.getElementById('f3DlEnd'); const concInput = document.getElementById('f3DlConc'); const startN = Math.max(1, parseInt(startInput && startInput.value, 10) || 1); let endN = parseInt(endInput && endInput.value, 10) || toc.length; endN = Math.min(Math.max(endN, 1), toc.length); // 源官方建议单批 ≤100;默认 10(服务端逐章处理,批越大单批耗时越长) let BATCH = Math.min(Math.max(parseInt(concInput && concInput.value, 10) || 10, 1), 100); const chapters = toc.filter((ch) => ch.globalIndex >= startN - 1 && ch.globalIndex <= endN - 1); const total = chapters.length; if (!total) { this.log('章节范围为空', 'err'); finish(); return; } if (prog) prog.style.display = 'block'; this.log('开始下载《' + bookName + '》第 ' + startN + ' ~ ' + (startN + total - 1) + ' 章,每批 ' + BATCH + ' 章、共 ' + Math.ceil(total / BATCH) + ' 批'); const results = new Array(total); let done = 0; let ok = 0; let fail = 0; const t0 = Date.now(); for (let bstart = 0; bstart < total; bstart += BATCH) { if (this.cancelFlag) { this.log('⏹ 下载已取消', 'err'); break; } const ids = chapters.slice(bstart, bstart + BATCH).map((c) => String(c.itemId)); let arr = await Api.batch(ids, bookId); if (!arr) { // 整批失败:歇 3 秒整批重试一次 await new Promise((r) => setTimeout(r, 3000)); arr = await Api.batch(ids, bookId); } if (arr) { arr.forEach((r, k) => { const ch = chapters[bstart + k]; if (r.html) { results[bstart + k] = { ch: ch, html: r.html }; ok++; } else { fail++; this.log('✗ ' + ch.title + ' 获取失败', 'err'); } }); } else { chapters.slice(bstart, bstart + BATCH).forEach((ch) => { fail++; this.log('✗ ' + ch.title + ' 整批失败', 'err'); }); } done = Math.min(bstart + BATCH, total); this.progress(done, total, fail, t0); } // 缺章逐章补(外部源偶发失败时,重试一轮,失败退避 2 秒) const missing = []; for (let k = 0; k < total; k++) { if (!results[k]) missing.push(chapters[k]); } if (missing.length && !this.cancelFlag) { this.log('缺 ' + missing.length + ' 章,逐章补取…'); for (const ch of missing) { if (this.cancelFlag) break; let html = await Api.chapter(ch.itemId); if (!html) { await new Promise((r) => setTimeout(r, 2000)); html = await Api.chapter(ch.itemId); } const target = chapters.findIndex((c) => String(c.itemId) === String(ch.itemId)); // 批量阶段已对缺章 fail++ 过:补取成功冲销一笔,失败不重复计 if (html && target >= 0) { results[target] = { ch: ch, html: html }; ok++; fail--; } } } // 拼 TXT(分卷标题 + 章节标题 + 正文) const lines = []; lines.push('《' + bookName + '》'); lines.push('下载时间: ' + new Date().toLocaleString()); lines.push(''); let curVol = null; let savedCount = 0; results.forEach((r) => { if (!r) return; savedCount++; if (r.ch.volumeTitle && r.ch.volumeIndex !== curVol) { curVol = r.ch.volumeIndex; lines.push(''); lines.push('=== ' + r.ch.volumeTitle + ' ==='); lines.push(''); } lines.push(''); lines.push(r.ch.title); lines.push(''); lines.push(htmlToText(r.html)); lines.push(''); }); if (savedCount) { const blob = new Blob(['\uFEFF' + lines.join('\n')], { type: 'text/plain;charset=utf-8' }); const safeName = bookName.replace(/[\\/:*?"<>|]/g, '_'); saveAs(blob, safeName + '.txt'); this.log('💾 已保存 ' + safeName + '.txt(' + savedCount + '/' + total + ' 章)', 'ok'); } else { this.log('没有成功章节,未生成文件', 'err'); } if (this.cancelFlag && savedCount < total) { this.log('下载被取消,仅包含已完成部分'); } finish(); } }; // ============================================================ // 16. 路由与启动 // ============================================================ function dispatchRoute() { const pm = location.pathname.match(/\/page\/(\d+)/); if (pm) { // 书籍详情页:URL 即身份;权威目录每次进入都刷新持久缓存 if (String(ctx.bookId) !== pm[1]) { ctx.bookId = pm[1]; ctx.bookName = null; Toc.reset(); } ctx.pending = false; Reader.cancelPending(); Toc.captureFromBookPage(); Sidebar.refresh(); return; } const rid = extractChapterId(); if (rid) { // 阅读页:URL 无书籍身份,等 state 与本章一致后再确认(宁可 pending,不可记错书) if (!ctx.bookId) ctx.pending = true; syncBookFromState(freshChapterData(rid)); // state 已刷新时先定身份,消除串书窗口 Reader.onChapter(rid); Sidebar.refresh(); return; } // 其他页面(首页/榜单/书库):停掉阅读相关轮询 Reader.cancelPending(); Sidebar.refresh(); } function installRouting() { let lastHref = location.href; const notify = () => { if (location.href === lastHref) return; lastHref = location.href; dispatchRoute(); }; ['pushState', 'replaceState'].forEach((type) => { const orig = history[type]; if (typeof orig !== 'function') return; history[type] = function () { const ret = orig.apply(this, arguments); try { window.dispatchEvent(new Event('locationchange')); } catch (e) { /* ignore */ } return ret; }; }); window.addEventListener('popstate', notify); window.addEventListener('locationchange', notify); } // 一次性清理:旧版本的残留键(v3.x 时代 fq3_toc_/fq_toc_、 // sessionStorage 的 fq4_ch_、无卷结构的 fq4_toc_ V1 残留) function cleanupLegacyCaches() { try { Object.keys(localStorage) .filter((k) => k.indexOf('fq3_toc_') === 0 || k.indexOf('fq_toc_') === 0) .forEach((k) => localStorage.removeItem(k)); Object.keys(localStorage) .filter((k) => k.indexOf('fq4_toc_') === 0) .forEach((k) => { const o = safeJSON(localStorage.getItem(k) || 'null', null); if (!(o && Array.isArray(o.volumes))) localStorage.removeItem(k); }); } catch (e) { /* ignore */ } try { Object.keys(sessionStorage) .filter((k) => k.indexOf('fq4_ch_') === 0) .forEach((k) => sessionStorage.removeItem(k)); } catch (e) { /* ignore */ } } function boot() { cleanupLegacyCaches(); injectReaderCSS(); installTocCapture(); installRouting(); whenReady(() => { Sidebar.mount(); dispatchRoute(); }); // document-start 就处理阅读页(正文画入不等待 DOMContentLoaded, // paint 自带重试;身份/进度确认走 state 轮询,同样不依赖 ready) dispatchRoute(); } // 调试出口 try { window.fqDebug = { ctx: ctx, Toc: Toc, Reader: Reader, Downloader: Downloader, ChapterCache: ChapterCache }; } catch (e) { /* ignore */ } boot(); })();