// ==UserScript== // @name 番🍅茄小说阅读助手 // @namespace fanqie-reader // @version 1.2.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 oiapi.net // @run-at document-start // @license MIT // ==/UserScript== (function () { 'use strict'; // ============================================================ // 0. 常量与设置 // ============================================================ const VERSION = '1.2.0'; const TAG = '[fq]'; // 外部源:OIAPI(https://oiapi.net/api/FqRead),需个人 Key(QQ 机器人申请)。 // 按请求计费——实测 2026-10-18:5 章批量 = 1 次请求 = 1 额度,失败请求同样计费 const API_URL = 'https://oiapi.net/api/FqRead'; // 块预取:全书按 10 章一块(用户定版 2026-10-18)—— // 逢每块第 9 章预取下一整块;第 11 章起落到未缓存章按所在块整取;第 1~10 章永不预取 const PF_BLOCK = 10; const DEFAULT_SETTINGS = { cacheMax: 2000, // 章节缓存上限(章),按书淘汰 keepBehind: 20, // 读点之前保留多少章 protectDays: 7, // 近几天读过的书不被整本清理 poolMB: 5, // 进度 + 目录 共用 localStorage 池上限 apiKey: '' // OIAPI 个人密钥,只保存在本机 }; function getSettings() { try { const saved = JSON.parse(GM_getValue('f3_settings', '{}') || '{}'); return Object.assign({}, DEFAULT_SETTINGS, saved); } catch (e) { return Object.assign({}, DEFAULT_SETTINGS); } } function saveSettingsObj(s) { try { GM_setValue('f3_settings', JSON.stringify(s)); } catch (e) { /* ignore */ } } function getApiKey() { return String(getSettings().apiKey || '').trim(); } // 当地日期(YYYY-MM-DD),用量统计按自然日归零 function localToday() { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } // 用量统计(本地估算):对照 QQ 机器人里的额度余额,就能确认实际计费粒度 function bumpUsage(nChapters) { try { const today = localToday(); const u = safeJSON(GM_getValue('f3_usage', '{}') || '{}', {}) || {}; const cur = (u.d === today) ? u : { d: today, req: 0, ch: 0 }; cur.req++; cur.ch += Math.max(0, nChapters || 0); GM_setValue('f3_usage', JSON.stringify(cur)); } catch (e) { /* ignore */ } } function usageText() { try { const u = safeJSON(GM_getValue('f3_usage', '{}') || '{}', {}) || {}; if (u.d !== localToday()) return '今日尚未发起请求'; return '今日已发 ' + (u.req || 0) + ' 次请求 / 取得 ' + (u.ch || 0) + ' 章(本地统计)'; } catch (e) { return ''; } } // ============================================================ // 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/ord(下载器/批量取文依赖),重算补齐 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; if (typeof ch.ord !== 'number') ch.ord = gi + 1; 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, ord: globalIndex + 1 // 全书序号(批量取文用);站点顺序即接口序号 }); 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, ord: i + 1, 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) => { let t = tag.replace(/([\s/])on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '$1'); // 纵深复查:浏览器解析属性值时会解码实体(如 javascript:), // 解码后出现脚本协议的 URL 属性整体移除 if (t.indexOf('&#') !== -1 && /(?:java|vb)script:/i.test(decodeEntities(t))) { t = t.replace(/\s(?:href|src|action|formaction)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, ''); } return t; }) .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(OIAPI:单章/批量)。纯取文,不碰缓存—— // 缓存写入只发生在阅读路径(Fetching),下载绝不污染阅读缓存 // ============================================================ // 契约实测 2026-10-18(真机验证):POST form 到 API_URL,成功 code===1(兼容 "1")。 // - 单章 method=ids:id=章节Id(无需目录,阅读主路径);data 为 list(单元素)。 // - 批量 method=chapter:id=bookId,chapter=全书序号逗号分隔;data 为 list, // 每项含 chapter_id / chapter(序号) / chapter_title / content。 // - 正文是纯文本(\n\n 分段),开头带"章标题\n\n"前缀,剥除后逐行转

。 // - 批量响应实测倒序返回——顺序不可信,一律按 chapter_id 匹配请求集, // 认不出的章一律丢弃(宁缺勿错,错配是污染缓存的事故)。 // - 频控特征:message 含 "Trying to access array offset";无 key:code=-42。 // - 目录依旧零请求(详情页/站内捕获/本地缓存三来源原样保留); // 批量取文需要全书序号,故预取与下载仍依赖目录(与旧版一致)。 function chapterContentToHtml(raw, title) { let t = String(raw || '').replace(/\r\n?/g, '\n').trim(); if (!t) return ''; if (title) { const prefix = title + '\n\n'; if (t.indexOf(prefix) === 0) t = t.slice(prefix.length); else if (t.indexOf(title) === 0) t = t.slice(title.length).trim(); } // 万一以后改为 HTML 返回,仍走既有净化管线;纯文本则逐行转

(esc 防注入) if (/<\s*(p|br|div|article)\b/i.test(t)) return normalizeHtml(t); return t.split(/\n+/) .map((line) => line.trim()) .filter(Boolean) .map((line) => '

' + esc(line) + '

') .join('\n'); } // 从取文响应提取章节项:[{chapterId, ord, html}];空/畸形项丢弃 function extractChapterItems(json) { if (!json || String(json.code) !== '1') return []; let data = json.data; if (data && !Array.isArray(data) && typeof data === 'object') { data = Array.isArray(data.data) ? data.data : null; // 兼容 dict 包一层的形态 } if (!Array.isArray(data)) return []; const items = []; data.forEach((it) => { if (!it || typeof it !== 'object' || it.chapter_id == null) return; const title = it.chapter_title != null ? String(it.chapter_title) : ''; const html = chapterContentToHtml(it.content, title); if (!html) return; // 空内容不占位 const ord = parseInt(it.chapter, 10); items.push({ chapterId: String(it.chapter_id), ord: isNaN(ord) ? null : ord, html: html }); }); return items; } function apiForm(apiKey, method, id, chapter) { const p = new URLSearchParams(); p.set('key', apiKey); p.set('type', 'json'); p.set('method', method); p.set('id', id); if (chapter != null) p.set('chapter', chapter); return p.toString(); } function apiRequest(form, nChapters) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: API_URL, data: form, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, // 超时随批量放宽:大批量(下载 50~100 章/批)服务端处理更久, // 宁可多等也不能让"服务端已计费但本地超时"的双输情况发生 timeout: Math.min(300000, 30000 + (nChapters || 0) * 3000), onload: (res) => { bumpUsage(nChapters); // 失败请求同样计费(实测),一律计数 const json = safeJSON(res.responseText, null); if (!json) { console.log(TAG, 'OIAPI 响应非 JSON:', String(res.responseText).slice(0, 120)); resolve(null); return; } if (String(json.code) !== '1') { const msg = String(json.message || ''); if (msg.indexOf('Trying to access array offset') !== -1) { console.log(TAG, 'OIAPI 频控(PHP 后端拒绝),稍后重试即可'); } else if (/额度|余额|quota/i.test(msg)) { // Key 在但额度用尽:弹"签到领额度"引导(10 分钟节流) console.log(TAG, 'OIAPI 额度不足:', msg); Guide.quotaShort(); } else if (/key/i.test(msg)) { // Key 无效/已失效:走申请引导(重新申请或检查填写) console.log(TAG, 'OIAPI Key 异常:', msg); Guide.needKey(); } else { console.log(TAG, 'OIAPI code', json.code, ':', msg.slice(0, 100)); } resolve(null); return; } resolve(json); }, onerror: () => { bumpUsage(nChapters); console.log(TAG, 'OIAPI 网络错误'); resolve(null); }, ontimeout: () => { bumpUsage(nChapters); console.log(TAG, 'OIAPI 请求超时'); resolve(null); } }); }); } const Api = { // 单章:按章节 Id 取(无需目录) chapterById(id) { const key = getApiKey(); if (!key) return Promise.resolve(null); return apiRequest(apiForm(key, 'ids', String(id), null), 1) .then((json) => extractChapterItems(json)[0] || null); }, _batchInflight: new Map(), // 批量:书 Id + 全书序号列表(N 章 = 1 额度)。返回 [{chapterId, ord, html}],可能不全。 // 同书同序号列表的在途请求直接复用——翻太快时"逢九预取"与"按块取文"撞车, // 不去重就会同一批扣两次额度 batch(bookId, ords) { const key = getApiKey(); if (!key || !ords || !ords.length) return Promise.resolve(null); const dedupKey = String(bookId) + '|' + ords.join(','); const dup = this._batchInflight.get(dedupKey); if (dup) { console.log(TAG, '批量请求在途,复用结果(去重)'); return dup; } const p = apiRequest(apiForm(key, 'chapter', String(bookId), ords.join(',')), ords.length) .then((json) => { const items = extractChapterItems(json); return items.length ? items : null; }); this._batchInflight.set(dedupKey, p); const cleanup = () => { this._batchInflight.delete(dedupKey); }; p.then(cleanup, cleanup); return p; } }; // 阅读取文入口:缓存 -> 在途去重 -> 按章节 Id 请求并落缓存(代际校验, // 清缓存后在途结果不入库;响应 chapter_id 与请求章不符则丢弃,绝不张冠李戴) const Fetching = { inflight: new Map(), get(id, bookIdHint) { const cached = ChapterCache.get(id); if (cached) return Promise.resolve(cached); if (this.inflight.has(id)) return this.inflight.get(id); const ep = cacheEpoch; // 记账身份:调用方显式传入(身份已核实)才记账到该书,否则挂 '?'。 // 换书直跳且 state 未刷新时 ctx.bookId 可能还是旧书——宁挂待定不误记 const bid = bookIdHint != null && bookIdHint !== '' ? String(bookIdHint) : '?'; const p = Api.chapterById(id).then((r) => { this.inflight.delete(id); if (!r || !r.html) return null; if (String(r.chapterId) !== String(id)) { console.log(TAG, '取文身份不符:请求', id, '响应', r.chapterId, ',丢弃'); return null; } if (ep !== cacheEpoch) return r.html; ChapterCache.set(id, r.html); // 无条件记账(身份未知挂 '?',序号待目录确认后由 recordProgress 补记): // 缓存条目必须必有 meta,否则淘汰与"清空全部"都会漏掉这份孤儿缓存 setMeta(id, bid, -1); runEviction(); return r.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, ord: i + 1, 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 { const u = xhr.__f3url || ''; if (u && (u.indexOf('fanqienovel') !== -1 || u.charAt(0) === '/')) { 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(); // 已读标记快速通道:本章已在会话目录里(目录本身经书籍页归属校验), // 身份即刻可信,不等 state 逐章刷新——连续快翻时 state 确认赶不上翻页, // 慢路径会漏标(已读勾跨章断裂的根因) if (ctx.bookId && ctx.toc.some((ch) => String(ch.itemId) === String(id))) { this.recordProgress(id); } // 身份确认 + 进度记录:独立轮询,不阻塞缓存命中时的正文画入 this.confirmBook(id, mySeq); const cached = ChapterCache.get(id); if (cached) { console.log(TAG, id, '命中缓存,直接画入'); this.paint(id, cached); this.prefetchAround(id); return; } // 目录快路径:会话目录已有本章(线性翻章的常态)→ 免判定轮询立即行动, // 锁定章直接取文(省 0.25~1s 首字延迟),试读章直接放行。目录里找不到 // 本章(冷加载/无目录/新章超出目录)才走下方判定轮询 const tocHit = ctx.bookId ? ctx.toc.find((ch) => String(ch.itemId) === String(id)) : null; if (tocHit) { this.cancelPending(); const idx = ctx.toc.indexOf(tocHit); if (tocHit.isChapterLock === false) { console.log(TAG, id, '目录标记试读,站点原生渲染;预取后续锁定章节'); this.prefetchAround(id); return; } console.log(TAG, id, '目录标记锁定,跳过判定直接取全文'); if (!getApiKey()) { Guide.needKey(); return; } const ord = (tocHit.ord != null) ? tocHit.ord : tocHit.globalIndex + 1; const p = (ord >= PF_BLOCK + 1) ? this.blockFetch(id, ord, ctx.toc) : Fetching.get(id, ctx.bookId); p.then((html) => { if (mySeq !== this.seq) return; if (!html) { toast('[fq] 本章获取失败:外部源超时或被限流,稍后重开本章会自动重试'); return; } if (ctx.bookId && ChapterCache.has(id)) setMeta(id, ctx.bookId, idx); this.paint(id, html); this.prefetchAround(id); }).catch(() => { /* ignore */ }); return; } // 判定循环:等页面 state 与本章一致(最多 60 × 120ms ≈ 7.2s) 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 旧包也能判) // ④ 全都没有 → 等下一拍;60 拍后按锁定兜底,保证读得到 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 >= 60) decision = 'locked'; // 拿不准按锁定处理,保证读得到 if (!decision) return; this.cancelPending(); // 身份是否已核实(state 与本章对上):决定记账挂真书名还是 '?' const identityKnown = !!(cd && cd.bookId); if (decision === 'trial') { console.log(TAG, id, '试读章节,站点原生渲染,不干预;预取后续锁定章节'); this.prefetchAround(id); return; } console.log(TAG, id, '锁定章节,取全文'); if (!getApiKey()) { this.cancelPending(); Guide.needKey(); // 首触弹完整申请引导(二维码),重复触发降级为 toast return; } 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); const entry = idx >= 0 ? toc[idx] : null; const ord = entry ? ((entry.ord != null) ? entry.ord : entry.globalIndex + 1) : 0; // 第 11 章起落到未缓存锁定章 → 按所在块整取(本章含在内); // 第 1 块(1~10 章,永不预取)或无目录 → 单章取 const p = (ord >= PF_BLOCK + 1 && ctx.bookId) ? this.blockFetch(id, ord, toc) : Fetching.get(id, identityKnown ? ctx.bookId : null); return p.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 */ }); }, 120); }, 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; // 固定进入时刻的书籍身份,防回调执行时已换书 // 标题优先取目录(即时且准确);刚翻章时 DOM 标题可能还停留在上一章 const tocCh = ctx.toc.find((ch) => String(ch.itemId) === String(id)); Progress.markRead(bookId, id, (tocCh && tocCh.title) || 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 */ } }, // 块预取(逢九续块):读到每块第 9 章(9/19/29…)时预取下一个整块, // 读到块尾不空等;第 1 块(1~10 章)永不预取。同一章只触发一轮, // 闩锁在算出结果后才落:身份/目录未就绪时不闩,本章还有重试机会 prefetchAround(currentId) { const key = String(currentId); if (this._lastPrefetchId === key) return; if (this._pfTimer) clearTimeout(this._pfTimer); this._pfTimer = setTimeout(() => { 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 cur = toc.find((ch) => String(ch.itemId) === String(currentId)); if (!cur) { // 目录里还没有本章:延后重试一次(不闩锁),仍没有就放弃—— // 之后若落到未缓存章,阅读路径的"按块整取"会兜底 if (!freshChapterData(currentId) && this._pfRetryKey !== key) { this._pfRetryKey = key; setTimeout(() => { if (String(ctx.chapterId) === key) this.prefetchAround(currentId); }, 2000); } else { this._lastPrefetchId = key; } return; } const ord = (cur.ord != null) ? cur.ord : cur.globalIndex + 1; this._lastPrefetchId = key; // 本章预取事务已定型,此后不再重复 if ((ord - 1) % PF_BLOCK !== PF_BLOCK - 2) return; // 非逢九:无事可做 const nextStart = (Math.floor((ord - 1) / PF_BLOCK) + 1) * PF_BLOCK + 1; // 下一块剔除试读章与已缓存章;批量响应只认 chapter_id,集外一律丢弃 const expect = {}; const ords = []; for (let o = nextStart; o < nextStart + PF_BLOCK; o++) { const e = toc.find((c) => ((c.ord != null ? c.ord : c.globalIndex + 1) === o)); if (!e || e.isChapterLock === false) continue; // 试读章不花额度 if (ChapterCache.has(String(e.itemId))) continue; expect[String(e.itemId)] = e; ords.push(o); } if (!ords.length) return; console.log(TAG, '逢九预取下一块', nextStart + '~' + (nextStart + PF_BLOCK - 1), ':', ords.length, '章(1 次请求)'); Api.batch(bookId, ords).then((list) => { if (!list || ep !== cacheEpoch || String(ctx.bookId) !== bookId) return; // 失败/已清缓存/已换书 let got = 0; list.forEach((r) => { if (!r || !r.html) return; const cid = String(r.chapterId); const e = expect[cid]; if (!e || ChapterCache.has(cid)) return; ChapterCache.set(cid, r.html); setMeta(cid, bookId, e.globalIndex); got++; }); if (got) { runEviction(); console.log(TAG, '预取完成:', got, '章入缓存'); } }); }).catch(() => { /* ignore */ }); }, 500); }, // 阅读取文(第 11 章起落到未缓存锁定章):按所在块一次整取(本章含在内, // 1 次请求 = 1 额度)。块失败或响应缺本章时单章兜底,保证当前章必出 blockFetch(id, ord, toc) { const bookId = String(ctx.bookId); const blockStart = Math.floor((ord - 1) / PF_BLOCK) * PF_BLOCK + 1; const expect = {}; const ords = []; for (let o = blockStart; o < blockStart + PF_BLOCK; o++) { const e = toc.find((c) => ((c.ord != null ? c.ord : c.globalIndex + 1) === o)); if (!e || e.isChapterLock === false) continue; // 试读章不花额度 if (ChapterCache.has(String(e.itemId))) continue; // 已缓存不重取 expect[String(e.itemId)] = e; ords.push(o); } const ep = cacheEpoch; return Api.batch(bookId, ords).then((list) => { let html = null; if (list && ep === cacheEpoch) { let got = 0; list.forEach((r) => { if (!r || !r.html) return; const cid = String(r.chapterId); const hit = expect[cid]; if (!hit) return; // 请求集外:丢弃 if (!ChapterCache.has(cid)) { ChapterCache.set(cid, r.html); setMeta(cid, bookId, hit.globalIndex); got++; } if (cid === String(id)) html = r.html; }); if (got) { runEviction(); console.log(TAG, '按块取文', blockStart + '~' + (blockStart + PF_BLOCK - 1), ':', got, '章入缓存'); } } if (!html) { console.log(TAG, '块取未含本章,单章兜底'); return Fetching.get(id, bookId); } return html; }); } }; // ============================================================ // 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); } // ============================================================ // 12.5 引导弹窗:Key 申请引导 / 额度不足提示 // 二维码由内嵌的 qrcode-generator(MIT,见下方库代码)本地生成, // 不请求任何第三方图片服务 // ============================================================ // ===== qrcode-generator 1.4.4 (MIT) begin ===== /** * Minified by jsDelivr using Terser v5.37.0. * Original file: /npm/qrcode-generator@1.4.4/qrcode.js * * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files */ var qrcode=function(){var t=function(t,r){var e=t,n=g[r],o=null,i=0,a=null,u=[],f={},c=function(t,r){o=function(t){for(var r=new Array(t),e=0;e=7&&v(t),null==a&&(a=p(e,n,u)),w(a,r)},l=function(t,r){for(var e=-1;e<=7;e+=1)if(!(t+e<=-1||i<=t+e))for(var n=-1;n<=7;n+=1)r+n<=-1||i<=r+n||(o[t+e][r+n]=0<=e&&e<=6&&(0==n||6==n)||0<=n&&n<=6&&(0==e||6==e)||2<=e&&e<=4&&2<=n&&n<=4)},h=function(){for(var t=8;t>n&1);o[Math.floor(n/3)][n%3+i-8-3]=a}for(n=0;n<18;n+=1){a=!t&&1==(r>>n&1);o[n%3+i-8-3][Math.floor(n/3)]=a}},d=function(t,r){for(var e=n<<3|r,a=B.getBCHTypeInfo(e),u=0;u<15;u+=1){var f=!t&&1==(a>>u&1);u<6?o[u][8]=f:u<8?o[u+1][8]=f:o[i-15+u][8]=f}for(u=0;u<15;u+=1){f=!t&&1==(a>>u&1);u<8?o[8][i-u-1]=f:u<9?o[8][15-u-1+1]=f:o[8][15-u-1]=f}o[i-8][8]=!t},w=function(t,r){for(var e=-1,n=i-1,a=7,u=0,f=B.getMaskFunction(r),c=i-1;c>0;c-=2)for(6==c&&(c-=1);;){for(var g=0;g<2;g+=1)if(null==o[n][c-g]){var l=!1;u>>a&1)),f(n,c-g)&&(l=!l),o[n][c-g]=l,-1==(a-=1)&&(u+=1,a=7)}if((n+=e)<0||i<=n){n-=e,e=-e;break}}},p=function(t,r,e){for(var n=A.getRSBlocks(t,r),o=b(),i=0;i8*u)throw"code length overflow. ("+o.getLengthInBits()+">"+8*u+")";for(o.getLengthInBits()+4<=8*u&&o.put(0,4);o.getLengthInBits()%8!=0;)o.putBit(!1);for(;!(o.getLengthInBits()>=8*u||(o.put(236,8),o.getLengthInBits()>=8*u));)o.put(17,8);return function(t,r){for(var e=0,n=0,o=0,i=new Array(r.length),a=new Array(r.length),u=0;u=0?h.getAt(s):0}}var v=0;for(g=0;gn)&&(t=n,r=e)}return r}())},f.createTableTag=function(t,r){t=t||2;var e="";e+='',e+="";for(var n=0;n";for(var o=0;o';e+=""}return e+="",e+="
"},f.createSvgTag=function(t,r,e,n){var o={};"object"==typeof arguments[0]&&(t=(o=arguments[0]).cellSize,r=o.margin,e=o.alt,n=o.title),t=t||2,r=void 0===r?4*t:r,(e="string"==typeof e?{text:e}:e||{}).text=e.text||null,e.id=e.text?e.id||"qrcode-description":null,(n="string"==typeof n?{text:n}:n||{}).text=n.text||null,n.id=n.text?n.id||"qrcode-title":null;var i,a,u,c,g=f.getModuleCount()*t+2*r,l="";for(c="l"+t+",0 0,"+t+" -"+t+",0 0,-"+t+"z ",l+=''+y(n.text)+"":"",l+=e.text?''+y(e.text)+"":"",l+='',l+='":r+=">";break;case"&":r+="&";break;case'"':r+=""";break;default:r+=n}}return r};return f.createASCII=function(t,r){if((t=t||1)<2)return function(t){t=void 0===t?2:t;var r,e,n,o,i,a=1*f.getModuleCount()+2*t,u=t,c=a-t,g={"██":"█","█ ":"▀"," █":"▄"," ":" "},l={"██":"▀","█ ":"▀"," █":" "," ":" "},h="";for(r=0;r=c?l[i]:g[i];h+="\n"}return a%2&&t>0?h.substring(0,h.length-a-1)+Array(a+1).join("▀"):h.substring(0,h.length-1)}(r);t-=1,r=void 0===r?2*t:r;var e,n,o,i,a=f.getModuleCount()*t+2*r,u=r,c=a-r,g=Array(t+1).join("██"),l=Array(t+1).join(" "),h="",s="";for(e=0;e>>8),r.push(255&a)):r.push(n)}}return r}};var r,e,n,o,i,a=1,u=2,f=4,c=8,g={L:1,M:0,Q:3,H:2},l=0,h=1,s=2,v=3,d=4,w=5,p=6,y=7,B=(r=[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],e=1335,n=7973,i=function(t){for(var r=0;0!=t;)r+=1,t>>>=1;return r},(o={}).getBCHTypeInfo=function(t){for(var r=t<<10;i(r)-i(e)>=0;)r^=e<=0;)r^=n<5&&(e+=3+i-5)}for(n=0;n=256;)r-=255;return t[r]}};return n}();function k(t,r){if(void 0===t.length)throw t.length+"/"+r;var e=function(){for(var e=0;e>>7-r%8&1)},put:function(t,r){for(var n=0;n>>r-n-1&1))},getLengthInBits:function(){return r},putBit:function(e){var n=Math.floor(r/8);t.length<=n&&t.push(0),e&&(t[n]|=128>>>r%8),r+=1}};return e},M=function(t){var r=a,e=t,n={getMode:function(){return r},getLength:function(t){return e.length},write:function(t){for(var r=e,n=0;n+2>>8&255)+(255&n),t.put(n,13),e+=2}if(e>>8)},writeBytes:function(t,e,n){e=e||0,n=n||t.length;for(var o=0;o0&&(r+=","),r+=t[e];return r+="]"}};return r},S=function(t){var r=t,e=0,n=0,o=0,i={read:function(){for(;o<8;){if(e>=r.length){if(0==o)return-1;throw"unexpected end of file./"+o}var t=r.charAt(e);if(e+=1,"="==t)return o=0,-1;t.match(/^\s$/)||(n=n<<6|a(t.charCodeAt(0)),o+=6)}var i=n>>>o-8&255;return o-=8,i}},a=function(t){if(65<=t&&t<=90)return t-65;if(97<=t&&t<=122)return t-97+26;if(48<=t&&t<=57)return t-48+52;if(43==t)return 62;if(47==t)return 63;throw"c:"+t};return i},I=function(t,r,e){for(var n=function(t,r){var e=t,n=r,o=new Array(t*r),i={setPixel:function(t,r,n){o[r*e+t]=n},write:function(t){t.writeString("GIF87a"),t.writeShort(e),t.writeShort(n),t.writeByte(128),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(255),t.writeByte(255),t.writeByte(255),t.writeString(","),t.writeShort(0),t.writeShort(0),t.writeShort(e),t.writeShort(n),t.writeByte(0);var r=a(2);t.writeByte(2);for(var o=0;r.length-o>255;)t.writeByte(255),t.writeBytes(r,o,255),o+=255;t.writeByte(r.length-o),t.writeBytes(r,o,r.length-o),t.writeByte(0),t.writeString(";")}},a=function(t){for(var r=1<>>r!=0)throw"length over";for(;c+r>=8;)f.writeByte(255&(t<>>=8-c,g=0,c=0;g|=t<0&&f.writeByte(g)}});h.write(r,n);var s=0,v=String.fromCharCode(o[s]);for(s+=1;s=6;)i(t>>>r-6),r-=6},o.flush=function(){if(r>0&&(i(t<<6-r),t=0,r=0),e%3!=0)for(var o=3-e%3,a=0;a>6,128|63&n):n<55296||n>=57344?r.push(224|n>>12,128|n>>6&63,128|63&n):(e++,n=65536+((1023&n)<<10|1023&t.charCodeAt(e)),r.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n))}return r}(t)},function(t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports&&(module.exports=t())}((function(){return qrcode})); // ===== qrcode-generator end ===== const GUIDE_BOT_URL = 'https://bot.q.qq.com/s/ucBwC9Kk7u'; const Guide = { _keyGuideShown: false, // 每次页面加载最多弹一次申请引导,之后降级为 toast _quotaAt: 0, // 额度提示节流:10 分钟内不重复弹 _cssDone: false, _css() { if (this._cssDone) return; this._cssDone = true; const st = document.createElement('style'); st.id = 'f3-guide-css'; st.textContent = ` #f3-guide-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 99999; background: rgba(15, 23, 42, .45); display: flex; align-items: center; justify-content: center; font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; } .f3-gd-card { width: min(372px, calc(100vw - 32px)); max-height: 88vh; overflow-y: auto; background: #fff; border-radius: 14px; padding: 16px; box-shadow: 0 12px 40px rgba(15, 23, 42, .25); color: #1f2329; box-sizing: border-box; } .f3-gd-title { font-size: 15px; font-weight: 700; margin-bottom: 10px; } .f3-gd-sec { font-size: 12px; line-height: 1.7; margin-bottom: 8px; color: #344054; } .f3-gd-sec b { color: #1f2329; } .f3-gd-qr { display: flex; justify-content: center; padding: 4px 0 2px; } .f3-gd-qr svg { width: 168px; height: 168px; } .f3-gd-hint { font-size: 11px; color: #98a2b3; text-align: center; word-break: break-all; margin-bottom: 8px; } .f3-gd-fine { font-size: 10px; color: #c0c7d1; line-height: 1.6; margin-top: 6px; } .f3-gd-btns { display: flex; gap: 8px; margin-top: 12px; } .f3-gd-btns button { flex: 1; border: 1px solid #e0e3e8; background: #fff; color: #344054; border-radius: 8px; padding: 8px 4px; font-size: 12px; cursor: pointer; white-space: nowrap; } .f3-gd-btns button:hover { background: #f5f6f8; } .f3-gd-btns button.f3-gd-btn-p { background: #1f2329; color: #fff; border-color: #1f2329; } .f3-gd-btns button.f3-gd-btn-p:hover { background: #0f172a; } `; (document.head || document.documentElement).appendChild(st); }, _qrSvg(text, cellSize) { try { const qr = qrcode(0, 'M'); qr.addData(text); qr.make(); return qr.createSvgTag({ cellSize: cellSize || 4, margin: 2 }); } catch (e) { console.log(TAG, '二维码生成失败:', e && e.message); return ''; } }, _show(opts) { try { this._css(); const old = document.getElementById('f3-guide-mask'); if (old) old.remove(); const mask = document.createElement('div'); mask.id = 'f3-guide-mask'; const card = document.createElement('div'); card.className = 'f3-gd-card'; const h = document.createElement('div'); h.className = 'f3-gd-title'; h.textContent = opts.title || '提示'; card.appendChild(h); const body = document.createElement('div'); body.innerHTML = opts.body || ''; // 内容均为脚本内静态文案 + 本地生成的 SVG card.appendChild(body); const btns = document.createElement('div'); btns.className = 'f3-gd-btns'; (opts.buttons || []).forEach((b) => { const el = document.createElement('button'); el.textContent = b.label; if (b.primary) el.className = 'f3-gd-btn-p'; el.addEventListener('click', () => { mask.remove(); if (b.onClick) b.onClick(); }); btns.appendChild(el); }); card.appendChild(btns); mask.appendChild(card); (document.body || document.documentElement).appendChild(mask); } catch (e) { /* ignore */ } }, _openSettings() { const sb = document.getElementById('f3Sb'); if (sb) sb.classList.remove('collapsed'); Sidebar.showView('settings'); SettingsUI.load(); }, _openBot() { try { window.open(GUIDE_BOT_URL, '_blank'); } catch (e) { /* ignore */ } }, // 无 Key / Key 失效:完整申请引导(旧源崩溃说明 + 二维码 + 每日签到说明) keyGuide() { const qr = this._qrSvg(GUIDE_BOT_URL, 4); this._show({ title: '需要申请 API Key(免费)', body: `
旧免费源已失效(崩溃),本脚本已更换新源。新源为第三方免费接口,要求每人使用自己的 API Key——填入 Key 后才能获取锁定章节的全文。
申请方式(免费):用手机 QQ 扫描下方二维码添加机器人,按机器人提示申请 Key,注册即送 1 万额度。
${qr || '
二维码生成失败,请点下方「打开申请页面」
'}
${GUIDE_BOT_URL}
每日额度:之后每天在机器人处签到(打卡)即可领取额度(+1000/天)。本脚本按请求计费并自动批量合并(一批多章只算 1 次),日常阅读非常省。
拿到 Key 后:点右侧「书」标签 → ⚙ 设置 → 粘贴 Key → 保存。Key 只保存在你自己的浏览器里。
新源为第三方免费服务,额度与稳定性由其提供方决定,与番茄官方无关;仅供个人学习研究使用,请支持正版。
`, buttons: [ { label: '打开申请页面', primary: true, onClick: () => this._openBot() }, { label: '去填 Key(设置)', onClick: () => this._openSettings() }, { label: '稍后再说' } ] }); }, // 需要引导时统一入口:首触弹窗,重复触发降级为 toast,避免骚扰 needKey() { if (this._keyGuideShown) { toast('[fq] 未配置 API Key:点右侧「书」→ ⚙ 设置填入(再看申请引导:重新打开一个锁定章节)'); return; } this._keyGuideShown = true; this.keyGuide(); }, // Key 在但额度用尽:提示去打卡领额度(无需重新申请) quotaShort() { const now = Date.now(); if (now - this._quotaAt < 10 * 60 * 1000) return; this._quotaAt = now; const qr = this._qrSvg(GUIDE_BOT_URL, 4); this._show({ title: '外部源额度不足', body: `
外部源返回"额度不足":你的 Key 额度可能已用完。无需重新申请——每天在 QQ 机器人处签到(打卡)即可领取额度(+1000/天)。
${qr}
${GUIDE_BOT_URL}
本脚本按请求计费(一批多章只算 1 次);额度恢复后无需任何操作,重新打开章节即可。
`, buttons: [ { label: '打开 QQ 机器人', primary: true, onClick: () => this._openBot() }, { label: '知道了' } ] }); } }; // ============================================================ // 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'); // 折叠期间 renderToc 的定位滚动不生效(display:none),展开后重定位到当前章 this.renderTocSafe(); }); 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; }); }); // 当前章滚入可视区:手动算 scrollTop(scrollIntoView 有连带滚动整页的 // 副作用;面板折叠 display:none 时定位无效——展开时的重定位见 f3SbTab 绑定) const cur = box.querySelector('.f3-toc-item.current'); if (cur) { const cr = cur.getBoundingClientRect(); const tr = box.getBoundingClientRect(); if (tr.height > 0 && (cr.top < tr.top || cr.bottom > tr.bottom)) { box.scrollTop += cr.top - tr.top - box.clientHeight / 2 + cr.height / 2; } } }, 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('f3SetDays', s.protectDays); set('f3SetPool', s.poolMB); set('f3SetKey', s.apiKey); const usageEl = document.getElementById('f3SetUsage'); if (usageEl) usageEl.textContent = usageText(); }, 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 pool = num('f3SetPool'); const keyEl = document.getElementById('f3SetKey'); const key = ((keyEl && keyEl.value) || '').trim(); if (max >= 100) s.cacheMax = max; if (keep >= 0) s.keepBehind = keep; if (days >= 0) s.protectDays = days; if (pool >= 1) s.poolMB = pool; if (key) s.apiKey = key; // 留空 = 不改动(防误清已填的 Key) 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; if (!getApiKey()) { Guide.needKey(); // 无 Key 点下载:弹申请引导 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; // itemId 一致还不够:state 可能是上一本书的旧包,必须核对书籍归属 if (cd && cd.bookName && String(cd.bookId) === String(ctx.bookId)) { ctx.bookName = String(cd.bookName); } } const bookName = ctx.bookName || 'novel'; const startInput = document.getElementById('f3DlStart'); const endInput = document.getElementById('f3DlEnd'); 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); // 每批固定 1000 章(实测双书安全上限):普通书 1500 章/批可行,但字数怪兽 // (单章近 2 万字)1500 章会撞源后端 256MB 内存墙——1000 在两本极端书上都稳 const BATCH = 1000; 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) + ' 批(约 ' + Math.ceil(total / BATCH) + ' 次请求)'); const results = new Array(total); const noRetake = {}; // 整批两连败的章:不参与逐章补取(批越大逐章补越烧额度) let done = 0; let ok = 0; let fail = 0; const t0 = Date.now(); const ordOf = (ch) => (ch.ord != null ? ch.ord : ch.globalIndex + 1); for (let bstart = 0; bstart < total; bstart += BATCH) { if (this.cancelFlag) { this.log('⏹ 下载已取消', 'err'); break; } const slice = chapters.slice(bstart, bstart + BATCH); // 批量响应只认 chapter_id:不在请求集内的响应一律丢弃(宁缺勿错) const expect = {}; slice.forEach((ch, j) => { expect[String(ch.itemId)] = bstart + j; }); const ords = slice.map(ordOf); let list = await Api.batch(bookId, ords); if ((!list || !list.length) && !this.cancelFlag) { // 整批失败/全军覆没:歇 3 秒整批重试一次 await new Promise((r) => setTimeout(r, 3000)); if (!this.cancelFlag) list = await Api.batch(bookId, ords); } if (!list) { // 两连败:整批放弃补取。批越大逐章补取越烧额度, // 提示调小批量重新下载更省 slice.forEach((ch, j) => { noRetake[bstart + j] = true; }); } let got = 0; if (list) { list.forEach((r) => { if (!r || !r.html) return; const k = expect[String(r.chapterId)]; if (k === undefined || results[k]) return; // 请求集外/重复:丢弃 results[k] = { ch: chapters[k], html: r.html }; ok++; got++; }); } slice.forEach((ch, j) => { if (!results[bstart + j]) { fail++; this.log('✗ ' + ch.title + (list ? ' 未取到' : ' 整批失败'), 'err'); } }); if (got) this.log('批次完成:' + got + '/' + slice.length + ' 章入库'); done = Math.min(bstart + BATCH, total); this.progress(done, total, fail, t0); } // 缺章逐章补(按章节 Id 单章取,与阅读同路径;失败退避 2 秒重试一次)。 // 整批两连败的章不补:防止超大批次失败后逐章补取烧掉成百上千额度 const missing = []; let skipped = 0; for (let k = 0; k < total; k++) { if (results[k]) continue; if (noRetake[k]) { skipped++; continue; } missing.push(k); } if (skipped) this.log('⚠ 跳过整批失败的 ' + skipped + ' 章(未逐章补取,避免大额消耗);稍后重新下载即可补齐', 'err'); if (missing.length && !this.cancelFlag) { this.log('缺 ' + missing.length + ' 章,逐章补取…(每章 1 次请求)'); for (const k of missing) { if (this.cancelFlag) break; const ch = chapters[k]; let r = await Api.chapterById(ch.itemId); if ((!r || !r.html) && !this.cancelFlag) { await new Promise((res) => setTimeout(res, 2000)); r = await Api.chapterById(ch.itemId); } // 批量阶段已对缺章 fail++ 过:补取成功冲销一笔,失败不重复计 if (r && r.html && String(r.chapterId) === String(ch.itemId)) { results[k] = { ch: ch, html: r.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; ctx.chapterId = 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(); // 路由分派只在启动时跑一次(重复分派会起双份书籍页采集轮询) }); // document-start 就处理阅读页(正文画入不等待 DOMContentLoaded, // paint 自带重试;身份/进度确认走 state 轮询,同样不依赖 ready) dispatchRoute(); // 500ms meta 合并窗的卸载冲刷:防止关页时丢账产生孤儿缓存 document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushMeta(); }); window.addEventListener('pagehide', flushMeta); } // 调试出口 try { window.fqDebug = { ctx: ctx, Toc: Toc, Reader: Reader, Downloader: Downloader, ChapterCache: ChapterCache, Api: Api, Guide: Guide }; } catch (e) { /* ignore */ } boot(); })();