// ==UserScript== // @name X阅读模式 // @namespace https://scriptcat.org/scripts/code/7614/X阅读模式.user.js // @version 1.5.2.6 // @description 小说阅读增强工具 // @author 失辛向南 // @license MIT // @match *://*/* // @run-at document-end // @noframes // @require https://cdnjs.cloudflare.com/ajax/libs/readability/0.4.1/Readability.min.js // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @grant GM_info // @connect * // ==/UserScript== (function () { 'use strict'; if (window.top !== window.self) return; /* ==================== 诊断日志系统 ==================== */ var __diag = (function () { var MAX = 800; var lines = []; var scriptName = 'X阅读模式'; // 版本号单一来源:优先取 GM 元数据中的实际安装版本,避免头注版本与内部版本不一致 var version = (function () { try { if (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) { return String(GM_info.script.version); } } catch (e) {} return '2.0.1'; })(); function nowStr() { try { return new Date().toLocaleString('zh-CN', { hour12: false }); } catch (e) { return new Date().toString(); } } function safeStr(o) { try { if (o instanceof Error) return o.name + ': ' + o.message + (o.stack ? '\n' + o.stack.split('\n').slice(0, 6).join('\n') : ''); if (typeof o === 'string') return o; return JSON.stringify(o); } catch (e) { return String(o); } } function push(level, msg) { try { var s = safeStr(msg); if (s.length > 600) s = s.slice(0, 600) + '…'; lines.push('[' + nowStr() + '][' + level + '] ' + s); if (lines.length > MAX) lines.shift(); } catch (e) {} } function wrapConsole() { ['log', 'info', 'warn', 'error', 'debug'].forEach(function (m) { var orig = console[m]; if (typeof orig !== 'function') return; try { console[m] = function () { try { var args = Array.prototype.slice.call(arguments); var text = args.map(function (a) { return typeof a === 'string' ? a : safeStr(a); }).join(' '); if (m === 'error' || m === 'warn') push(m.toUpperCase(), text); else if (text.charAt(0) === '[') push(m.toUpperCase(), text); } catch (e) {} return orig.apply(console, arguments); }; } catch (e) {} }); } function hookErrors() { window.addEventListener('error', function (ev) { try { // 资源加载失败(img/script/link 等):单独记录具体资源 URL var t = ev.target; if (t && t.tagName && !(ev.message)) { var src = t.src || t.currentSrc || t.href || ''; push('ERROR', '资源加载失败: <' + String(t.tagName).toLowerCase() + '> ' + (src || '(无URL)')); return; } var msg = ev.message || ''; var loc = ((ev.filename || '') + ':' + (ev.lineno || '') + ':' + (ev.colno || '')).replace(/^::/, ''); if (ev.error && ev.error.message) msg = ev.error.message; // 过滤无 message、无位置、无 error 对象的空错误事件(多为扩展/无关资源注入,无排障价值) if (!msg && !loc && !ev.error) return; var extra = ''; if (ev.error && ev.error.stack) { extra = '\n' + ev.error.stack.split('\n').slice(0, 5).join('\n'); } push('ERROR', 'window.onerror: ' + msg + ' @ ' + loc + extra); } catch (e2) {} }, true); window.addEventListener('unhandledrejection', function (ev) { var r = ev.reason; push('ERROR', 'unhandledrejection: ' + (r && r.message ? r.message : safeStr(r))); }); } function buildReport() { var out = []; out.push('========== ' + scriptName + ' 诊断日志 =========='); out.push('版本: ' + version); out.push('生成时间: ' + nowStr()); out.push('页面: ' + location.href); out.push('UA: ' + navigator.userAgent); out.push('视口: ' + (window.innerWidth || '') + 'x' + (window.innerHeight || '')); try { out.push('白名单数量: ' + getWhitelist().length); out.push('历史记录数量: ' + getHistory().length); try { out.push('目录缓存数量: ' + Object.keys(getTocCacheAll()).length); } catch (eTc) {} var st = getSettings(); out.push('设置: ' + (st ? safeStr(st) : '(默认)')); } catch (e) { out.push('存储状态读取失败: ' + safeStr(e)); } out.push('-------- 日志记录 (' + lines.length + ' 条) --------'); for (var i = 0; i < lines.length; i++) out.push(lines[i]); return out.join('\n'); } function download() { try { var text = buildReport(); var blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; var d = new Date(), pad = function (n) { return n < 10 ? '0' + n : '' + n; }; a.download = scriptName + '-诊断日志-' + d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '-' + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()) + '.txt'; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function () { try { URL.revokeObjectURL(url); } catch (e2) {} }, 5000); return true; } catch (e) { try { prompt('无法自动下载日志,请复制以下内容:', buildReport()); } catch (e2) {} return false; } } wrapConsole(); hookErrors(); return { version: version, push: push, download: download, buildReport: buildReport, info: function (m) { push('INFO', m); }, warn: function (m) { push('WARN', m); }, error: function (m) { push('ERROR', m); } }; })(); __diag.info('脚本已加载,版本 ' + __diag.version); /* ==================== 常量与状态 ==================== */ var KEY_SETTINGS = 'novel_reader2_settings_v1'; var KEY_WHITELIST = 'novel_reader2_whitelist_v1'; var KEY_HISTORY = 'novel_reader2_history_v1'; var KEY_PURIFY = 'novel_reader2_purify_v1'; var KEY_TOC_CACHE = 'novel_reader2_toc_cache_v1'; var TOC_CACHE_TTL = 24 * 60 * 60 * 1000; // 目录缓存有效期:24小时 var TOC_CACHE_MAX = 10; // 目录缓存最多保留10本书(LRU淘汰) var TOC_CACHE_MAX_BYTES = 2 * 1024 * 1024; // 目录缓存序列化体积预算(约2MB),防止超 GM 存储配额后缓存静默失效 var TOC_CACHE_REFRESH_MIN = 30 * 60 * 1000; // 命中缓存后的时间戳刷新最小间隔(30分钟),避免高频全量重写 var MAX_STITCH = 500; // 首次运行:从原版(novel_reader_*)迁移数据到新版(novel_reader2_*) // 原因:两版存储键独立,原版白名单/设置不会自动带过来,导致白名单站点不自动进入 (function migrateFromLegacy() { try { if (GM_getValue('novel_reader2_migrated_v1', '') === '1') return; var pairs = [ ['novel_reader_whitelist_v1', KEY_WHITELIST], ['novel_reader_settings_v1', KEY_SETTINGS], ['novel_reader_purify_v1', KEY_PURIFY], ['novel_reader_history_v1', KEY_HISTORY] ]; for (var i = 0; i < pairs.length; i++) { try { var oldVal = GM_getValue(pairs[i][0], ''); var newVal = GM_getValue(pairs[i][1], ''); if (oldVal && !newVal) GM_setValue(pairs[i][1], oldVal); } catch (e) {} } GM_setValue('novel_reader2_migrated_v1', '1'); } catch (e) {} })(); var state = { open: false, opening: false, root: null, win: null, visited: {}, stitchCount: 0, pendingInit: null, bookKey: null, bookTitle: null, exiting: false // 新增:退出标志,用于 beforeunload 判断 }; var _tocStop = false; var _tocBusy = false; // 目录抓取进行中标志:防止并列触发导致重复抓取/竞态 var _dlActive = false; // 全本下载进行中标志:仅下载中才拦截 beforeunload // 目录分页会话级缓存:同一会话内重复抓取同一目录页直接复用,减少无谓网络请求。 // 带容量上限(LRU 淘汰最旧),避免多本多页目录时整页 HTML 无限常驻内存。 var _tocPageCache = {}; var TOC_PAGE_CACHE_TTL = 10 * 60 * 1000; var TOC_PAGE_CACHE_MAX = 60; // ISS-102:目录分页会话缓存总字节预算(约 8MB)。只按条数 LRU 时,单页 HTML 可达数百 KB, // 60 条最坏常驻数十 MB;加入字节预算后按最旧优先淘汰,仅影响缓存占用,不改变命中语义。 var TOC_PAGE_CACHE_MAX_BYTES = 8 * 1024 * 1024; function tocPageCacheSet(url, html) { var keys = Object.keys(_tocPageCache); if (keys.length >= TOC_PAGE_CACHE_MAX) { var oldest = null, oldestTime = Infinity; for (var i = 0; i < keys.length; i++) { var e = _tocPageCache[keys[i]]; if (e && e.time < oldestTime) { oldestTime = e.time; oldest = keys[i]; } } if (oldest) delete _tocPageCache[oldest]; } var bytes = html ? html.length : 0; _tocPageCache[url] = { html: html, time: Date.now(), bytes: bytes }; keys = Object.keys(_tocPageCache); var total = 0; for (var j = 0; j < keys.length; j++) total += (_tocPageCache[keys[j]].bytes || 0); while (total > TOC_PAGE_CACHE_MAX_BYTES && keys.length > 1) { var o = null, ot = Infinity; for (var j2 = 0; j2 < keys.length; j2++) { var e2 = _tocPageCache[keys[j2]]; if (e2 && e2.time < ot && keys[j2] !== url) { ot = e2.time; o = keys[j2]; } } if (!o) break; total -= (_tocPageCache[o].bytes || 0); delete _tocPageCache[o]; keys = Object.keys(_tocPageCache); } } /* ==================== 存储 ==================== */ function getSettings() { try { var v = GM_getValue(KEY_SETTINGS, ''); var o = v ? JSON.parse(v) : null; return (o && typeof o === 'object') ? o : null; } catch (e) { return null; } } // 白名单/净化词内存缓存:会话内高频读取(每章净化、启动判定)避免重复 GM 读 + JSON.parse; // 每次写入即失效重建;跨标签页修改通过窗口 focus 时失效兜底。 var _whitelistCache = null; var _purifyCache = null; var _historyCache = null; var _historyWriteTimer = null; // 历史记录写入去抖:滚动翻章会高频触发 addHistory,合并 1.5s 内的连续写入, // 静默后或页面卸载(pagehide)时真正写 GM,避免每切一章做一次全量序列化 function flushHistoryWrite() { if (_historyWriteTimer) { clearTimeout(_historyWriteTimer); _historyWriteTimer = null; } if (_historyCache) { try { GM_setValue(KEY_HISTORY, JSON.stringify(_historyCache)); } catch (e) {} } } window.addEventListener('focus', function () { flushHistoryWrite(); _whitelistCache = null; _purifyCache = null; _historyCache = null; _tocCacheMem = null; idbPreload(); }); window.addEventListener('pagehide', flushHistoryWrite); function getWhitelist() { if (_whitelistCache) return _whitelistCache; try { var raw = GM_getValue(KEY_WHITELIST, '[]'); var list = JSON.parse(raw); if (Array.isArray(list) && list.length > 0 && typeof list[0] === 'string') { var newList = list.map(function(d) { return { domain: d, title: '' }; }); saveWhitelist(newList); return (_whitelistCache = newList); } if (Array.isArray(list)) { list = list.filter(function(item) { return item && typeof item === 'object' && item.domain; }); return (_whitelistCache = list); } return (_whitelistCache = []); } catch (e) { return []; } } // ISS-103:白名单域名规范化。hostname 比对不含端口,而手工输入可能带端口(example.com:8080); // 统一在保存/比对/添加时去协议、去端口、小写、去空白,保证两种写法都能命中同一记录。 function normalizeDomain(d) { if (!d) return ''; var s = String(d).trim().toLowerCase().replace(/^https?:\/\//i, '').split('/')[0].split('#')[0].split('?')[0]; var idx = s.indexOf(':'); if (idx !== -1) s = s.slice(0, idx); return s; } function saveWhitelist(list) { _whitelistCache = null; try { var clean = (list || []).map(function (it) { if (!it || typeof it !== 'object') return it; var d = normalizeDomain(it.domain); if (!d) return it; return { domain: d, title: it.title || '' }; }); GM_setValue(KEY_WHITELIST, JSON.stringify(clean)); } catch (e) {} } function inWhitelist(host) { var list = getWhitelist(); var target = normalizeDomain(host); for (var i = 0; i < list.length; i++) { if (list[i].domain === target) return true; } return false; } function getHistory() { if (_historyCache) return _historyCache; try { var v = GM_getValue(KEY_HISTORY, '[]'); var h = JSON.parse(v); return (_historyCache = Array.isArray(h) ? h : []); } catch (e) { return []; } } function saveHistory(history) { _historyCache = history; if (_historyWriteTimer) clearTimeout(_historyWriteTimer); _historyWriteTimer = setTimeout(flushHistoryWrite, 1500); } function getPurifyWords() { if (_purifyCache) return _purifyCache; try { var v = GM_getValue(KEY_PURIFY, '[]'); var list = JSON.parse(v); if (!Array.isArray(list)) return (_purifyCache = []); return (_purifyCache = list.filter(function (w) { return typeof w === 'string' && w.trim(); })); } catch (e) { return []; } } function savePurifyWords(list) { _purifyCache = null; try { GM_setValue(KEY_PURIFY, JSON.stringify(list)); } catch (e) {} } /* ==================== 目录缓存(最近10本书,24小时过期) ==================== 结构: { [bookKey]: { toc:[{title,url}], tocUrl:'', title:'', savedAt:时间戳, ver:缓存版本 } } 进入正文页时若命中未过期缓存,直接用缓存目录,不再重新抓取目录页。 每次读写都会清理:超过24小时的条目删除;超过10本时按 savedAt 最旧的 LRU 淘汰。 ver 用于作废旧版可能“不完整”的目录缓存(如 bxwx m. 站分页断裂只抓到部分章节的旧缓存)。 */ var TOC_CACHE_VER = 3; /* ==================== 目录缓存 IndexedDB 旁路 ==================== GM 存储(localStorage 系)有配额上限:超大目录(数 MB)写入可能抛错并被静默吞掉, 导致目录缓存静默失效。IndexedDB 无此限制,作为 GM 之外的持久层: · 脚本启动/窗口聚焦时异步把 IDB 中的缓存并入内存视图 _tocCacheMem; · 写入时同步更新 _tocCacheMem + 照旧写 GM(配额失败无害)+ 异步写 IDB; · 读取优先 _tocCacheMem(同步),预载未完成时回退 GM(现状路径)。 兼容性:IDB 不可用(隐私模式/旧引擎)时完全回退 GM-only,行为与改造前一致。 */ var _tocCacheMem = null; var _idbOpenPromise = null; function idbOpen() { if (_idbOpenPromise) return _idbOpenPromise; _idbOpenPromise = new Promise(function (resolve) { try { var req = indexedDB.open('__novel_reader_toc_cache', 1); req.onupgradeneeded = function (ev) { try { var db = ev.target.result; if (!db.objectStoreNames.contains('kv')) db.createObjectStore('kv'); } catch (e) {} }; req.onsuccess = function (ev) { resolve(ev.target.result); }; req.onerror = function () { resolve(null); }; req.onblocked = function () { resolve(null); }; } catch (e) { resolve(null); } }); return _idbOpenPromise; } function idbTx(mode) { return idbOpen().then(function (db) { if (!db) return null; try { return db.transaction('kv', mode).objectStore('kv'); } catch (e) { return null; } }); } function idbPut(obj) { idbTx('readwrite').then(function (store) { if (!store) return; try { store.put(obj, 'tocCache'); } catch (e) {} }).catch(function () {}); } function idbPreload() { idbTx('readonly').then(function (store) { if (!store) return null; return new Promise(function (resolve) { try { var req = store.get('tocCache'); req.onsuccess = function () { var o = req.result; // 预载完成前若本会话已有写入(_tocCacheMem 已建立),保留更新值;否则并入 IDB 数据 if (o && typeof o === 'object' && !Array.isArray(o)) { if (_tocCacheMem === null || typeof _tocCacheMem !== 'object') _tocCacheMem = o; } else if (_tocCacheMem === null) { _tocCacheMem = {}; } resolve(); }; req.onerror = function () { resolve(); }; } catch (e) { resolve(); } }); }).catch(function () {}); } function getTocCacheAll() { if (_tocCacheMem !== null && typeof _tocCacheMem === 'object') return _tocCacheMem; try { var v = GM_getValue(KEY_TOC_CACHE, '{}'); var o = v ? JSON.parse(v) : null; var r = (o && typeof o === 'object' && !Array.isArray(o)) ? o : {}; _tocCacheMem = r; return r; } catch (e) { return {}; } } function saveTocCacheAll(o) { var obj = o || {}; _tocCacheMem = obj; idbPut(obj); try { GM_setValue(KEY_TOC_CACHE, JSON.stringify(obj)); } catch (e) {} } idbPreload(); function pruneTocCache(cache) { if (!cache || typeof cache !== 'object') return {}; var now = Date.now(); var keys = Object.keys(cache); for (var i = 0; i < keys.length; i++) { var k = keys[i], it = cache[k]; if (!it || !Array.isArray(it.toc) || !it.savedAt || now - it.savedAt > TOC_CACHE_TTL) { delete cache[k]; } } var remain = Object.keys(cache); if (remain.length > TOC_CACHE_MAX) { remain.sort(function (a, b) { return cache[a].savedAt - cache[b].savedAt; }); var drop = remain.length - TOC_CACHE_MAX; for (var j = 0; j < drop; j++) delete cache[remain[j]]; } // 字节预算:序列化体积超限时按最旧优先淘汰;单本超限则整体放弃缓存,避免触发存储配额静默失败 remain = Object.keys(cache); if (remain.length) { var bytes = {}; var total = 2; // "{}" for (var bi = 0; bi < remain.length; bi++) { bytes[remain[bi]] = JSON.stringify(cache[remain[bi]]).length; total += bytes[remain[bi]] + 2; } if (total > TOC_CACHE_MAX_BYTES) { remain.sort(function (a, b) { return cache[a].savedAt - cache[b].savedAt; }); while (remain.length > 1 && total > TOC_CACHE_MAX_BYTES) { var dropKey = remain.shift(); delete cache[dropKey]; total -= bytes[dropKey] + 2; } if (remain.length === 1 && total > TOC_CACHE_MAX_BYTES) { delete cache[remain[0]]; } } } return cache; } function getTocCacheForBook(bookKey) { if (!bookKey) return null; var cache = getTocCacheAll(); var it = cache[bookKey]; if (!it || !Array.isArray(it.toc) || !it.savedAt) return null; if (it.ver !== TOC_CACHE_VER) { // 旧版缓存(可能不完整)作废,重新抓取 delete cache[bookKey]; saveTocCacheAll(cache); return null; } if (Date.now() - it.savedAt > TOC_CACHE_TTL) { delete cache[bookKey]; saveTocCacheAll(cache); return null; } return it; } function saveTocCacheForBook(bookKey, toc, tocUrl, title, lastUrl) { if (!bookKey || !toc || !toc.length) return; var cache = getTocCacheAll(); var entry = { toc: toc, tocUrl: tocUrl || '', title: title || '', savedAt: Date.now(), ver: TOC_CACHE_VER }; if (lastUrl) entry.lastUrl = lastUrl; // 记忆所选/当前章节,便于回退目录恢复阅读位置 cache[bookKey] = entry; pruneTocCache(cache); saveTocCacheAll(cache); } function removeTocCacheForBook(bookKey) { if (!bookKey) return; var cache = getTocCacheAll(); if (cache[bookKey]) { delete cache[bookKey]; saveTocCacheAll(cache); } } function extractNovelTitle(title) { if (!title) return ''; var t = title.replace(/[((][^))]*[))]/g, '').trim(); var match = t.match(/^(.*?)\s*第[0-9零一二三四五六七八九十百千万亿两]+[章节回卷话篇集]/); if (match) { return match[1].trim() || t; } return t || '未命名'; } function getBookRootKey(url) { try { var u = new URL(url); var path = u.pathname; if (path.endsWith('/')) return u.origin + path; var parts = path.split('/').filter(function(p) { return p; }); if (parts.length === 0) return u.origin + '/'; var last = parts[parts.length - 1]; if (/^\d+$/.test(last) || /^\d+\.html?$/i.test(last) || (/^[a-z0-9_-]+\.html?$/i.test(last) && /\d/.test(last))) { parts.pop(); } var newPath = '/' + parts.join('/'); if (!newPath.endsWith('/')) newPath += '/'; return u.origin + newPath; } catch(e) { return url; } } function addHistory(chapterTitle, url, bookKey, bookTitle) { if (!chapterTitle || !url) return; var cleanUrl = url.split('#')[0]; var key = bookKey || getBookRootKey(cleanUrl); var list = getHistory(); var found = false; var finalTitle = bookTitle || extractNovelTitle(chapterTitle) || '未命名'; for (var i = 0; i < list.length; i++) { var item = list[i]; var existingKey = item.bookKey || getBookRootKey(item.url); if (existingKey === key) { item.timestamp = Date.now(); item.url = cleanUrl; if (!item.bookKey) item.bookKey = key; if (!item.title || item.title === '未命名') { item.title = finalTitle; } found = true; break; } } if (!found) { list.push({ title: finalTitle, url: cleanUrl, timestamp: Date.now(), bookKey: key }); if (list.length > 100) { list.sort(function(a,b) { return a.timestamp - b.timestamp; }); list.shift(); } } saveHistory(list); } function fetchSiteTitle(domain, callback) { if (!domain) { callback(''); return; } var url = 'https://' + domain; GM_xmlhttpRequest({ method: 'GET', url: url, timeout: 5000, onload: function(res) { try { var html = res.responseText; var match = html.match(/]*>([^<]*)<\/title>/i); if (match && match[1]) { callback(match[1].trim()); } else { callback(''); } } catch(e) { callback(''); } }, onerror: function() { callback(''); }, ontimeout: function() { callback(''); } }); } /* ==================== 工具函数 ==================== */ var CHAP_RE = /(第\s*[0-9零一二三四五六七八九十百千两〇○]+\s*[章节節回卷話篇集]|chapter\s*\d+|序章|楔子|尾声|后记|番外)/i; var NAV_WORDS = /^(首页|书架|排行|分类|搜索|登录|注册|完本|最新|书库|书单|我的|消息|反馈|下载|客户端|繁體|繁体|english|返回顶部|上一页|下一页|上一章|下一章|刷新|加入书签|投推荐票|章节错误|字号|夜间模式|手机阅读|电脑版)$/i; var TOC_TEXT_RE = /^(目录|目錄|章节目录|返回目录|查看目录|返回书页|书页|目次|章节列表|列表|目录页)$/; function resolveUrl(href, base) { try { return new URL(href, base).href; } catch (e) { return ''; } } function isBadHref(h2) { return !h2 || h2.indexOf('javascript:') === 0 || h2.charAt(0) === '#' || h2.indexOf('about:') === 0 || h2.indexOf('mailto:') === 0; } function noHash(u) { try { return u.split('#')[0]; } catch (e) { return u; } } function cleanText(t) { return (t || '').replace(/\s+/g, '').trim(); } // 判断 URL 是否为“章节正文页”:末段形如 123.html / 123_1.html / abc123.html。 // 目录页通常以 / 结尾或无 .html 后缀,不会命中。用于宽松目录匹配的护栏。 // 增强:当当前页是章节正文页时,同书章节链接都在一起(同目录),而目录页常在别的 // 目录(如 /dir/xxx.htm)。因此候选与当前页“不同目录”的不再判为章节页, // 避免把 bxwx 这类 /dir/1088/1088155.htm 的目录链接误当章节页而漏掉。 function looksLikeChapterUrl(u) { try { var uo = new URL(u); var segs = (uo.pathname || '').split('/').filter(function (s) { return s; }); var last = segs[segs.length - 1] || ''; if (!(/^\d+(?:_\d+)?\.html?$/i.test(last) || (/^[a-z0-9_-]+\.html?$/i.test(last) && /\d/.test(last)))) return false; try { var cur = new URL(location.href); var csegs = (cur.pathname || '').split('/').filter(function (s) { return s; }); var curFile = csegs[csegs.length - 1] || ''; var curIsChapter = /^\d+(?:_\d+)?\.html?$/i.test(curFile) || (/^[a-z0-9_-]+\.html?$/i.test(curFile) && /\d/.test(curFile)); if (curIsChapter) { // 当前页是章节页:候选须与当前页同目录才视为“同书章节”,否则视为目录页 return segs.slice(0, -1).join('/') === csegs.slice(0, -1).join('/'); } } catch (e) {} return true; } catch (e) { return false; } } function chineseToNumber(ch) { if (!ch) return 0; var map = { '零':0,'〇':0,'○':0,'一':1,'二':2,'两':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,'十':10,'百':100,'千':1000,'万':10000,'亿':100000000 }; // 无位权数字的“逐位序列”写法:一一零=110、一零二=102(部分站点这样写章号) var hasWei = false; for (var w = 0; w < ch.length; w++) { if (map[ch[w]] >= 10) { hasWei = true; break; } } if (!hasWei) { var n = 0; for (var d = 0; d < ch.length; d++) { var dv = map[ch[d]]; if (dv === undefined || dv >= 10) continue; n = n * 10 + dv; } return n; } var num = 0, temp = 0; for (var i=0; i= 10) { if (temp === 0) temp = 1; num += temp * val; temp = 0; } else { temp = val; } } return num + temp; } function romanToNumber(roman) { var map = { 'I':1,'V':5,'X':10,'L':50,'C':100,'D':500,'M':1000 }; var total = 0, prev = 0; for (var i=roman.length-1; i>=0; i--) { var cur = map[roman[i]]; if (!cur) continue; if (cur < prev) total -= cur; else total += cur; prev = cur; } return total; } function parseChapterNumber(title) { if (!title) return null; var original = String(title).trim(); // 预处理①:去掉所有空格(含全角空格),保证空格不影响章号识别 var clean = original.replace(/[\s\u3000]+/g, ''); var n = pickChapterNumber(clean); if (n !== null) return n; // 预处理②:去掉括号及其内容(()、()、【】、〔〕、「」、『』),避免"(第一卷终)"等干扰排序 n = pickChapterNumber(clean.replace(/[((【\[〔〈「『].*?[))】\]〕〉」』]/g, '')); if (n !== null) return n; // 兜底:空格分隔的"12 正文"形式在去空格后无法识别,用原标题再试一次 return pickChapterNumber(original); } function pickChapterNumber(clean) { var match; // 阿拉伯数字章号优先("第405章(第三卷终)"应取章号405,不被括号里的卷号干扰) match = clean.match(/第\s*(\d+)\s*[章节回话篇集]/i); if (match) return parseInt(match[1], 10); // 中文章号(不含"卷":卷是分卷编号,不是章节号,避免"第025章(第一卷终)"取成卷号1) match = clean.match(/第\s*([〇○零一二三四五六七八九十百千万亿两](?:\s*[〇○零一二三四五六七八九十百千万亿两])*)\s*[章节回话篇集]/i); if (match) return chineseToNumber(match[1]); match = clean.match(/chapter\s*(\d+)/i); if (match) return parseInt(match[1], 10); match = clean.match(/chapter\s*([IVXLCDM]+)/i); if (match) return romanToNumber(match[1]); match = clean.match(/^(\d+)[、..\s]/); if (match) return parseInt(match[1], 10); match = clean.match(/^([零一二三四五六七八九十百千万亿两]+)[、..\s]/); if (match) return chineseToNumber(match[1]); match = clean.match(/^([IVXLCDM]+)[、..\s]/); if (match) return romanToNumber(match[1]); return null; } function scoreNext(t) { // 支持 下一章 及变体:下—章、下-章、下–章 if (/^(下一[章節节回卷篇]|下一章节|next\s*chapter|nextchapter|下[—\-–]?[章節节回卷篇])$/i.test(t)) return 5; if (/(下一[章節节回卷篇]|下[—\-–]?[章節节回卷篇])/.test(t)) return 4; // 支持 下一页 及变体:下—页、下-页、下–页 if (/^(下一[页頁張张]|下[—\-–]?[页頁張张]|下页|下節|next\s*page|nextpage)$/i.test(t)) return 2; if (/^(next|›|»|→|>>?)$/i.test(t)) return 1; return 0; } function scorePrev(t) { if (/^(上一[章節节回卷篇]|上一章节|prev\s*chapter|prevchapter)$/i.test(t)) return 5; if (/(上一[章節节回卷篇])/.test(t)) return 4; if (/^(上一[页頁張张]|上页|prev\s*page|prevpage|previous)$/i.test(t)) return 2; if (/^(prev|‹|«|←|< .main > a[href]', '.novelinfvie'] }, { name: 'T10', must: ['.read_content', '.chapter_content'] }, { name: 'T11', must: ['#ReadSet', '.jumbotron', '#chaptercontent', '.container', '.readpage'] } ]; /* ==================== 核心提取函数 ==================== */ function stripTags(html) { return (html || '').replace(/<[^>]*>/g, ''); } function readabilityParse(doc, opts) { try { var article = new Readability(doc.cloneNode(true), opts || {}).parse(); return article || null; } catch (e) { return null; } } function collectRuleContent(doc) { // B-1 修复:p 与其容器(div.content / .chapter-content 等)同时命中时, // p 的 innerHTML 是容器 innerHTML 的子串,直接 join 会让正文整段重复。 // 这里只收集"顶层"候选:祖先中已有(长度足够的)候选者时不再收集。 var MARK = '__rule_keep__'; var contentEls = doc.querySelectorAll('p, div.content, div.chapter-content, #chaptercontent, .novel-content'); var texts = []; var i, el; // 第一遍:标记"文本足够长"的候选 for (i = 0; i < contentEls.length; i++) { el = contentEls[i]; if (el.innerHTML && el.innerHTML.length > 100) { try { el[MARK] = 1; } catch (e) {} } } // 第二遍:祖先中已有长候选的(如 p 嵌套在 .chapter-content 内)跳过,避免重复 for (i = 0; i < contentEls.length; i++) { el = contentEls[i]; if (!el[MARK]) continue; var parent = el.parentNode; var nested = false; while (parent && parent.nodeType === 1 && parent !== doc.documentElement && parent.tagName !== 'BODY') { if (parent[MARK]) { nested = true; break; } parent = parent.parentNode; } if (nested) continue; texts.push(el.innerHTML); } // 清理标记 for (i = 0; i < contentEls.length; i++) { try { delete contentEls[i][MARK]; } catch (e) {} } return texts.join('
'); } function extractFromDoc(doc, baseUrl) { try { return extractFromDocInner(doc, baseUrl); } catch (e) { // 真实浏览器下个别 DOM 操作可能抛 Illegal invocation 等异常:绝不因此中断,返回空结构让调用方走字符串兜底 try { console.warn('[阅读模式] 内容提取异常:', baseUrl, e && e.message); } catch (e2) {} return { url: baseUrl, title: (doc && doc.title) || '', data: '', textContent: '', next_url: '', prev_url: '', tocUrl: '', toc: [] }; } } function extractFromDocInner(doc, baseUrl) { var matchedRule = null; for (var i = 0; i < SITE_RULES.length; i++) { var rule = SITE_RULES[i]; if (rule.must && rule.must.every(function (sel) { return doc.querySelector(sel); })) { matchedRule = rule; break; } } var content = ''; var title = doc.title || ''; var nextUrl = '', prevUrl = '', tocUrl = ''; if (matchedRule) { var titleEl = doc.querySelector('title'); if (titleEl) title = titleEl.textContent.trim(); content = collectRuleContent(doc); if (!content || stripTags(content).trim().length < 150) { matchedRule = null; content = ''; } } /* 内容提取:命中规则用规则选择器,否则回退 Readability / 段落抓取 */ if (!matchedRule) { var article = readabilityParse(doc, { charThreshold: 100 }); if (article) { content = article.content || ''; title = article.title || title; } if (!content || content.replace(/\s/g, '').length < 100) { var paragraphs = doc.querySelectorAll('p'); var pTexts = []; for (var k = 0; k < paragraphs.length; k++) { if (paragraphs[k].innerHTML.length > 50) pTexts.push(paragraphs[k].innerHTML); } if (pTexts.length > 1) content = pTexts.join('
'); else { var bodyText = doc.body.innerHTML; if (bodyText && bodyText.length > 200) content = bodyText; } } } /* ---- 下一章 / 目录链接识别:直接照搬 1234.js 逻辑 ---- · rel=next 链接(a[rel=next] 或 link[rel=next])作为 3 分基线 · 全量扫描链接文本,按 scoreNext 取最高分(兼容“下—章/下—页”破折号写法) · 上一章数据(prev_url)同样识别(移植优化恢复),供“上一章”导航使用 · 目录链接:先 TOC_TEXT_RE 精确词表匹配,未命中再走“包含匹配+护栏”宽松兜底 */ function relLink(rel) { try { var el = doc.querySelector('a[rel="' + rel + '"], link[rel="' + rel + '"]'); if (el) { var href = el.getAttribute('href'); if (!isBadHref(href)) { var u = resolveUrl(href, baseUrl); if (u && noHash(u) !== noHash(baseUrl)) return u; } } } catch (e) {} return null; } var anchors = []; try { anchors = Array.prototype.slice.call(doc.querySelectorAll('a[href]')); } catch (e) {} var selfUrl = noHash(baseUrl); // next/prev 同一遍锚点扫描合并打分(原两遍 forEach 合并为一遍,减少一次 resolveUrl/cleanText/正则开销) var rn = relLink('next'); var nextBest = rn ? { url: rn, score: 3 } : null; var rp = relLink('prev'); var prevBest = rp ? { url: rp, score: 3 } : null; anchors.forEach(function (a) { var href = a.getAttribute('href'); if (isBadHref(href)) return; var u = resolveUrl(href, baseUrl); if (!u || noHash(u) === selfUrl) return; var t = cleanText(a.textContent); if (!t) return; var ns = scoreNext(t); if (ns > 0 && (!nextBest || ns > nextBest.score)) nextBest = { url: u, score: ns }; var ps = scorePrev(t); if (ps > 0 && (!prevBest || ps > prevBest.score)) prevBest = { url: u, score: ps }; }); nextUrl = nextBest ? nextBest.url : ''; /* ---- 移植优化:恢复“上一章”数据(原版 prev 恒空;供上一章导航使用) ---- */ prevUrl = prevBest ? prevBest.url : ''; // 兜底:常规扫描未命中时,读取站点脚本里的 nextpage 变量(如 var nextpage = "/book/25365/11.html") if (!nextUrl) { try { var scrs = doc.querySelectorAll('script'); for (var si = 0; si < scrs.length; si++) { var st = scrs[si].textContent || ''; var nm = st.match(/var\s+nextpage\s*=\s*["']([^"']+)["']/); if (nm) { var nu2 = resolveUrl(nm[1], baseUrl); if (nu2 && noHash(nu2) !== selfUrl) { nextUrl = nu2; break; } } } } catch (e) {} } // 移植:span[data-url] 翻页兜底(源:parser.js)——部分站点翻页用 span/非a元素携带 data-url if (!nextUrl) { try { var spanEls = doc.querySelectorAll('span[data-url], a[data-url]'); for (var si = 0; si < spanEls.length; si++) { var st2 = cleanText(spanEls[si].textContent); if (!st2) continue; if (scoreNext(st2) > 0) { var nu4 = resolveUrl(spanEls[si].getAttribute('data-url'), baseUrl); if (nu4 && noHash(nu4) !== selfUrl) { nextUrl = nu4; break; } } } } catch (e) {} } // 第一轮:精确词表匹配(目录/章节目录/书页…),整串等于词表词,最可靠,优先使用。 // 与第二轮共用护栏:链接目标不得是章节正文页(目录链接指向目录页,不可能指向 章节数字.html)。 for (var j = 0; j < anchors.length; j++) { var href2 = anchors[j].getAttribute('href'); if (isBadHref(href2)) continue; if (TOC_TEXT_RE.test(cleanText(anchors[j].textContent))) { var tu = resolveUrl(href2, baseUrl); if (tu && noHash(tu) !== selfUrl && !looksLikeChapterUrl(tu)) { tocUrl = tu; break; } } } // 第二轮:宽松“包含匹配”兜底(处理“书页/目录”这类组合文字)。 // 裸包含会误伤:章节标题可能含“目录”(如《第一百二十四章 目录之争》), // 导航文字也可能带“目录”。因此同时满足三重护栏: // ① 文字短(目录标签一般 ≤12 字);② 不是章节标题(不匹配 CHAP_RE); // ③ href 不是正文页(末段不是章节数字.html 文件)。 if (!tocUrl) { for (var j2 = 0; j2 < anchors.length; j2++) { var a2 = anchors[j2]; var href3 = a2.getAttribute('href'); if (isBadHref(href3)) continue; var t3 = cleanText(a2.textContent); if (!t3 || t3.length > 12) continue; if (!/(目录|目錄|书页|書頁|章节目录|目次)/.test(t3)) continue; if (CHAP_RE.test(t3)) continue; var u3 = resolveUrl(href3, baseUrl); if (!u3 || noHash(u3) === selfUrl) continue; if (looksLikeChapterUrl(u3)) continue; tocUrl = u3; break; } } // 第三层备用兜底:正文页显式/宽松目录链接都没找到时,用「删掉章节号」推导书根目录 URL 作为目录页。 // 注意:推导出的页可能只是“最近更新/部分章节”页,真正的完整目录由 fetchToc // 抓取时的“查看完整目录”跟随逻辑(handleFetchToc 内 fullTocUrl 检测)再跳一层补齐。 if (!tocUrl) { try { var derivedToc = getBookRootKey(baseUrl); var derivedPath = ''; try { derivedPath = new URL(derivedToc).pathname; } catch (e) {} // 只在推导确实“删掉了章节文件段”时启用:排除站点根目录(/)与自身地址 if (derivedToc && derivedPath && derivedPath !== '/' && noHash(derivedToc) !== selfUrl) { tocUrl = derivedToc; try { __diag.info('未找到显式目录链接,使用URL推导兜底: ' + tocUrl); } catch (e) {} } } catch (e) {} } if (!content || content.replace(/\s/g, '').length < 50) { var art = readabilityParse(doc); if (art) { content = art.content || ''; title = art.title || title; } } return { url: baseUrl, title: title, data: content, textContent: content ? stripTags(content) : '', next_url: nextUrl, prev_url: prevUrl, tocUrl: tocUrl, toc: [] }; } // 纯字符串正文/next 兜底:完全不依赖 DOM(避开真实浏览器里 Illegal invocation 类解析异常), // 用正则抓

段落、var nextpage / rel=next / “下一章”锚点、目录锚点 function fallbackExtractHtml(html, baseUrl) { var title = '', data = '', next = '', toc = ''; try { var t = html.match(/]*>([\s\S]*?)<\/title>/i); if (t) title = t[1].replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); var nv = html.match(/var\s+nextpage\s*=\s*["']([^"']+)["']/i); if (nv && nv[1]) next = resolveUrl(nv[1], baseUrl); if (!next) { var rl = html.match(/]*rel=["']next["'][^>]*>/i); if (rl && rl[0]) { var rh = rl[0].match(/href=["']([^"']+)["']/); if (rh && rh[1]) next = resolveUrl(rh[1], baseUrl); } } if (!next) { var na = html.match(/]*href=["']([^"']+)["'][^>]*>[\s\S]*?下一[章节页][\s\S]*?<\/a>/i) || html.match(/]*>[\s\S]*?下一[章节页][\s\S]*?<\/a>/i); if (na) { var nh = na[0].match(/href=["']([^"']+)["']/); if (nh && nh[1]) next = resolveUrl(nh[1], baseUrl); } } if (next && noHash(next) === noHash(baseUrl)) next = ''; var td = html.match(/]*href=["']([^"']+)["'][^>]*>[\s\S]*?(?:目录|章节目录)[\s\S]*?<\/a>/i); if (td && td[1]) toc = resolveUrl(td[1], baseUrl); var ps = html.match(/]*>([\s\S]*?)<\/p>/gi); if (ps && ps.length) { data = ps.join(''); } else { var body = html.match(/]*>([\s\S]*?)<\/body>/i); if (body && body[1]) data = body[1]; } } catch (e) {} return { url: baseUrl, title: title, data: data, textContent: stripTags(data), next_url: next, prev_url: '', tocUrl: toc }; } /* ==================== 工具函数(保留) ==================== */ function showToastIn(doc, id, msg, css, dur) { try { var old = doc.getElementById(id); if (old) old.parentNode.removeChild(old); var t = doc.createElement('div'); t.id = id; t.textContent = msg; t.style.cssText = css; (doc.body || doc.documentElement).appendChild(t); setTimeout(function () { try { t.parentNode.removeChild(t); } catch (e) {} }, dur); } catch (e) {} } function toast(msg) { showToastIn(document, '__novel_reader_toast__', msg, 'position:fixed;top:16%;left:50%;transform:translateX(-50%);z-index:2147483647;background:rgba(0,0,0,.78);color:#fff;font-size:14px;line-height:1.5;padding:10px 18px;border-radius:20px;font-family:sans-serif;pointer-events:none;white-space:nowrap;', 2400); } /* ==================== 电脑站目录回退:m. → www. ==================== 需求:正文获取 / 下载章节保持原逻辑(按当前站 m. 抓取,不切换域名,避免不同站 分页规则差异破坏功能)。仅当「移动站目录获取不到」且当前为 m. 站时, 才回退抓取电脑站(www.)目录页:把目录 URL 由 m. 换成 www.,用桌面 UA 访问, 抓到的目录自动默认第一章、写入目录缓存并记忆所选章节。 实现:fetchUrl 为原始纯请求(正文/下载不受影响);目录回退仅由 handleFetchToc 在移动站目录为空时触发,使用 fetchUrlDesktop 抓 www. 目录页。 */ var _mSiteHost = ''; // 当前站点 m. 主机名,如 'm.example.com';非 m. 站为空 var _desktopHost = ''; // 对应电脑站主机名,如 'www.example.com' // 桌面端 UA:抓取电脑站目录页时使用,避免被服务器按 UA 识别为移动端 var DESKTOP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; // 识别当前网页是否 m. 开头的移动站(m.xxx.xxx 多级形式,排除 m.com 这类单段后缀的误判) (function detectSiteVariant() { var host = ''; try { host = (window.location.hostname || '').toLowerCase(); } catch (e) {} if (/^m\.[a-z0-9-]+\.[a-z0-9-]/.test(host)) { _mSiteHost = host; _desktopHost = 'www.' + host.slice(2); } })(); // 把同站 m. URL 换成 www.;非 m. 站或非同站 URL 原样返回 function toDesktopUrl(url) { if (!url || !_mSiteHost) return url; try { var u = new URL(url); if ((u.hostname || '').toLowerCase() === _mSiteHost) { u.hostname = _desktopHost; return u.href; } } catch (e) {} return url; } // 把同站 www. URL 换回 m.;非 m. 站或非同站 URL 原样返回 // (回退目录只用于“拿到目录列表”,章节链接需换回 m. 供正文/下载按移动站原逻辑抓取) function toMobileUrl(url) { if (!url || !_desktopHost) return url; try { var u = new URL(url); if ((u.hostname || '').toLowerCase() === _desktopHost) { u.hostname = _mSiteHost; return u.href; } } catch (e) {} return url; } // 原始抓取(正文 / 下载使用):不切换域名、不覆写 UA,与原脚本行为完全一致 function fetchUrl(url, cb) { try { return GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'text', timeout: 20000, headers: { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' }, onload: function (r) { cb(null, r.responseText || r.response || ''); }, onerror: function () { cb('网络错误'); }, ontimeout: function () { cb('请求超时'); } }); } catch (e) { cb(String(e)); return null; } } // 电脑站目录页抓取(目录回退专用):桌面 UA function fetchUrlDesktop(url, cb) { try { return GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'text', timeout: 20000, headers: { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'User-Agent': DESKTOP_UA }, onload: function (r) { cb(null, r.responseText || r.response || ''); }, onerror: function () { cb('网络错误'); }, ontimeout: function () { cb('请求超时'); } }); } catch (e) { cb(String(e)); return null; } } function parseHtml(html, baseUrl) { var doc = new DOMParser().parseFromString(html, 'text/html'); try { var baseEl = document.createElement('base'); baseEl.href = baseUrl; doc.head.appendChild(baseEl); } catch (e) {} return doc; } function htmlToText(html) { if (!html) return ''; try { var doc = new DOMParser().parseFromString('

' + html + '
', 'text/html'); var root = doc.getElementById('__tx_root'); var bad = root.querySelectorAll('script,style,noscript,iframe,object,embed,form,input,button,select,textarea,link,meta,svg'); for (var i = 0; i < bad.length; i++) { try { bad[i].parentNode.removeChild(bad[i]); } catch (e) {} } var out = []; (function walk(node) { var children = node.childNodes; for (var k = 0; k < children.length; k++) { var n = children[k]; if (n.nodeType === 3) { out.push(n.nodeValue || ''); } else if (n.nodeType === 1) { var tag = (n.tagName || '').toLowerCase(); if (tag === 'br') { out.push('\n'); continue; } walk(n); if (/^(p|div|li|h[1-6]|blockquote|section|article|tr|ul|ol|table|pre|figure|figcaption|dd|dt|hr)$/.test(tag)) out.push('\n'); } } })(root); var text = out.join(''); text = text.replace(/\r/g, ''); text = text.replace(/[ \t\u00a0\u3000]+/g, ' '); text = text.replace(/ ?\n ?/g, '\n'); text = text.replace(/\n{3,}/g, '\n\n'); return text.trim(); } catch (e) { return String(html).replace(/<[^>]*>/g, '').trim(); } } // ==================== 分页噪声行判定(下载与阅读正文共用) ==================== // 页码标记:第X章(第Y页)/(第Y页)/第 X/Y 页 / 裸 X/Y function isPageMarkerLine(t) { if (!t) return false; var s = String(t).replace(/\s+/g, ''); if (!s) return false; if (/^第[0-9零一二三四五六七八九十百千万亿两]+[章节回卷话篇集]/.test(s) && /[((]第.*?页[))]/.test(s)) return true; if (/^[((]第.*?页[))]$/.test(s)) return true; if (/^第\s*[((]?\s*\d+\s*\/\s*\d+\s*[))]?\s*页$/.test(s)) return true; if (/^第\s*\d+\s*页$/.test(s)) return true; return false; } // “本章未完,下一页继续阅读”类分页提示行判定(比旧版做更多适配) function isNextPagePromptLine(t) { if (!t) return false; var s = String(t).replace(/\s+/g, ''); if (!s) return false; // ① 本章/正文/本书/内容 + 未完(未完结/尚未结束/待续…) + 下一页/下页/翻页/继续阅读/点击/翻 if (/(本章|正文|本书|内容)/.test(s) && /(未完|未完结|尚未结束|没有结束|未结束|待续)/.test(s) && /(下一页|下页|翻页|翻到|继续阅读|点击|请翻|下一頁|下頁|翻頁)/.test(s)) return true; // ② “未完待续”独立短句(含括号包裹) if (/^[((【\[]?(本章)?未完待续[))】\]]?[,。,!!??…~~\s]*$/.test(s)) return true; // ③ “下一页/下页/翻页 + 继续阅读”独立短句(含“点击/请翻到”引导、“继续阅读本章”等后缀) if (/^(点击|请|请点|轻点|点)?(翻到|前往|点击)?(下一页|下页|翻页|下一頁|下頁|翻頁)(继续阅读|继续|阅读|查看)?(本章内容|本章|本页|正文)?[。!!??…~\s]*$/.test(s)) return true; if (/^(请|点|点击)?(翻到|前往)?(下一页|下页|翻页|下一頁|下頁|翻頁)[^。!?\n]{0,10}(继续|继续阅读|阅读)[。!!??…~\s]*$/.test(s)) return true; return false; } function isPageNoiseLine(t) { return isPageMarkerLine(t) || isNextPagePromptLine(t); } function cleanDownloadText(text) { if (!text) return text; var lines = text.split('\n'); var cleaned = []; for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (isPageNoiseLine(line)) continue; cleaned.push(lines[i]); } return cleaned.join('\n'); } // 去掉每页开头的“章节标题行”(第X章/序章/楔子/番外…), // 避免分页站每页正文都内嵌标题,拼接后连出多个“第一章”。finalize 统一在头部加一次章名。 // 护栏:短行(≤30字)且不含句子标点,避免把“第X章……”开头的正文句误删。 function stripLeadingChapterHeading(text) { if (!text) return text; var lines = String(text).split('\n'); var re = /^(第\s*[0-9零一二三四五六七八九十百千两〇○]+\s*[章节節回卷話篇集]|chapter\s*\d+|序章|楔子|尾声|后记|番外)(\s|[::]|$)/i; var changed = false; while (lines.length) { var line = lines[0].trim(); if (!line) { lines.shift(); continue; } // 跳过开头空行 if (line.length <= 30 && re.test(line) && !/[。!?,;、]/.test(line)) { lines.shift(); changed = true; } else break; } if (!changed) return text; return lines.join('\n').replace(/^\n+/, ''); } /* ==================== 正文净化(净化词拦截) ==================== 规则:正文中包含净化词的"句子"整句清除。 · 句子 = 净化词前后直接连接在一起的连续文字(不含标点)。 · 标点(中英文标点)只作分隔符,不纳入连接范围。 · 夹在英文(英文字母 a-zA-Z 两侧)中间的 / 与 . 视为文字,不切断英文整体; 未夹在英文中间的 / 与 . 仍按标点处理(分隔符)。 · 英文净化词(含 a-zA-Z)前后直接紧邻的 / 与 . 视为文字,一并纳入。 · 隔离:净化词前加 / 表示左侧隔离、后加 / 表示右侧隔离—— 隔离侧只清除净化词本身,该侧任何前后文字(含句末标点)都不清理。 · 被清除句子紧跟的句末标点顺带清除,避免正文残留孤立标点。 ================================================================== */ function isPunctChar(c) { return ',。!?;:、,.!?;:()()【】《》〈〉「」『』〔〕[]{}“”‘’"\'…—·–-~@#%^&*=+$¥€<>|\\/'.indexOf(c) !== -1; } function isEnglishWord(w) { return /[a-zA-Z]/.test(w); } function isEngLetter(c) { return /[a-zA-Z]/.test(c); } // 解析净化词:剥离首尾 "/" 作为隔离标记,返回核心词与左右隔离标志 function parsePurifyWord(w) { var s = String(w); var isoLeft = false, isoRight = false; while (s.charAt(0) === '/') { isoLeft = true; s = s.slice(1); } while (s.charAt(s.length - 1) === '/') { isoRight = true; s = s.slice(0, s.length - 1); } return { core: s, isoLeft: isoLeft, isoRight: isoRight }; } function markPurifyRun(text, mark, start, L, eng, isoLeft, isoRight) { var n = text.length; var i, j, k, c; for (i = start; i < start + L && i < n; i++) mark[i] = true; // '/' 或 '.' 是否应视为文字:紧邻英文净化词,或夹在英文字母中间 function slashIsText(idx, adjacentToWord) { var ch = text.charAt(idx); if (ch !== '/' && ch !== '.') return false; if (eng && adjacentToWord) return true; if (idx > 0 && idx < n - 1 && isEngLetter(text.charAt(idx - 1)) && isEngLetter(text.charAt(idx + 1))) return true; return false; } if (!isoLeft) { j = start - 1; while (j >= 0) { c = text.charAt(j); if (isPunctChar(c)) { if (slashIsText(j, j === start - 1)) { mark[j] = true; j--; continue; } break; } mark[j] = true; j--; } } if (!isoRight) { k = start + L; while (k < n) { c = text.charAt(k); if (isPunctChar(c)) { if (slashIsText(k, k === start + L)) { mark[k] = true; k++; continue; } break; } mark[k] = true; k++; } // 清除该句紧跟的连续句末标点,避免孤立标点残留 while (k < n && isPunctChar(text.charAt(k))) { mark[k] = true; k++; } } } // 净化词 Trie:把全部净化词核心词插入字典树,一次扫描同时匹配所有词, // 替代逐词 indexOf 的 O(词数×文本长) 扫描。命中语义与原实现逐词扫描完全一致: // 每个核心词在文本中的每一次出现(含重叠、含不同隔离符写法)都会触发同一条 markPurifyRun 规则。 function buildPurifyTrie(words) { var root = { next: null, ends: null }; for (var wi = 0; wi < words.length; wi++) { var w = words[wi]; if (!w) continue; var p = parsePurifyWord(w); var core = p.core; if (!core) continue; var L = core.length; if (L === 0) continue; var eng = isEnglishWord(core); var node = root; for (var i = 0; i < L; i++) { var ch = core.charAt(i); var nx = node.next ? node.next[ch] : null; if (!nx) { nx = { next: null, ends: null }; if (!node.next) node.next = {}; node.next[ch] = nx; } node = nx; } if (!node.ends) node.ends = []; node.ends.push({ L: L, eng: eng, isoLeft: p.isoLeft, isoRight: p.isoRight }); } return root; } function purifyText(text, words) { if (!text || !words || !words.length) return text; var n = text.length; var mark = new Uint8Array(n); // 命中标记:Uint8Array 比 Array 省内存、快分配 // Trie 单遍扫描(缓存复用:词集不变时不重复构建,避免下载等逐行净化场景反复建树) var tv = purifyWordsVersion(words); var c = purifyText._trieCache; var trie = (c && c.ver === tv) ? c.trie : buildPurifyTrie(words); if (!c || c.ver !== tv) purifyText._trieCache = { ver: tv, trie: trie }; var i, j, node; for (i = 0; i < n; i++) { node = trie; for (j = i; j < n; j++) { node = node.next ? node.next[text.charAt(j)] : null; if (!node) break; if (node.ends) { for (var e = 0; e < node.ends.length; e++) { var en = node.ends[e]; markPurifyRun(text, mark, i, en.L, en.eng, en.isoLeft, en.isoRight); } } } } // 输出用分块拼接,避免逐字符 += 反复创建长字符串 var out = ''; if (n < 4096) { for (i = 0; i < n; i++) if (!mark[i]) out += text.charAt(i); return out; } var chunks = []; var start = 0; for (i = 0; i < n; i++) { if (mark[i]) { if (i > start) chunks.push(text.slice(start, i)); start = i + 1; } } if (start < n) chunks.push(text.slice(start)); return chunks.join(''); } // 净化词集合版本签名:主线程清洗与 iframe 渲染共用同一函数; // 版本一致时 iframe 可跳过重复净化(cleanReaderHtml 已在主线程做过同词集净化,幂等)。 function purifyWordsVersion(words) { var s = (words || []).join('\u0001'); var h = 5381; for (var i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; return 'pv' + h.toString(36); } // 对已插入 DOM 的元素递归净化其文本节点(用于净化词变更后即时重净) function purifyDomElement(el, words) { if (!el || !words || !words.length) return; var walk = function (node) { var children = node.childNodes; for (var i = 0; i < children.length; i++) { var c = children[i]; if (c.nodeType === 3) { var v = c.nodeValue; if (v && /[A-Za-z0-9\u4e00-\u9fa5]/.test(v)) { var clean = purifyText(v, words); if (clean !== v) c.nodeValue = clean; } } else if (c.nodeType === 1) { walk(c); } } }; walk(el); } // 对 HTML 字符串净化(渲染前处理),保留标签结构,只清除文本节点中的命中句子 function purifyHtml(html, words) { if (!html || !words || !words.length) return html; try { var doc = new DOMParser().parseFromString('
' + html + '
', 'text/html'); var root = doc.getElementById('__purify_root'); if (!root) return html; purifyDomElement(root, words); return root.innerHTML; } catch (e) { return html; } } // 目录页章节名解析(阅读正文使用): // 正文里显示的章节名改为“从目录页抓取的章节名”,替代正文页自身的 title。 // normReaderUrl:URL 归一化(去 hash/query/末尾斜杠;并保留“再去 .html/.htm”的第二级),兼容目录与正文 URL 的细微差异。 // 返回 [原级, 去.html级] 两个字符串;tocTitleForUrl 中任一级相等即视为同一章节。 function normReaderUrl(u) { if (!u) return null; var s = String(u); try { s = new URL(s).href; } catch (e) {} s = s.split('#')[0].split('?')[0].replace(/\/+$/, ''); return [s, s.replace(/\.html?$/i, '')]; } // 目录索引(供阅读器高频查表,已替代被删除的线性遍历版 tocTitleForUrl): // URL 两级归一化等价匹配优先(原级/去.html级,绝不子串匹配,避免 “123” 误配到 “12345” 之类的错章), // 章节号兜底(正文 title 的章号命中目录条目的章号,用完整章节名,如 “第101章 避无可避 (第1/2页)” → 目录 “第101章 避无可避”); // 每个键取 toc 中首个命中项。 function buildTocIndex(toc) { var u0 = {}, u1 = {}, n = {}; if (toc && toc.length) { for (var i = 0; i < toc.length; i++) { var it = toc[i]; if (!it || !it.url) continue; var t = normReaderUrl(it.url); if (!t) continue; if (u0[t[0]] === undefined) u0[t[0]] = it.title || ''; if (u1[t[1]] === undefined) u1[t[1]] = it.title || ''; var tn = parseChapterNumber(it.title); if (tn !== null && tn > 0 && n[tn] === undefined) n[tn] = it.title || ''; } } return { u0: u0, u1: u1, n: n }; } function tocTitleForUrlIdx(idx, url, bodyTitle) { if (!idx) return ''; if (url) { var target = normReaderUrl(url); if (target) { var a = target[0], b = target[1]; if (idx.u0[a] !== undefined) return idx.u0[a]; if (idx.u1[a] !== undefined) return idx.u1[a]; if (idx.u0[b] !== undefined) return idx.u0[b]; if (idx.u1[b] !== undefined) return idx.u1[b]; } } if (bodyTitle) { var bn = parseChapterNumber(bodyTitle); if (bn !== null && bn > 0 && idx.n[bn] !== undefined) return idx.n[bn]; } return ''; } // 下载文本净化:按行(段落)分别净化,与阅读器逐文本节点净化保持一致 function purifyLines(text, words) { if (!text || !words || !words.length) return text; var lines = String(text).split('\n'); for (var i = 0; i < lines.length; i++) { if (/[A-Za-z0-9\u4e00-\u9fa5]/.test(lines[i])) { var clean = purifyText(lines[i], words); if (clean !== lines[i]) lines[i] = clean; } } return lines.join('\n'); } // ==================== 阅读正文清洗(与下载清洗同规则,但保留 HTML 结构) ==================== // 收集根内所有“有文字、无元素子节点”的叶子元素(按文档顺序;跳过 img/br) function collectLeafTextEls(root) { var list = []; (function walk(node) { var kids = node.childNodes; for (var i = 0; i < kids.length; i++) { var c = kids[i]; if (c.nodeType !== 1) continue; var el = c; if (el.tagName === 'IMG' || el.tagName === 'BR') continue; var hasElChild = false; for (var j = 0; j < el.childNodes.length; j++) { var cc = el.childNodes[j]; if (cc.nodeType === 1 && cc.tagName !== 'BR') { hasElChild = true; break; } } if (!hasElChild && (el.textContent || '').replace(/\s+/g, '').trim()) list.push(el); walk(el); } })(root); return list; } // 只对根内“首部/尾部”叶子元素删除分页噪声,正文中间不碰(避免误杀正文)。 // 分页站的页码、未完下一页提示都出现在每页首尾,正文中间极少出现这类整行。 function removePageNoiseLeafs(root, maxHead, maxTail) { var leaves = collectLeafTextEls(root); if (!leaves.length) return; var h = Math.min(leaves.length, maxHead || 8); var t = Math.min(leaves.length, maxTail || 8); for (var j = 0; j < leaves.length; j++) { var inHead = j < h; var inTail = j >= leaves.length - t; if (!inHead && !inTail) continue; var el = leaves[j]; if (el.querySelector && el.querySelector('img')) continue; var text = (el.textContent || '').replace(/\s+/g, '').trim(); if (!text) continue; if (isPageNoiseLine(text)) el.parentNode.removeChild(el); } } // 删除正文开头“章节标题行”叶子元素(与下载 stripLeadingChapterHeading 同规则;只匹配开头) // 首页分页专用:.chapter-title 已单独显示标题,正文里源站写的“第X章…”标题行必须删掉,避免重复。 // 扫描开头最多 6 个叶子,命中“章节标题行”即删;正文句(带标点/超长/不以章名开头)不会命中。 function stripLeadingHeadingDom(root) { var re = /^(第\s*[0-9零一二三四五六七八九十百千两〇○]+\s*[章节節回卷話篇集]|chapter\s*\d+|序章|楔子|尾声|后记|番外)(\s|[::]|$)/i; var leaves = collectLeafTextEls(root); var maxScan = Math.min(leaves.length, 6); for (var k = 0; k < maxScan; k++) { var el = leaves[k]; if (!el || !el.parentNode) continue; var text = (el.textContent || '').trim(); if (!text) continue; if (text.length <= 30 && re.test(text) && !/[。!?,;、]/.test(text)) { el.parentNode.removeChild(el); } } } // 阅读正文 HTML 清洗:去分页噪声 → 去开头章节标题 → 净化词 function cleanReaderHtml(html, words, opts) { if (!html) return html; try { var doc = new DOMParser().parseFromString('
' + html + '
', 'text/html'); var root = doc.getElementById('__reader_clean_root'); if (!root) return html; removePageNoiseLeafs(root); if (!opts || opts.stripLeadingHeading !== false) stripLeadingHeadingDom(root); purifyDomElement(root, words); return root.innerHTML; } catch (e) { return html; } } function saveTextFile(text) { // 下载文件名:优先取历史记录中最新一条的书名;无有效书名时回退到当前书名/页面标题 var title = ''; try { var hist = getHistory(); var latest = null; for (var i = 0; i < hist.length; i++) { if (!latest || (hist[i].timestamp || 0) > (latest.timestamp || 0)) latest = hist[i]; } if (latest && latest.title && latest.title !== '未命名') title = latest.title; } catch (e) { try { __diag.warn('历史取书名失败: ' + (e && e.message)); } catch (e2) {} } title = title || state.bookTitle || window.top.document.title || 'novel'; // 支持直接传入 BlobPart 数组:全本下载时避免先 join 出全书大串再入 Blob(省一份全书内存) var blob = new Blob(Array.isArray(text) ? text : [text], { type: 'text/plain;charset=utf-8' }); var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = title.replace(/[\*\/:<>\?\\\|\r\n,]/g, '_') + '.txt'; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function () { try { URL.revokeObjectURL(a.href); } catch (e) {} }, 10000); } /* ==================== iframe 阅读器(与之前一致,包含图片过滤) ==================== */ var READER_CSS = "*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}body,html{height:100%;overflow:hidden}body{font-family:-apple-system,BlinkMacSystemFont,\"PingFang SC\",\"Hiragino Sans GB\",\"Microsoft YaHei\",sans-serif;transition:background-color .3s,color .3s}body.theme-white{background:#fff;color:#2b2b2b}body.theme-warm{background:#f7f0e3;color:#4a3f35}body.theme-green{background:#d7e8d4;color:#364736}body.theme-dark{background:#1b1b1f;color:#a8a8a8}.topbar{position:fixed;top:0;left:0;right:0;z-index:100;height:48px;display:flex;align-items:center;padding:0 12px;transition:transform .25s,background-color .3s;transform:translateY(-100%)}.topbar.visible{transform:translateY(0)}.theme-white .topbar{background:rgba(255,255,255,.96);box-shadow:0 2px 8px rgba(0,0,0,.05)}.theme-warm .topbar{background:rgba(247,240,227,.96);box-shadow:0 2px 8px rgba(0,0,0,.05)}.theme-green .topbar{background:rgba(215,232,212,.96);box-shadow:0 2px 8px rgba(0,0,0,.05)}.theme-dark .topbar{background:rgba(27,27,31,.96);box-shadow:0 2px 8px rgba(0,0,0,.2)}.topbar .btn-back{width:36px;height:36px;border:none;background:0 0;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center;border-radius:50%}.theme-green .btn-back,.theme-warm .btn-back,.theme-white .btn-back{color:#555}.theme-dark .btn-back{color:#aaa}.topbar .bar-title{flex:1;text-align:center;font-size:15px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 8px;opacity:.7}.topbar .btn-catalog{width:36px;height:36px;border:none;background:0 0;cursor:pointer;display:flex;align-items:center;justify-content:center;border-radius:50%}.topbar .btn-catalog svg{width:20px;height:20px}.theme-green .btn-catalog,.theme-warm .btn-catalog,.theme-white .btn-catalog{color:#555}.theme-dark .btn-catalog{color:#aaa}.content-wrapper{position:relative;height:100%;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:60px 20px 80px}.chapter-block{margin-bottom:24px}.chapter-divider{display:flex;align-items:center;justify-content:center;gap:8px;min-width:0;margin:16px 0 24px;opacity:.4;font-size:12px;white-space:nowrap}.chapter-divider::after,.chapter-divider::before{content:\"\";display:block;flex:1 1 12px;min-width:12px;max-width:30%;height:1px;background:currentColor;opacity:.5}.chapter-divider-title{display:block;flex:0 1 auto;min-width:0;max-width:calc(100% - 56px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chapter-title{font-weight:700;text-align:center;margin-bottom:24px;line-height:1.4;font-size:var(--reader-ts,22px)}.chapter-content{line-height:var(--reader-lh,1.9);text-align:justify;word-break:break-all;font-size:var(--reader-fs,18px)}.chapter-content p{margin-bottom:1em}.chapter-content img{max-width:100%;height:auto;border-radius:4px;margin:8px 0}.stitch-loading{text-align:center;padding:16px 0;opacity:.5;font-size:13px;visibility:hidden;min-height:48px}.stitch-loading.visible{visibility:visible}.stitch-loading .spinner{display:inline-block;width:14px;height:14px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle;margin-right:6px}.chapter-content img.reader-img-loading{min-height:160px;background-color:#eee;background-repeat:no-repeat;background-position:center;background-size:auto;background-image:url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='200' viewBox='0 0 300 200'%3E%3Crect width='100%25' height='100%25' fill='%23eeeeee'/%3E%3Cg fill='%23bbbbbb' transform='translate(130,80)'%3E%3Crect x='0' y='0' width='40' height='30' rx='3' ry='3' fill='none' stroke='%23bbbbbb' stroke-width='2'/%3E%3Ccircle cx='10' cy='10' r='3'/%3E%3Cpath d='M2,28 L14,16 L22,22 L32,10 L38,16 L38,28 Z'/%3E%3C/g%3E%3Ctext x='50%25' y='75%25' text-anchor='middle' font-family='sans-serif' font-size='12' fill='%23999999'%3ELoading...%3C/text%3E%3C/svg%3E\")}.loading{display:flex;align-items:center;justify-content:center;height:60%;flex-direction:column;opacity:.5;font-size:14px}.loading .spinner{width:24px;height:24px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;margin-bottom:12px}@keyframes spin{to{transform:rotate(360deg)}}.bottombar{position:fixed;bottom:0;left:0;right:0;z-index:100;transition:transform .25s,background-color .3s;transform:translateY(100%)}.bottombar.visible{transform:translateY(0)}.theme-white .bottombar{background:rgba(255,255,255,.97);box-shadow:0 -2px 8px rgba(0,0,0,.05)}.theme-warm .bottombar{background:rgba(247,240,227,.97);box-shadow:0 -2px 8px rgba(0,0,0,.05)}.theme-green .bottombar{background:rgba(215,232,212,.97);box-shadow:0 -2px 8px rgba(0,0,0,.05)}.theme-dark .bottombar{background:rgba(27,27,31,.97);box-shadow:0 -2px 8px rgba(0,0,0,.2)}.toolbar-row{display:flex;height:52px;align-items:center;justify-content:space-around}.toolbar-btn{border:none;background:0 0;font-size:12px;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:3px;padding:4px 6px;border-radius:8px;transition:all .15s}.toolbar-btn svg{width:22px;height:22px}.theme-white .toolbar-btn{color:#555}.theme-warm .toolbar-btn{color:#6b5d4e}.theme-green .toolbar-btn{color:#4a6a4a}.theme-dark .toolbar-btn{color:#999}.toolbar-btn:active{opacity:.6}.toolbar-btn.active{font-weight:600}.theme-white .toolbar-btn.active{color:#333}.theme-warm .toolbar-btn.active{color:#4a3f35}.theme-green .toolbar-btn.active{color:#2d4a2d}.theme-dark .toolbar-btn.active{color:#ddd}.settings-panel{position:fixed;bottom:0;left:0;right:0;z-index:200;padding:20px 20px 28px;border-radius:16px 16px 0 0;transition:transform .3s cubic-bezier(.32,.72,0,1);transform:translateY(100%)}.settings-panel.visible{transform:translateY(0)}.theme-white .settings-panel{background:#fff;box-shadow:0 -4px 20px rgba(0,0,0,.1)}.theme-warm .settings-panel{background:#f7f0e3;box-shadow:0 -4px 20px rgba(0,0,0,.08)}.theme-green .settings-panel{background:#d7e8d4;box-shadow:0 -4px 20px rgba(0,0,0,.08)}.theme-dark .settings-panel{background:#262630;box-shadow:0 -4px 20px rgba(0,0,0,.3)}.settings-panel .panel-handle{width:36px;height:4px;border-radius:2px;margin:0 auto 16px}.theme-white .panel-handle{background:#ddd}.theme-warm .panel-handle{background:#d6c9ae}.theme-green .panel-handle{background:#b0c9ac}.theme-dark .panel-handle{background:#444}.settings-mask{position:fixed;top:0;left:0;right:0;bottom:0;z-index:150;background:rgba(0,0,0,.3);display:none}.settings-mask.visible{display:block}.setting-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.setting-row:last-child{margin-bottom:0}.setting-label{font-size:13px;opacity:.6;min-width:48px}.font-controls{display:flex;align-items:center;flex:1;margin-left:12px}.font-controls button{width:40px;height:36px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;transition:all .15s}.theme-white .font-controls button{background:#f0f0f0;color:#333}.theme-warm .font-controls button{background:#ebe0cc;color:#4a3f35}.theme-green .font-controls button{background:#c1d9bd;color:#364736}.theme-dark .font-controls button{background:#2a2a30;color:#ccc}.font-controls button:active{transform:scale(.92)}.font-controls .font-size-val{flex:1;text-align:center;font-size:14px;font-weight:500}.spacing-controls{display:flex;gap:8px;flex:1;margin-left:12px}.spacing-controls button{flex:1;height:34px;border:none;border-radius:8px;font-size:12px;cursor:pointer;transition:all .15s}.theme-white .spacing-controls button{background:#f0f0f0;color:#555}.theme-warm .spacing-controls button{background:#ebe0cc;color:#5c4b37}.theme-green .spacing-controls button{background:#c1d9bd;color:#364736}.theme-dark .spacing-controls button{background:#2a2a30;color:#999}.spacing-controls button.active{font-weight:700}.theme-white .spacing-controls button.active{background:#333;color:#fff}.theme-warm .spacing-controls button.active{background:#7a6652;color:#f7f0e3}.theme-green .spacing-controls button.active{background:#4a6e4a;color:#e8f4e6}.theme-dark .spacing-controls button.active{background:#555;color:#eee}.theme-options{display:flex;gap:12px;flex:1;margin-left:12px;justify-content:center}.theme-dot{width:36px;height:36px;border-radius:50%;border:2px solid transparent;cursor:pointer;position:relative;transition:all .15s}.theme-dot:active{transform:scale(.9)}.theme-dot.active{border-color:#666}.theme-dark .theme-dot.active{border-color:#aaa}.dot-white{background:#fff;box-shadow:inset 0 0 0 1px #ddd}.dot-warm{background:#f7f0e3;box-shadow:inset 0 0 0 1px #d6c9ae}.dot-green{background:#d7e8d4;box-shadow:inset 0 0 0 1px #a8c9a4}.dot-dark{background:#1b1b1f;box-shadow:inset 0 0 0 1px #444}.overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;display:none}.overlay.visible{display:block}.catalog-mask{position:fixed;top:0;left:0;right:0;bottom:0;z-index:250;background:rgba(0,0,0,.4);opacity:0;visibility:hidden;transition:opacity .3s,visibility .3s}.catalog-mask.visible{opacity:1;visibility:visible}.catalog-sidebar{position:fixed;top:0;right:0;bottom:0;width:75%;max-width:320px;z-index:300;display:flex;flex-direction:column;transform:translateX(100%);transition:transform .3s cubic-bezier(.32,.72,0,1)}.catalog-sidebar.visible{transform:translateX(0)}.theme-white .catalog-sidebar{background:#fff}.theme-warm .catalog-sidebar{background:#f7f0e3}.theme-green .catalog-sidebar{background:#d7e8d4}.theme-dark .catalog-sidebar{background:#1e1e24}.catalog-header{padding:16px 20px 12px;font-size:16px;font-weight:600;flex-shrink:0;display:flex;align-items:center;justify-content:space-between}.theme-white .catalog-header{border-bottom:1px solid #eee}.theme-warm .catalog-header{border-bottom:1px solid #e2d6c0}.theme-green .catalog-header{border-bottom:1px solid #b8cfb4}.theme-dark .catalog-header{border-bottom:1px solid #333}.catalog-header .header-buttons{display:flex;gap:6px;align-items:center}.catalog-header button{font-size:12px;border:none;background:rgba(128,128,128,.16);color:inherit;opacity:.75;border-radius:12px;padding:4px 10px;cursor:pointer;white-space:nowrap}.catalog-header button:hover{opacity:1}.catalog-list{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:8px 0}.catalog-item{display:block;padding:12px 20px;font-size:14px;line-height:1.4;cursor:pointer;transition:background .15s;text-decoration:none;color:inherit}.catalog-item:active{opacity:.6}.catalog-item.active{font-weight:600}.theme-white .catalog-item.active{color:#1a73e8;background:#f0f6ff}.theme-warm .catalog-item.active{color:#8b6914;background:#f0e8d0}.theme-green .catalog-item.active{color:#2e7d32;background:#c5e1c8}.theme-dark .catalog-item.active{color:#7cacf8;background:#262630}.catalog-empty{padding:40px 20px;text-align:center;font-size:13px;opacity:.4}.panel-extra{position:fixed;bottom:0;left:0;right:0;z-index:220;padding:20px 20px 28px;border-radius:16px 16px 0 0;transition:transform .3s cubic-bezier(.32,.72,0,1);transform:translateY(100%);height:70vh;display:flex;flex-direction:column}.panel-extra.visible{transform:translateY(0)}.theme-white .panel-extra{background:#fff;box-shadow:0 -4px 20px rgba(0,0,0,.1)}.theme-warm .panel-extra{background:#f7f0e3;box-shadow:0 -4px 20px rgba(0,0,0,.08)}.theme-green .panel-extra{background:#d7e8d4;box-shadow:0 -4px 20px rgba(0,0,0,.08)}.theme-dark .panel-extra{background:#262630;box-shadow:0 -4px 20px rgba(0,0,0,.3)}.panel-extra .panel-handle{width:36px;height:4px;border-radius:2px;margin:0 auto 16px}.theme-white .panel-handle{background:#ddd}.theme-warm .panel-handle{background:#d6c9ae}.theme-green .panel-handle{background:#b0c9ac}.theme-dark .panel-handle{background:#444}.panel-extra .panel-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;font-size:16px;font-weight:600}.panel-extra .panel-close{background:0 0;border:none;font-size:22px;cursor:pointer;opacity:.5;padding:0 6px;transition:opacity .2s}.panel-extra .panel-close:hover{opacity:1}.panel-extra .panel-list-wrap{position:relative;flex:1;overflow:hidden;min-height:0}.panel-extra .panel-list{position:absolute;top:0;left:0;right:0;bottom:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:4px 0;scroll-behavior:smooth}.panel-extra .panel-item{padding:10px 6px;border-bottom:1px solid rgba(128,128,128,.15);display:flex;justify-content:space-between;align-items:center;font-size:14px;transition:background .1s;cursor:default}.panel-extra .panel-item .item-main{flex:1;display:flex;justify-content:space-between;align-items:center;overflow:hidden;cursor:pointer}.panel-extra .panel-item .item-main:active{opacity:.6}.panel-extra .panel-item .item-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:8px}.panel-extra .panel-item .item-del{background:0 0;border:none;font-size:16px;cursor:pointer;opacity:.3;padding:0 4px;transition:opacity .2s;color:inherit;line-height:1}.panel-extra .panel-item .item-del:hover{opacity:.9}.panel-extra .panel-empty{padding:30px 20px;text-align:center;opacity:.4;font-size:13px}.panel-extra .fade-mask{position:absolute;left:0;right:0;height:24px;pointer-events:none;z-index:2;transition:opacity .2s}.panel-extra .fade-mask.top{top:0;background:linear-gradient(to bottom,var(--bg),transparent)}.panel-extra .fade-mask.bottom{bottom:0;background:linear-gradient(to top,var(--bg),transparent)}.panel-extra .fade-mask.hidden{opacity:0}.theme-white .panel-extra{--bg:#fff}.theme-warm .panel-extra{--bg:#f7f0e3}.theme-green .panel-extra{--bg:#d7e8d4}.theme-dark .panel-extra{--bg:#262630}.topbar:not(.visible),.bottombar:not(.visible),.settings-panel:not(.visible),.panel-extra:not(.visible){box-shadow:none!important}@supports ((-webkit-backdrop-filter:blur(4px)) or (backdrop-filter:blur(4px))){.theme-white .topbar{background:rgba(255,255,255,.72);-webkit-backdrop-filter:blur(16px) saturate(1.4);backdrop-filter:blur(16px) saturate(1.4)}.theme-warm .topbar{background:rgba(247,240,227,.76);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3)}.theme-green .topbar{background:rgba(215,232,212,.76);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3)}.theme-dark .topbar{background:rgba(27,27,31,.78);-webkit-backdrop-filter:blur(16px) saturate(1.2);backdrop-filter:blur(16px) saturate(1.2)}.theme-white .bottombar{background:rgba(255,255,255,.74);-webkit-backdrop-filter:blur(16px) saturate(1.4);backdrop-filter:blur(16px) saturate(1.4)}.theme-warm .bottombar{background:rgba(247,240,227,.78);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3)}.theme-green .bottombar{background:rgba(215,232,212,.78);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3)}.theme-dark .bottombar{background:rgba(27,27,31,.80);-webkit-backdrop-filter:blur(16px) saturate(1.2);backdrop-filter:blur(16px) saturate(1.2)}.theme-white .settings-panel{background:rgba(255,255,255,.80);-webkit-backdrop-filter:blur(20px) saturate(1.4);backdrop-filter:blur(20px) saturate(1.4)}.theme-warm .settings-panel{background:rgba(247,240,227,.84);-webkit-backdrop-filter:blur(20px) saturate(1.3);backdrop-filter:blur(20px) saturate(1.3)}.theme-green .settings-panel{background:rgba(215,232,212,.84);-webkit-backdrop-filter:blur(20px) saturate(1.3);backdrop-filter:blur(20px) saturate(1.3)}.theme-dark .settings-panel{background:rgba(38,38,48,.84);-webkit-backdrop-filter:blur(20px) saturate(1.2);backdrop-filter:blur(20px) saturate(1.2)}.theme-white .panel-extra{background:rgba(255,255,255,.80);-webkit-backdrop-filter:blur(20px) saturate(1.4);backdrop-filter:blur(20px) saturate(1.4)}.theme-warm .panel-extra{background:rgba(247,240,227,.84);-webkit-backdrop-filter:blur(20px) saturate(1.3);backdrop-filter:blur(20px) saturate(1.3)}.theme-green .panel-extra{background:rgba(215,232,212,.84);-webkit-backdrop-filter:blur(20px) saturate(1.3);backdrop-filter:blur(20px) saturate(1.3)}.theme-dark .panel-extra{background:rgba(38,38,48,.84);-webkit-backdrop-filter:blur(20px) saturate(1.2);backdrop-filter:blur(20px) saturate(1.2)}.theme-white .catalog-sidebar{background:rgba(255,255,255,.82);-webkit-backdrop-filter:blur(20px) saturate(1.4);backdrop-filter:blur(20px) saturate(1.4)}.theme-warm .catalog-sidebar{background:rgba(247,240,227,.86);-webkit-backdrop-filter:blur(20px) saturate(1.3);backdrop-filter:blur(20px) saturate(1.3)}.theme-green .catalog-sidebar{background:rgba(215,232,212,.86);-webkit-backdrop-filter:blur(20px) saturate(1.3);backdrop-filter:blur(20px) saturate(1.3)}.theme-dark .catalog-sidebar{background:rgba(30,30,36,.86);-webkit-backdrop-filter:blur(20px) saturate(1.2);backdrop-filter:blur(20px) saturate(1.2)}.theme-white .panel-extra{--bg:rgba(255,255,255,.80)}.theme-warm .panel-extra{--bg:rgba(247,240,227,.84)}.theme-green .panel-extra{--bg:rgba(215,232,212,.84)}.theme-dark .panel-extra{--bg:rgba(38,38,48,.84)}}.font-controls button,.spacing-controls button{border:1px solid rgba(128,128,128,.28);box-shadow:0 1px 2px rgba(0,0,0,.06)}.theme-white .font-controls button,.theme-white .spacing-controls button{background:#e8e8ee;color:#2b2b2b}.theme-warm .font-controls button,.theme-warm .spacing-controls button{background:#e4d4b0;color:#3f3628}.theme-green .font-controls button,.theme-green .spacing-controls button{background:#b2d1aa;color:#2d4a2d}.theme-dark .font-controls button,.theme-dark .spacing-controls button{background:#3a3a45;color:#e0e0e0}"; var READER_BODY = "
\n \n
\n \n
\n
\n
\n
\n
\n 目录\n \n
\n \n \n
\n
\n
\n
暂无目录
\n
\n
\n
正在加载内容...
\n
\n
\n
正在加载下一章...
\n
\n
\n
\n
\n \n \n \n \n \n
\n
\n
\n
\n
\n
字号\n
18
\n
\n
行距\n
\n
\n
背景\n
\n
\n
\n\n
\n
\n
白名单网站
\n
\n
\n
暂无白名单
\n
\n
\n
\n\n
\n
\n
阅读历史
\n
\n
\n
暂无历史记录
\n
\n
\n
\n\n
\n
\n
净化词
\n
\n \n \n
\n
净化说明(阅读和下载TXT都会生效)
1. 整句清除:正文出现净化词,就把它连着的整句删掉——词前面连着的字、词本身、后面连着的字和句末标点(。!?…)全清;前后若遇到标点就停,标点另一侧的字不删。例:删“广告”,句子“本书由广告赞助。”整句消失。
2. 一次加多个词:用隔开即可。例:广告加群扫码。
3. 只想删词、不误删旁边的字:在词前或词后加/。词前加/(如/广告)=左边的字不误删;词后加/(如广告/)=右边的字和句末标点都不误删;两边都加/(如/广告/)=只删“广告”二字,最保险。
4. 英文单词不怕被切开:夹在字母中间的/.算单词的一部分(如 a/b、U.S. 不会被切断),没夹在字母里只当普通标点;净化词本身是英文时,它紧挨着的/.也会一并清除。
5. 删除词:点词条右侧的即可。
\n
\n
\n
暂无净化词
\n
\n
\n
\n
"; /* ==================== iframe 阅读器脚本(含图片过滤和拦截) ==================== */ var _sharedSrcCache = ''; var SHARED_SRC = (function () { // 惰性缓存:buildIframe 每次创建阅读器都会引用 SHARED_SRC,避免每次重复 toString+join if (_sharedSrcCache) return _sharedSrcCache; _sharedSrcCache = [showToastIn, getBookRootKey, chineseToNumber, romanToNumber, parseChapterNumber, pickChapterNumber, isPunctChar, isEnglishWord, isEngLetter, parsePurifyWord, markPurifyRun, buildPurifyTrie, purifyText, purifyWordsVersion, purifyDomElement, purifyHtml, normReaderUrl, buildTocIndex, tocTitleForUrlIdx] .map(function (f) { return f.toString(); }).join('\n'); return _sharedSrcCache; })(); function iframeMain() { var m = document.getElementById('topbar'), h = document.getElementById('bottombar'), e = document.getElementById('overlay'), E = document.getElementById('contentWrapper'), g = document.getElementById('articleContainer'), f = document.getElementById('chaptersHost'), p = document.getElementById('loadingTip'), v = document.getElementById('barTitle'), b = document.getElementById('fontSizeVal'), y = document.getElementById('stitchLoadingNext'), n = document.getElementById('catalogSidebar'), r = document.getElementById('catalogMask'), _ = document.getElementById('catalogList'), btnLoadToc = document.getElementById('btnLoadToc'), btnDownloadAll = document.getElementById('btnDownloadAll'), tocProgress = document.getElementById('tocProgress'), loadingText = document.getElementById('loadingText'); var STR = { readerMode: '阅读模式', chapters: '目录', noChapters: '暂无目录', loading: '正在加载内容...', loadingJump: '正在加载章节...', loadingNext: '正在加载下一章...', nextChapter: '下一章', loadFail: '章节加载失败', tocLoaded: '已加载目录', night: '夜间', day: '日间', download: '下载全部', downloading: '正在下载...' }; document.title = STR.readerMode; loadingText.textContent = STR.loading; var C = [], d = 0; var o = false, G = false; var stitching = false, awaitingNextUrl = '', stitchFails = 0; var tocData = [], tocUrl = ''; var initialChapterUrl = ''; // PERF-1 修复:目录渲染防抖定时器(章节拼接场景) var catalogRenderTimer = null; // 中文数字→数值(定位目录第一章用,兼容 第1章/第一章/第一百零三章 等写法) function cnNum(v) { v = (v || '').replace(/\s+/g, ''); if (/^\d+$/.test(v)) return parseInt(v, 10); var m = { '零': 0, '〇': 0, '○': 0, '一': 1, '二': 2, '两': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9 }; var u = { '十': 10, '百': 100, '千': 1000 }; var total = 0, num = 0; for (var i = 0; i < v.length; i++) { var c = v.charAt(i); if (m[c] !== undefined) { num = m[c]; } else if (u[c] !== undefined) { if (num === 0) num = 1; total += num * u[c]; num = 0; } } return total + num; } // 定位目录中的第一章:章节号最小的一章。 // 注意:站点目录顶部常有“最新章节/最近更新”倒序区,tocData[0] 未必是第一章,必须按最小章节号定位。 function findFirstChapter() { var minNum = Infinity, best = null; for (var i = 0; i < tocData.length; i++) { var title = tocData[i].title || ''; var n = null; var mt = /第\s*([0-9零一二三四五六七八九十百千两〇○]+)\s*章/.exec(title); if (mt) n = cnNum(mt[1]); else if (/^(楔子|序章|引子|前言|序言|开篇|开始)/.test(title)) n = 0; if (n !== null && n < minNum) { minNum = n; best = tocData[i]; } } return best || (tocData[0] || null); } var tocLoading = false; var s = { fontSize: 18, lineHeight: 1.9, theme: 'warm' }; var currentBookKey = ''; var currentBookTitle = ''; var currentHost = ''; var selectMode = false, dlRunning = false, selectedUrls = {}; var selectToolbar = null; var markActivePrev = null; var lastScrollTop = 0; var scrollTicking = false; var extraVisible = false; var extraMask = document.getElementById('extraMask'); var purifyWords = []; var purifyVer = ''; // 当前净化词集合版本(与主线程清洗版本一致时可跳过重复净化) var tocIdx = { u0: {}, u1: {}, n: {} };// 目录索引:标题查表 O(1)(tocData 变化时重建) // 大目录虚拟滚动:超过阈值的纯“第X章”编号列表只渲染可视窗口(+缓冲)内的条目,滚动时重建窗口。 // 阈值以下/选择模式/卷章分组/折叠分组走原全量渲染,交互与改造前完全一致。 var TOC_VIRTUAL_THRESHOLD = 2000; var tocVirt = null; // { rowH, total, curIdx, items },非虚拟模式为 null var tocVirtRowH = 0; // 缓存的行高(测量一次) var _virtScrollTimer = null; function tocVirtPaint() { if (!tocVirt) return; var view = _.querySelector('.toc-virt-view'); if (!view) return; var st = _.scrollTop, h = _.clientHeight || 300, rowH = tocVirt.rowH || 40; var start = Math.max(0, Math.floor(st / rowH) - 10); var end = Math.min(tocVirt.total, Math.ceil((st + h) / rowH) + 10); view.style.top = (start * rowH) + 'px'; view.innerHTML = ''; var frag = document.createDocumentFragment(); for (var i = start; i < end; i++) { frag.appendChild(createItem(tocVirt.items[i].item, i === tocVirt.curIdx)); } view.appendChild(frag); markActive(); } function onVirtScroll() { if (_virtScrollTimer) return; _virtScrollTimer = requestAnimationFrame(function () { _virtScrollTimer = null; tocVirtPaint(); }); } var purifyRefreshTimer = null; // 净化词变更防抖定时器 var dirtyChapters = {}; // 待补净化的章节索引(可视区外章节,滚入时再净化) var whitelistPanel = document.getElementById('whitelistPanel'); var historyPanel = document.getElementById('historyPanel'); var btnNight = document.getElementById('btnNightMode'); var Y = document.getElementById('settingsPanel'), j = document.getElementById('settingsMask'); function toast(msg) { showToastIn(document, '__reader_toast__', msg, 'position:fixed;top:18%;left:50%;transform:translateX(-50%);z-index:999;background:rgba(0,0,0,.72);color:#fff;font-size:13px;line-height:1.5;padding:9px 16px;border-radius:18px;pointer-events:none;', 2200); } // 带「重试」按钮的持久提示:用于自动加载连续失败等需要用户介入的场景 function toastWithRetry(msg, onRetry) { try { var old = document.getElementById('__reader_toast_retry__'); if (old && old.parentNode) old.parentNode.removeChild(old); var t = document.createElement('div'); t.id = '__reader_toast_retry__'; t.style.cssText = 'position:fixed;top:18%;left:50%;transform:translateX(-50%);z-index:1000;background:rgba(0,0,0,.8);color:#fff;font-size:13px;line-height:1.6;padding:9px 14px;border-radius:20px;font-family:sans-serif;display:flex;align-items:center;gap:10px;pointer-events:auto;box-shadow:0 4px 16px rgba(0,0,0,.3);max-width:80vw;'; var span = document.createElement('span'); span.textContent = msg; var btn = document.createElement('button'); btn.textContent = '重试'; btn.style.cssText = 'background:#3ecf5f;color:#fff;border:none;border-radius:14px;padding:4px 14px;font-size:13px;cursor:pointer;flex-shrink:0;'; btn.addEventListener('click', function () { try { if (t.parentNode) t.parentNode.removeChild(t); } catch (e) {} if (onRetry) onRetry(); }); t.appendChild(span); t.appendChild(btn); (document.body || document.documentElement).appendChild(t); setTimeout(function () { try { if (t.parentNode) t.parentNode.removeChild(t); } catch (e) {} }, 20000); } catch (e) {} } // ====================== 图片过滤函数 ====================== // 1.5.1 本地补丁(广告拦截增强): // ① 动图识别扩展:data:image/gif 内联动图、.webp 动图、.apng 一并删除; // ② 拦截用内联 background-image 实现的横幅广告容器(图片自然尺寸横版 + 元素渲染为横版才删,降低误伤); // ③ 拦截被 CSS 拉宽成横版的非横版原图(自然尺寸非横版、渲染宽高比 >= 1.5 的拉伸横幅)。 function filterImages(container) { var imgs = container.querySelectorAll('img'); imgs.forEach(function(img) { var src = img.getAttribute('src') || img.getAttribute('data-src') || ''; if (/^data:image\/gif/i.test(src) || /\.(gif|webp|apng)(\?.*)?$/i.test(src)) { img.remove(); return; } var checkAndRemove = function() { // 预载/懒加载赋值 src 后,缓存命中时 load 事件可能同步派发,此时元素尚未重新布局, // offsetHeight 仍是无图时的占位高度;且 iframe 注入的 // `.chapter-content p img{max-height:1.6em}` 会把 p 内大插图钳制成 1.6em 高的横条, // 二者都会把正常竖图误判为“拉伸横幅”删掉。 // 修复:判定前对自然高 ≥80px 的大插图解除限高钳制(小图标/表情保持 1.6em 行内高度), // 读 offsetHeight 会强制同步布局,随后按真实渲染比判定;再延迟到下一帧兜底同步派发场景。 var _img = this; var run = function() { var nw = _img.naturalWidth, nh = _img.naturalHeight; if (nw > 0 && nh > 0) { if (nh >= 80) _img.style.maxHeight = 'none'; if (nw > nh || isStretchedBanner(_img)) { _img.remove(); } } }; if (typeof requestAnimationFrame === 'function') { try { requestAnimationFrame(run); return; } catch (e) {} } run(); }; if (img.complete && img.naturalWidth > 0) { checkAndRemove.call(img); return; } img.addEventListener('load', checkAndRemove); img.addEventListener('error', function() { this.remove(); }); }); // 漏网路径①:横幅广告常用 div/a 的内联 background-image 实现,不是 。 // 预取背景图自然尺寸,图片为横版且容器渲染也是横版(横幅特征)时才删除容器。 var els = container.querySelectorAll('[style]'); els.forEach(function(el) { var st = el.getAttribute('style') || ''; var m = /(?:^|;)\s*background(?:-image)?\s*:\s*url\((['"]?)([^'")]+)\1\)/i.exec(st); if (!m) return; var url = m[2].trim(); if (!url) return; var im = new Image(); im.onload = function() { try { var nw = this.naturalWidth, nh = this.naturalHeight; if (!(nw > 0 && nh > 0 && nw > nh)) return; var w = el.offsetWidth || 0, h = el.offsetHeight || 0; if (!(w > 0 && h > 0 && w > h)) return; el.remove(); } catch (e) {} }; im.onerror = function() {}; im.src = url; }); // 漏网路径②:素材本身不是横版(1:1 方形/竖图)但被站点 CSS 拉成通栏横幅。 // 仅当渲染宽高比明显大于自然比例(>= 1.5)才判定为拉伸横幅,避免误伤正常插图。 function isStretchedBanner(img) { var nw = img.naturalWidth, nh = img.naturalHeight; if (!(nw > 0 && nh > 0) || nw > nh) return false; var w = img.offsetWidth || 0, h = img.offsetHeight || 0; return w > 0 && h > 0 && w > h && (w / h) >= 1.5; } } // ====== 图片懒加载:data-src 图片进入视口(±600px 缓冲)才真正加载 ====== // 原逻辑在章节渲染时对每张 data-src 图片立即 new Image() 预载,图多章节一次性并发几十个请求。 // 改为 IntersectionObserver:图片滚入视口附近才赋值 src 触发加载;加载完成后的广告过滤判定 // (动图删除/横版横幅/拉伸横幅)仍由 filterImages 挂载的 load 回调执行,拦截逻辑不变。 // 降级:环境不支持 IntersectionObserver 时立即加载(等同原行为)。 var lazyImgObserver = null; function lazyLoadImg(img) { var src = img.getAttribute('data-src'); if (!src) return; // 大插图(自然高 ≥80px)解除 iframe 内 `.chapter-content p img{max-height:1.6em}` 的行内小图限高, // 避免竖图被压成 1.6em 高的横条(既影响阅读,也会被 filterImages 误判为“拉伸横幅”删除)。 function unclamp(img2, im) { try { if (im.naturalHeight >= 80) img2.style.maxHeight = 'none'; } catch (e) {} } if (typeof IntersectionObserver === 'undefined') { var im0 = new Image(); im0.onload = function () { img.src = src; img.removeAttribute('data-src'); unclamp(img, im0); }; im0.onerror = function () { img.removeAttribute('data-src'); }; im0.src = src; return; } if (!lazyImgObserver) { lazyImgObserver = new IntersectionObserver(function (entries) { entries.forEach(function (en) { if (!en.isIntersecting) return; var img2 = en.target; var s2 = img2.getAttribute('data-src'); if (!s2) return; var im = new Image(); im.onload = function () { img2.src = s2; img2.removeAttribute('data-src'); unclamp(img2, im); }; im.onerror = function () { img2.removeAttribute('data-src'); }; im.src = s2; try { lazyImgObserver.unobserve(img2); } catch (e) {} }); }, { rootMargin: '600px 0px' }); } lazyImgObserver.observe(img); } function getHighlightColor() { switch (s.theme) { case 'white': return '#e6f2ff'; case 'warm': return '#e8ddc8'; case 'green': return '#c5e1c8'; case 'dark': return '#3a4a5a'; default: return '#e6f2ff'; } } function send(msg) { try { window.parent.postMessage(msg, '*'); } catch (err) {} } function sendCurrent(url, title) { send({ type: 'setCurrentUrl', url: url || '', title: title || '', bookKey: currentBookKey, bookTitle: currentBookTitle }); } // ====== 阅读器内部日志转发:把 iframe 内的 console/异常转发给父页面诊断日志 ====== function forwardDiag(level, msg) { try { var text = (typeof msg === 'string') ? msg : (function () { try { return JSON.stringify(msg); } catch (e) { return String(msg); } })(); if (text.length > 600) text = text.slice(0, 600) + '…'; send({ type: '__diag', level: level, msg: text }); } catch (e) {} } try { ['log', 'info', 'warn', 'error', 'debug'].forEach(function (m) { var orig = console[m]; if (typeof orig !== 'function') return; console[m] = function () { try { var args = Array.prototype.slice.call(arguments); var text = args.map(function (a) { return (typeof a === 'string') ? a : (function () { try { return JSON.stringify(a); } catch (e) { return String(a); } })(); }).join(' '); if (m === 'error' || m === 'warn' || text.charAt(0) === '[') forwardDiag(m.toUpperCase(), text); } catch (e) {} return orig.apply(console, arguments); }; }); window.addEventListener('error', function (ev) { forwardDiag('ERROR', 'iframe window.onerror: ' + (ev.message || '') + ' @ ' + (ev.filename || '') + ':' + (ev.lineno || '')); }, true); } catch (e) {} window.addEventListener('message', function (ev) { var msg = ev.data; if (!msg || typeof msg !== 'object' || !msg.type) return; handleMessage(msg); }); function handleMessage(msg) { switch (msg.type) { case 'init': if (msg.settings && typeof msg.settings === 'object') { if (msg.settings.fontSize) s.fontSize = msg.settings.fontSize; if (msg.settings.lineHeight) s.lineHeight = msg.settings.lineHeight; if (msg.settings.theme) s.theme = msg.settings.theme; } currentBookKey = msg.bookKey || ''; currentBookTitle = msg.bookTitle || ''; currentHost = msg.host || ''; if (Array.isArray(msg.purify)) { purifyWords = msg.purify; purifyVer = purifyWordsVersion(purifyWords); } applySettings(); // ====== 内联图片高度限制(仅正文模式) ====== { var style = document.createElement('style'); style.id = 'inline-img-style'; style.textContent = ` .chapter-content p img { max-height: 1.6em; vertical-align: middle; margin: 0 2px; } `; document.head.appendChild(style); } tocData = msg.toc || []; tocUrl = msg.tocUrl || ''; tocIdx = buildTocIndex(tocData); btnLoadToc.style.display = tocUrl ? '' : 'none'; if (msg.chapter) { initialChapterUrl = msg.chapter.url || ''; J(msg.chapter, true); I(msg.chapter); } updateTocUI(); refreshRenderedTitles(); // 目录提前加载:一切换到阅读模式界面即开始抓取目录,无需等点击右上角三条横杠图标。 // 受 tocLoading 保护,不会与打开目录时的延迟触发重复抓取。 if (tocUrl && (!tocData || tocData.length === 0) && !tocLoading) { startTocFetch(); } break; case 'setBookInfo': currentBookKey = msg.bookKey || currentBookKey; currentBookTitle = msg.bookTitle || currentBookTitle; if (typeof updateTocUI === 'function') updateTocUI(); break; case 'reinit': { // 净化词删除后重建正文:清空已加载章节,用新净化词集重新渲染首章 C = []; f.innerHTML = ''; stitching = false; awaitingNextUrl = ''; stitchFails = 0; dirtyChapters = {}; if (Array.isArray(msg.purify)) { purifyWords = msg.purify; purifyVer = purifyWordsVersion(purifyWords); } if (msg.chapter) { initialChapterUrl = msg.chapter.url || ''; J(msg.chapter, true); I(msg.chapter); } try { toast('已应用新的净化规则'); } catch (e1) {} break; } case 'chapterReady': y.classList.remove('visible'); if (msg.mode === 'jump') { hideLoading(); resetAndLoad(msg.chapter); } else { if (awaitingNextUrl && msg.url === awaitingNextUrl) { awaitingNextUrl = ''; stitching = false; J(msg.chapter, false); stitchFails = 0; setTimeout(checkAutoLoad, 0); } else { try { console.warn('[阅读模式] 自动加载状态异常:等待', awaitingNextUrl || '(空)', '但收到', msg.url || '(空)'); } catch (e1) {} } } break; case 'chapterFailed': y.classList.remove('visible'); if (msg.mode === 'jump') { hideLoading(); toast(STR.loadFail + (msg.reason ? ':' + msg.reason : '')); } else { stitching = false; awaitingNextUrl = ''; stitchFails++; if (stitchFails < 3) setTimeout(checkAutoLoad, 2000); else { try { toastWithRetry('下一页加载失败:' + (msg.reason || '') , function () { stitchFails = 0; checkAutoLoad(); }); } catch (e1) {} } try { console.warn('[阅读模式] 章节自动加载失败(第' + (stitchFails) + '次):', msg.url || '', msg.reason || ''); } catch (e1) {} } break; case 'tocReady': tocData = msg.toc || []; tocIdx = buildTocIndex(tocData); tocLoading = false; resetTocBtn(); renderCatalog(); updateTocUI(); refreshRenderedTitles(); if (msg.fromFallback && tocData && tocData.length) { // 抓取目录后跳第一章:仍在初始章节(尚未手动翻章/自动加载后续章)时,直接加载第一章。 // 注意站点目录顶部常有“最新章节/最近更新”倒序区,按最小章节号定位真正第一章。 var _fbCur = (C.length && d < C.length) ? C[d].url : ''; var _fbFirst = findFirstChapter(); if (_fbFirst && _fbFirst.url && _fbCur && _fbCur === initialChapterUrl && _fbFirst.url !== _fbCur) { closeCatalog(); showLoading(STR.loadingJump); send({ type: 'fetchChapter', url: _fbFirst.url, mode: 'jump' }); } // 未跳转时:默认高亮第一章为选中项(同样定位到最小章节号那一条) if (!_.querySelector('.catalog-item.active') && _fbFirst && _fbFirst.url) { var fbItems = _.querySelectorAll('.catalog-item'); var fbTarget = null; for (var _fi = 0; _fi < fbItems.length; _fi++) { if (fbItems[_fi].dataset.tocUrl === _fbFirst.url) { fbTarget = fbItems[_fi]; break; } } var fbFirstEl = fbTarget || _.querySelector('.catalog-item'); if (fbFirstEl) { fbFirstEl.classList.add('active'); markActivePrev = fbFirstEl; } } } if (n.classList.contains('visible')) scrollActiveIntoCenter(); break; case 'tocProgress': if (tocProgress.style.display !== 'none') { tocProgress.textContent = '已获取 ' + (msg.count || 0) + ' 章'; } break; case 'whitelistData': renderWhitelist(msg.list || []); break; case 'purifyUpdate': purifyWords = Array.isArray(msg.purify) ? msg.purify : []; purifyVer = purifyWordsVersion(purifyWords); // 防抖重净:连续修改净化词时不立即全量重扫 if (purifyRefreshTimer) clearTimeout(purifyRefreshTimer); purifyRefreshTimer = setTimeout(reapplyPurify, 300); break; case 'purifyData': renderPurify(msg.list || []); break; case 'historyData': renderHistory(msg.list || []); break; case 'downloadStopping': break; case 'downloadComplete': resetDlBtn(); toast('下载完成!'); break; case 'downloadPaused': resetDlBtn(); toast('已停止下载,保存了 ' + msg.current + ' 章'); break; case 'downloadProgress': btnDownloadAll.textContent = '下载中 ' + msg.current + '/' + msg.total; break; } } function showContent(title) { v.textContent = title || STR.readerMode; p.style.display = 'none'; g.style.display = 'block'; } function showLoading(text) { loadingText.textContent = text || STR.loading; p.style.display = 'flex'; g.style.display = 'none'; } function hideLoading() { p.style.display = 'none'; g.style.display = 'block'; } function hideBars() { o = false; m.classList.remove('visible'); h.classList.remove('visible'); e.classList.remove('visible'); } // ISS-101:属性级清洗。除删除危险标签外,还清除 on* 事件属性与 javascript:/vbscript:/data:text/html // 协议属性,防止源站正文中的内联事件/危险链接在 srcdoc(与主页同源)iframe 内执行(DOM XSS)。 // 保留:正常 http(s) 链接、data:image 图片、style(仅过滤 expression( 注入)。 function sanitize(el) { var bad = el.querySelectorAll('script,iframe,object,embed,link,meta,form,input,button,select,textarea'); for (var i = 0; i < bad.length; i++) { try { bad[i].parentNode.removeChild(bad[i]); } catch (e2) {} } try { var all = el.querySelectorAll('*'); for (var a = 0; a < all.length; a++) { var node = all[a]; var attrs = node.attributes; if (!attrs || !attrs.length) continue; for (var k = attrs.length - 1; k >= 0; k--) { var attr = attrs[k]; var attrName = attr.name.toLowerCase(); if (/^on/i.test(attrName)) { node.removeAttribute(attr.name); continue; } var val = String(attr.value || '').replace(/[\u0000-\u0020]+/g, '').toLowerCase(); if ((attrName === 'href' || attrName === 'src' || attrName === 'xlink:href' || attrName === 'action' || attrName === 'formaction') && (/^javascript:/i.test(val) || /^vbscript:/i.test(val) || /^data:text\/html/i.test(val))) { node.removeAttribute(attr.name); } } var st = node.getAttribute ? node.getAttribute('style') : null; if (st && /expression\s*\(/i.test(st)) node.removeAttribute('style'); } } catch (e3) {} } // 去掉章节标题里的分页页码后缀((2/3)、(第2页)、第2页 等) function stripPageSuffix(t) { return String(t || '') .replace(/[((【\[]\s*第?\s*[0-9一二三四五六七八九十百千]+\s*\/\s*[0-9一二三四五六七八九十百千]+\s*页?\s*[))】\]]/g, '') .replace(/[((【\[]\s*第\s*[0-9一二三四五六七八九十百千万亿两]+\s*页\s*[))】\]]/g, '') .replace(/第\s*[0-9一二三四五六七八九十百千万亿两]+\s*页$/g, '') .replace(/\s+/g, '') .trim(); } // 判断两个标题是否属于同一章节(分页标题:第1章 vs 第1章(2/3)) function sameChapterTitle(a, b) { if (!a || !b) return false; var ca = String(a).replace(/\s+/g, '').trim(); var cb = String(b).replace(/\s+/g, '').trim(); if (!ca || !cb) return false; if (ca === cb) return true; var n1 = parseChapterNumber(ca); var n2 = parseChapterNumber(cb); if (n1 !== null && n2 !== null && n1 === n2) return true; var ra = stripPageSuffix(ca); var rb = stripPageSuffix(cb); return ra.length > 0 && ra === rb; } function J(t, first) { if (!t || !t.url) return; // 正文章节名优先取“目录页抓取的章节名”(URL 匹配或章节号兜底),未命中才回退正文页 title var dispTitle = tocTitleForUrlIdx(tocIdx, t.url, t.title) || t.title || ''; // 净化版本一致(主线程已用同词集清洗)时跳过重复净化,避免每章两遍 DOMParser+全树遍历 var skipPurify = (typeof t.purifyVersion === 'string' && t.purifyVersion === purifyVer); // 同一章节的分页:直接并入上一章块,不再重复渲染章节标题/分隔线(仅章节首页分页保留标题) var prev = C.length ? C[C.length - 1] : null; var isPage = !first && prev && sameChapterTitle(prev.title, dispTitle); if (isPage) { var cont = prev.element.querySelector('.chapter-content'); if (cont) { var tmp = document.createElement('div'); tmp.innerHTML = skipPurify ? (t.data || '') : purifyHtml(t.data || '', purifyWords); sanitize(tmp); filterImages(tmp); while (tmp.firstChild) cont.appendChild(tmp.firstChild); var imgs = tmp.querySelectorAll('img[data-src]'); for (var i = 0; i < imgs.length; i++) lazyLoadImg(imgs[i]); } prev.url = t.url || prev.url; prev.nextUrl = t.next_url || prev.nextUrl; scheduleCatalogRender(); return; } var block = document.createElement('div'); block.className = 'chapter-block'; block.dataset.url = t.url || ''; if (!first) { var divider = document.createElement('div'); divider.className = 'chapter-divider'; var dt = document.createElement('span'); dt.className = 'chapter-divider-title'; dt.textContent = dispTitle || STR.nextChapter; divider.appendChild(dt); block.appendChild(divider); } var titleEl = document.createElement('div'); titleEl.className = 'chapter-title'; titleEl.textContent = dispTitle; block.appendChild(titleEl); var contentEl = document.createElement('div'); contentEl.className = 'chapter-content'; contentEl.innerHTML = skipPurify ? (t.data || '') : purifyHtml(t.data || '', purifyWords); sanitize(contentEl); filterImages(contentEl); block.appendChild(contentEl); f.appendChild(block); C.push({ url: t.url, title: dispTitle, nextUrl: t.next_url || '', element: block }); var imgs = block.querySelectorAll('img[data-src]'); for (var i = 0; i < imgs.length; i++) lazyLoadImg(imgs[i]); if (first) { d = 0; showContent(dispTitle); E.scrollTop = 0; sendCurrent(t.url, dispTitle); } if (n.classList.contains('visible')) scheduleCatalogRender(); } // 目录加载完成后,把已渲染章节块的标题/分隔线/顶栏统一刷新为目录页章节名(查表 O(C),非 O(C×T)) function refreshRenderedTitles() { var i, item, tocT, blk, tEl, dEl; for (i = 0; i < C.length; i++) { item = C[i]; tocT = tocTitleForUrlIdx(tocIdx, item.url, item.title); if (!tocT || tocT === item.title) continue; item.title = tocT; blk = item.element; if (!blk) continue; tEl = blk.querySelector('.chapter-title'); if (tEl) tEl.textContent = tocT; dEl = blk.querySelector('.chapter-divider-title'); if (dEl) dEl.textContent = tocT; } if (C.length && C[d]) { v.textContent = C[d].title || STR.readerMode; sendCurrent(C[d].url, C[d].title); } } function I(t) { if (t && t.next_url) M(t.next_url); } function findLoadedByUrl(url) { if (!url) return -1; for (var i = 0; i < C.length; i++) if (C[i].url === url) return i; return -1; } function M(nextUrl) { if (!nextUrl || stitching) return; if (findLoadedByUrl(nextUrl) !== -1) return; stitching = true; awaitingNextUrl = nextUrl; y.classList.add('visible'); send({ type: 'fetchChapter', url: nextUrl, mode: 'stitch' }); } function resetAndLoad(chapter) { C = []; d = 0; f.innerHTML = ''; stitching = false; awaitingNextUrl = ''; stitchFails = 0; y.classList.remove('visible'); J(chapter, true); I(chapter); E.scrollTop = 0; } function updateTocUI() { if (tocLoading) { btnDownloadAll.style.display = 'none'; tocProgress.style.display = 'inline'; var count = tocData ? tocData.length : 0; tocProgress.textContent = count > 0 ? '已获取 ' + count + ' 章' : '加载中...'; } else { btnDownloadAll.style.display = ''; tocProgress.style.display = 'none'; } } function setTocBtnStopping() { btnLoadToc.textContent = '停止'; btnLoadToc.style.opacity = '1'; btnLoadToc.style.pointerEvents = 'auto'; btnLoadToc.style.cursor = 'pointer'; } function resetTocBtn() { btnLoadToc.textContent = '加载目录页'; btnLoadToc.style.opacity = ''; btnLoadToc.style.pointerEvents = ''; btnLoadToc.style.cursor = ''; } function startTocFetch() { tocLoading = true; updateTocUI(); setTocBtnStopping(); send({ type: 'fetchToc', url: tocUrl }); } function catalogItems() { if (tocData && tocData.length) return tocData; return C.map(function (c, i) { return { title: c.title || ('章节 ' + (i + 1)), url: c.url }; }); } function currentUrl() { return C.length && C[d] ? C[d].url : ''; } function createItem(it, isActive) { var el = document.createElement('div'); el.className = 'catalog-item' + (isActive ? ' active' : ''); if (it.url) el.dataset.tocUrl = it.url; // 预计算归一化 URL 两级键与章号,markActive 滚动高亮时查表(避免每帧对每项正则解析) var _nk = it.url ? normReaderUrl(it.url) : null; if (_nk) { el.dataset.nk0 = _nk[0]; el.dataset.nk1 = _nk[1]; } var _cn = parseChapterNumber(it.title || ''); if (_cn !== null && _cn > 0) el.dataset.cn = String(_cn); if (selectMode) { el.style.display = 'flex'; el.style.alignItems = 'center'; var cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'dl-check'; cb.dataset.dlUrl = it.url || ''; cb.checked = !!selectedUrls[it.url]; cb.style.cssText = 'margin:0 8px 0 0;flex:none;width:16px;height:16px;cursor:pointer;'; cb.addEventListener('click', function (ev) { ev.stopPropagation(); }); cb.addEventListener('change', function () { if (cb.checked) selectedUrls[it.url] = true; else delete selectedUrls[it.url]; updateSelCount(); }); el.appendChild(cb); el.appendChild(document.createTextNode(it.title)); el.addEventListener('click', function () { cb.checked = !cb.checked; if (cb.checked) selectedUrls[it.url] = true; else delete selectedUrls[it.url]; updateSelCount(); }); return el; } el.textContent = it.title; el.addEventListener('click', function () { closeCatalog(); var idx = findLoadedByUrl(it.url); if (idx >= 0) { var c = C[idx]; if (c && c.element) { c.element.scrollIntoView({ behavior: 'smooth', block: 'start' }); d = idx; v.textContent = c.title || STR.readerMode; sendCurrent(c.url, c.title); markActive(); } } else if (it.url) { showLoading(STR.loadingJump); send({ type: 'fetchChapter', url: it.url, mode: 'jump' }); } }); return el; } function renderCatalog() { // PERF-1 修复:立即重建时取消挂起的防抖重建;并保留目录滚动位置 if (catalogRenderTimer) { clearTimeout(catalogRenderTimer); catalogRenderTimer = null; } var keepScroll = _.scrollTop; _.innerHTML = ''; var items = catalogItems(); var kw = ''; try { kw = (document.getElementById('tocSearch').value || '').trim().toLowerCase(); } catch (e) {} if (kw) { items = items.filter(function (it) { var t = (it.title || '').toLowerCase(); if (t.indexOf(kw) !== -1) return true; var n = parseChapterNumber(it.title || ''); if (n !== null && String(n).indexOf(kw) !== -1) return true; return false; }); } if (!items.length) { tocVirt = null; _.innerHTML = '
' + STR.noChapters + '
'; _.scrollTop = keepScroll; return; } var seen = {}, unique = []; items.forEach(function (it) { if (!it.url) return; if (seen[it.url]) return; seen[it.url] = true; unique.push(it); }); items = unique; var cur = currentUrl(); var hasVolumeAndChapter = items.some(function (it) { var t = it.title || ''; return /卷.*章/.test(t); }); var normal = [], special = []; items.forEach(function (it) { var num = parseChapterNumber(it.title); if (num !== null && num > 0) { normal.push({ item: it, num: num }); } else { special.push(it); } }); // 大目录虚拟滚动:纯“第X章”编号列表、非选择模式、无卷章分组、无“其他章节”折叠且条目超阈值时启用。 // 排序/去重/跳转/高亮语义与全量渲染一致,仅 DOM 只保留可视窗口;选择模式仍走全量渲染(勾选交互不变)。 if (!selectMode && !hasVolumeAndChapter && special.length === 0 && normal.length > TOC_VIRTUAL_THRESHOLD) { renderCatalogVirtual(normal, cur, keepScroll); return; } tocVirt = null; _.removeEventListener('scroll', onVirtScroll); var frag = document.createDocumentFragment(); if (hasVolumeAndChapter) { items.forEach(function (it) { frag.appendChild(createItem(it, it.url === cur)); }); _.appendChild(frag); markActive(); _.scrollTop = keepScroll; return; } normal.sort(function (a, b) { return a.num - b.num; }); normal.forEach(function (o2) { frag.appendChild(createItem(o2.item, o2.item.url === cur)); }); if (special.length) { var toggleBtn = document.createElement('div'); toggleBtn.className = 'catalog-toggle'; toggleBtn.textContent = '▼ 其他章节 (' + special.length + ')'; toggleBtn.style.cssText = 'padding:8px 20px;font-size:13px;opacity:.6;cursor:pointer;border-top:1px solid rgba(128,128,128,.2);margin-top:8px;user-select:none;'; toggleBtn.addEventListener('click', function (ev) { ev.stopPropagation(); var container = this.nextElementSibling; if (container.style.display === 'none') { container.style.display = 'block'; this.textContent = '▲ 其他章节 (' + special.length + ')'; } else { container.style.display = 'none'; this.textContent = '▼ 其他章节 (' + special.length + ')'; } }); frag.appendChild(toggleBtn); var specialContainer = document.createElement('div'); specialContainer.className = 'special-chapters'; specialContainer.style.display = 'none'; special.forEach(function (it) { specialContainer.appendChild(createItem(it, it.url === cur)); }); frag.appendChild(specialContainer); } _.appendChild(frag); markActive(); _.scrollTop = keepScroll; } // PERF-1 修复:章节拼接时不再每次全量重建目录。 // · 目录数据已就绪(tocData 非空)→ 列表内容不随拼接变化,直接跳过; // · 目录未打开 → 跳过(打开目录时会即时重建,作者 1.5.2 已实现的语义保留); // · 目录打开且目录来自已拼接章节(tocData 为空)→ 防抖 150ms 重建一次。 function scheduleCatalogRender() { if (tocData && tocData.length) return; if (!n.classList.contains('visible')) return; if (catalogRenderTimer) return; catalogRenderTimer = setTimeout(function () { catalogRenderTimer = null; try { renderCatalog(); } catch (e) {} }, 150); } // 大目录虚拟滚动渲染:占位高度 + 仅渲染可视窗口(±10 行缓冲)的条目。 // 打开目录时定位到当前章(scrollActiveIntoCenter 虚拟分支);滚动由 onVirtScroll 重建窗口。 function renderCatalogVirtual(normalList, cur, keepScroll) { var total = normalList.length; if (!tocVirtRowH) { var probe = createItem(normalList[0].item, false); _.appendChild(probe); tocVirtRowH = probe.offsetHeight || 40; _.removeChild(probe); } var rowH = tocVirtRowH; // 当前章索引(与 markActive 语义一致:URL 归一化等价优先,章节号兜底) var curIdx = -1; var curNorm = cur ? normReaderUrl(cur) : null; var curTitle = (C.length && C[d]) ? (C[d].title || '') : ''; var curBn = null; if (curTitle) { curBn = parseChapterNumber(curTitle); if (curBn !== null && curBn <= 0) curBn = null; } if (curNorm || curBn !== null) { for (var i = 0; i < total; i++) { var it = normalList[i].item; if (curNorm) { var t = it.url ? normReaderUrl(it.url) : null; if (t && (t[0] === curNorm[0] || t[0] === curNorm[1] || t[1] === curNorm[0] || t[1] === curNorm[1])) { curIdx = i; break; } } if (curIdx === -1 && curBn !== null) { var cn = parseChapterNumber(it.title || ''); if (cn !== null && cn === curBn) { curIdx = i; break; } } } } tocVirt = { rowH: rowH, total: total, curIdx: curIdx, items: normalList }; var spacer = document.createElement('div'); spacer.style.cssText = 'position:relative;height:' + (total * rowH) + 'px;width:100%;'; var view = document.createElement('div'); view.className = 'toc-virt-view'; view.style.cssText = 'position:absolute;left:0;right:0;top:0;'; spacer.appendChild(view); _.appendChild(spacer); // PERF-1 修复:重建后恢复滚动位置(虚拟滚动按 scrollTop 重绘可视窗口) if (typeof keepScroll === 'number' && keepScroll > 0) { _.scrollTop = keepScroll; } tocVirtPaint(); _.removeEventListener('scroll', onVirtScroll); _.addEventListener('scroll', onVirtScroll); } // 目录条目与当前章节是否对应(查表版,语义与原 isSameTocEntry 一致): // URL 归一化等价(原级/去.html级),或章节号一致(目录/正文 URL 可能不一致) function markActive() { var cur = currentUrl(); var curTitle = (C.length && C[d]) ? (C[d].title || '') : ''; var hit = null; var curNorm = cur ? normReaderUrl(cur) : null; var curBn = null; if (curTitle) { curBn = parseChapterNumber(curTitle); if (curBn !== null && curBn <= 0) curBn = null; } if (curNorm || curBn !== null) { var items = _.querySelectorAll('.catalog-item'); var a = curNorm ? curNorm[0] : '', b = curNorm ? curNorm[1] : ''; for (var i = 0; i < items.length; i++) { var it = items[i]; if (curNorm && (it.dataset.nk0 === a || it.dataset.nk0 === b || it.dataset.nk1 === a || it.dataset.nk1 === b)) { hit = it; break; } if (curBn !== null && it.dataset.cn && parseInt(it.dataset.cn, 10) === curBn) { hit = it; break; } } } if (hit === markActivePrev) return; if (markActivePrev) { try { markActivePrev.classList.remove('active'); } catch (e) {} } markActivePrev = null; var old = _.querySelector('.catalog-item.active'); if (old && old !== hit) { try { old.classList.remove('active'); } catch (e) {} } if (hit) { hit.classList.add('active'); markActivePrev = hit; } } // 打开目录时:把当前章节的高亮项滚动到可视区居中;若它在折叠的“其他章节”分组里先展开 function scrollActiveIntoCenter() { // 虚拟滚动模式:按当前章索引直接定位(行高均匀,无需 DOM 查找) if (tocVirt && tocVirt.curIdx >= 0 && tocVirt.rowH) { var y = tocVirt.curIdx * tocVirt.rowH; _.scrollTop = Math.max(0, y - (_.clientHeight - tocVirt.rowH) / 2); return; } var act = _.querySelector('.catalog-item.active'); if (!act) return; var sp = act.parentElement; while (sp && sp !== _ && !(sp.classList && sp.classList.contains('special-chapters'))) sp = sp.parentElement; if (sp && sp !== _ && sp.style.display === 'none') { sp.style.display = 'block'; var tg = sp.previousElementSibling; if (tg && tg.classList && tg.classList.contains('catalog-toggle')) { tg.textContent = '▲ 其他章节 (' + sp.children.length + ')'; } } var r = act.getBoundingClientRect(), lr = _.getBoundingClientRect(); var cur = r.top - lr.top; var want = (lr.height - r.height) / 2; _.scrollTop += (cur - want); } function openCatalog() { renderCatalog(); n.classList.add('visible'); r.classList.add('visible'); hideBars(); scrollActiveIntoCenter(); if (tocData.length === 0 && tocUrl && !tocLoading) startTocFetch(); } function closeCatalog() { n.classList.remove('visible'); r.classList.remove('visible'); } function checkAutoLoad() { if (C.length === 0 || stitching) return; if (E.scrollHeight - (E.scrollTop + E.clientHeight) < 800) { var last = C[C.length - 1]; if (last && last.nextUrl) M(last.nextUrl); } } // 净化词重扫:防抖 + 只重净可视区(±600px)内的章节,范围外的标记为脏、滚入时补净化, // 避免净化词编辑时对数百个已加载章节全量重扫造成卡顿 function reapplyPurify() { purifyRefreshTimer = null; var cs = f.querySelectorAll('.chapter-content'); if (!cs.length) return; var viewBottom = (E.clientHeight || window.innerHeight) + 600; for (var pi = 0; pi < cs.length; pi++) { var el = cs[pi]; if (!el) continue; var r = el.getBoundingClientRect(); if (r.bottom < -600 || r.top > viewBottom) { dirtyChapters[pi] = true; continue; } purifyDomElement(el, purifyWords); delete dirtyChapters[pi]; } } // 滚动时补净化此前跳过的脏章节(滚入可视区即处理) function purgeDirtyOnScroll() { if (!dirtyChapters || !Object.keys(dirtyChapters).length) return; var cs = f.querySelectorAll('.chapter-content'); var viewBottom = (E.clientHeight || window.innerHeight) + 600; for (var di in dirtyChapters) { var el = cs[di]; if (el && el.getBoundingClientRect().top <= viewBottom) { purifyDomElement(el, purifyWords); delete dirtyChapters[di]; } } } function onScroll() { var st = E.scrollTop; if (Math.abs(st - lastScrollTop) > 2 && o) { o = false; m.classList.remove('visible'); h.classList.remove('visible'); } var prevScrollTop = lastScrollTop; lastScrollTop = st; checkAutoLoad(); if (!C.length) return; var i, target = -1; if (st >= prevScrollTop) { // 向下滚动:找「最后一个越过顶线」的章节(元素按顺序堆叠,遇 top>80 即可停) for (i = (d < 0 ? 0 : d); i < C.length; i++) { if (C[i].element.getBoundingClientRect().top <= 80) target = i; else break; } } else if (d >= 0) { // 向上滚动:从当前章节向前找「第一个越过顶线」的章节 for (i = d; i >= 0; i--) { if (C[i].element.getBoundingClientRect().top <= 80) { target = i; break; } } } if (target !== -1 && d !== target) { d = target; v.textContent = C[target].title || STR.readerMode; sendCurrent(C[target].url, C[target].title); // 目录关闭时跳过高亮扫描(打开目录时会全量重建并重新高亮);避免滚动帧对大目录全量扫描 if (n.classList.contains('visible')) markActive(); } // 补净化滚入可视区的脏章节 purgeDirtyOnScroll(); } E.addEventListener('scroll', function () { if (scrollTicking) return; scrollTicking = true; requestAnimationFrame(function () { scrollTicking = false; onScroll(); }); }); function toggleBars() { if (G) { closeSettings(); return; } if (n.classList.contains('visible')) { closeCatalog(); return; } if (extraVisible) { closeExtraPanel(); return; } o = !o; m.classList.toggle('visible', o); h.classList.toggle('visible', o); } E.addEventListener('click', function (ev) { var aEl = (ev.target && ev.target.closest) ? ev.target.closest('a') : null; if (aEl) { ev.preventDefault(); var href = aEl.getAttribute('href') || ''; // B-2 修复①:同页 hash 锚点(#top 等)直接放行,不弹确认、不跳外部 if (href.charAt(0) === '#') { var targetId = href.slice(1); var targetEl = null; if (targetId) { try { targetEl = document.getElementById(targetId); } catch (e) {} } if (targetEl) { try { targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e2) { try { targetEl.scrollIntoView(); } catch (e3) {} } } else { try { E.scrollTop = 0; } catch (e4) {} } return; } // B-2 修复②:去掉 iframe 侧确认弹窗,统一由父页 navigateTo 弹一次确认,避免双重弹窗; // 同时放行 javascript:/about:/mailto: 等非网页跳转,不弹窗 var hrefLower = href.toLowerCase(); if (hrefLower.indexOf('javascript:') === 0 || hrefLower.indexOf('about:') === 0 || hrefLower.indexOf('mailto:') === 0) return; var absUrl = ''; try { absUrl = new URL(href, document.baseURI || location.href).href; } catch (e) { absUrl = href; } if (absUrl && absUrl !== location.href) { send({ type: 'navigateTo', url: absUrl }); } return; } if (o || G) { toggleBars(); return; } var rect = E.getBoundingClientRect(); var yy = ev.clientY - rect.top, hh = rect.height, step = hh - 40; if (yy < hh * 0.3) E.scrollBy({ top: -step, behavior: 'smooth' }); else if (yy > hh * 0.7) E.scrollBy({ top: step, behavior: 'smooth' }); else toggleBars(); }); e.addEventListener('click', function () { toggleBars(); }); document.getElementById('btnBack').addEventListener('click', function (ev) { ev.stopPropagation(); send({ type: 'exit' }); }); document.getElementById('btnCatalog').addEventListener('click', function (ev) { ev.stopPropagation(); openCatalog(); }); r.addEventListener('click', function () { closeCatalog(); }); var tocSearchEl = document.getElementById('tocSearch'); if (tocSearchEl) { var tocSearchTimer = null; tocSearchEl.addEventListener('input', function () { // 搜索防抖:连续输入只做一次全量重建,避免每键对 2 万条目录重建 DOM if (tocSearchTimer) clearTimeout(tocSearchTimer); tocSearchTimer = setTimeout(function () { tocSearchTimer = null; renderCatalog(); if (tocSearchEl.value) { var act = _.querySelector('.catalog-item.active'); if (act) { try { act.scrollIntoView({ block: 'center' }); } catch (e) {} } } }, 300); }); } btnLoadToc.addEventListener('click', function (ev) { ev.stopPropagation(); if (!tocUrl) return; if (tocLoading) { send({ type: 'stopFetchToc' }); return; } startTocFetch(); }); function catalogMap() { var map = {}; catalogItems().forEach(function (it) { if (it.url) map[it.url] = it.title || it.url; }); return map; } function allChecks() { return Array.prototype.slice.call(_.querySelectorAll('.dl-check')); } function countSelected() { var cnt = 0; allChecks().forEach(function (c) { if (c.checked) cnt++; }); return cnt; } function updateSelCount() { var nSel = countSelected(); var btn = document.getElementById('dlSelStart'); if (btn) btn.textContent = '开始下载 (' + nSel + ')'; var all = document.getElementById('dlSelAll'); if (all) { var checks = allChecks(); all.checked = checks.length > 0 && nSel === checks.length; } } function toggleAllChecks(on) { allChecks().forEach(function (cb) { cb.checked = on; if (on) selectedUrls[cb.dataset.dlUrl] = true; else delete selectedUrls[cb.dataset.dlUrl]; }); updateSelCount(); } function selectRange(a, b) { var checks = allChecks(); toggleAllChecks(false); var i0 = Math.max(1, a) - 1; var i1 = Math.min(checks.length, b); for (var i = i0; i < i1; i++) { checks[i].checked = true; selectedUrls[checks[i].dataset.dlUrl] = true; } updateSelCount(); } function resetDlBtn() { dlRunning = false; btnDownloadAll.textContent = '下载全部'; btnDownloadAll.disabled = false; } function removeSelectToolbar() { if (selectToolbar && selectToolbar.parentNode) selectToolbar.parentNode.removeChild(selectToolbar); selectToolbar = null; } function mkDlBtn(text, primary) { var btn = document.createElement('button'); btn.textContent = text; btn.style.cssText = primary ? 'padding:5px 12px;border:none;border-radius:6px;background:#d9822b;color:#fff;cursor:pointer;font-size:12px;font-weight:500;' : 'padding:5px 10px;border:1px solid rgba(128,128,128,.4);border-radius:6px;background:transparent;cursor:pointer;font-size:12px;color:inherit;'; return btn; } function buildSelectToolbar() { removeSelectToolbar(); var tb = document.createElement('div'); tb.id = 'dlSelectToolbar'; tb.style.cssText = 'padding:8px 12px;border-bottom:1px solid rgba(128,128,128,.25);display:flex;flex-wrap:wrap;gap:6px;align-items:center;font-size:13px;flex:none;'; var lab = document.createElement('label'); lab.style.cssText = 'display:flex;align-items:center;gap:4px;cursor:pointer;'; var cbAll = document.createElement('input'); cbAll.type = 'checkbox'; cbAll.id = 'dlSelAll'; cbAll.style.cssText = 'width:15px;height:15px;cursor:pointer;'; cbAll.addEventListener('change', function () { toggleAllChecks(cbAll.checked); }); lab.appendChild(cbAll); lab.appendChild(document.createTextNode('全选')); tb.appendChild(lab); var sp1 = document.createElement('span'); sp1.textContent = '从'; tb.appendChild(sp1); var inpFrom = document.createElement('input'); inpFrom.type = 'number'; inpFrom.min = '1'; inpFrom.value = '1'; inpFrom.style.cssText = 'width:54px;padding:3px 4px;border:1px solid rgba(128,128,128,.4);border-radius:4px;background:transparent;color:inherit;font-size:12px;'; tb.appendChild(inpFrom); var sp2 = document.createElement('span'); sp2.textContent = '到'; tb.appendChild(sp2); var inpTo = document.createElement('input'); inpTo.type = 'number'; inpTo.min = '1'; inpTo.value = '1'; inpTo.style.cssText = inpFrom.style.cssText; tb.appendChild(inpTo); var btnRange = mkDlBtn('选范围', false); btnRange.addEventListener('click', function (ev) { ev.stopPropagation(); var a = parseInt(inpFrom.value, 10) || 1; var b = parseInt(inpTo.value, 10) || 1; if (a > b) { var t = a; a = b; b = t; } selectRange(a, b); }); tb.appendChild(btnRange); var btnStart = mkDlBtn('开始下载 (0)', true); btnStart.id = 'dlSelStart'; btnStart.addEventListener('click', function (ev) { ev.stopPropagation(); startSelectedDownload(); }); tb.appendChild(btnStart); var btnCancel = mkDlBtn('取消', false); btnCancel.addEventListener('click', function (ev) { ev.stopPropagation(); exitSelectMode(); }); tb.appendChild(btnCancel); if (_.parentNode) _.parentNode.insertBefore(tb, _); selectToolbar = tb; } function enterSelectMode() { selectMode = true; selectedUrls = {}; btnDownloadAll.textContent = '选择章节...'; btnDownloadAll.disabled = true; buildSelectToolbar(); renderCatalog(); } function exitSelectMode() { selectMode = false; selectedUrls = {}; removeSelectToolbar(); btnDownloadAll.textContent = STR.download; btnDownloadAll.disabled = false; renderCatalog(); } function startSelectedDownload() { var checks = allChecks(); var map = catalogMap(); var list = []; checks.forEach(function (cb) { if (cb.checked) list.push({ url: cb.dataset.dlUrl, title: map[cb.dataset.dlUrl] || '' }); }); if (!list.length) { toast('请先勾选要下载的章节'); return; } // 大书下载提示:等用户勾选完章节、点击「开始下载」时,若勾选章节较多再提示耗时与文件体积 if (list.length > 300) { if (!confirm('已勾选 ' + list.length + ' 章,全本下载耗时较长且 TXT 文件较大,确定继续吗?')) return; } selectMode = false; removeSelectToolbar(); dlRunning = true; btnDownloadAll.textContent = '下载中 0/' + list.length; btnDownloadAll.disabled = false; renderCatalog(); send({ type: 'downloadAll', chapters: list }); } btnDownloadAll.addEventListener('click', function (ev) { ev.stopPropagation(); if (dlRunning) { if (btnDownloadAll.textContent.indexOf('停止中') !== -1) return; btnDownloadAll.textContent = '停止中...'; btnDownloadAll.disabled = true; toast('正在停止下载...'); send({ type: 'downloadPause' }); return; } if (selectMode) return; var list = (tocData && tocData.length >= 20) ? tocData : C.slice(); if (!list || list.length === 0) { toast('暂无章节可下载'); return; } // 大书下载提示已后移到「勾选章节后点击开始下载」时(见 startSelectedDownload),进入选择模式不再拦截 enterSelectMode(); }); function syncNightBtn() { if (s.theme === 'dark') { btnNight.querySelector('span').textContent = STR.day; btnNight.classList.add('active'); } else { btnNight.querySelector('span').textContent = STR.night; btnNight.classList.remove('active'); } } btnNight.addEventListener('click', function (ev) { ev.stopPropagation(); if (s.theme === 'dark') { s.theme = s.prevTheme || 'warm'; } else { s.prevTheme = s.theme; s.theme = 'dark'; } document.body.className = 'theme-' + s.theme; syncNightBtn(); syncThemeDots(); saveSettings(); }); function openSettings() { G = true; Y.classList.add('visible'); j.classList.add('visible'); hideBars(); } function closeSettings() { G = false; Y.classList.remove('visible'); j.classList.remove('visible'); } document.getElementById('btnSettings').addEventListener('click', function (ev) { ev.stopPropagation(); openSettings(); }); j.addEventListener('click', function () { closeSettings(); }); document.getElementById('btnManagePurify').addEventListener('click', function (ev) { ev.stopPropagation(); closeSettings(); openExtraPanel('purifyPanel'); }); document.getElementById('purifyClose').addEventListener('click', function (ev) { ev.stopPropagation(); closeExtraPanel(); }); document.getElementById('purifyAddBtn').addEventListener('click', function (ev) { ev.stopPropagation(); var inp = document.getElementById('purifyInput'); var v = (inp.value || '').trim(); if (!v) return; send({ type: 'addPurifyWords', words: v }); inp.value = ''; }); document.getElementById('purifyInput').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') { ev.stopPropagation(); var v = (this.value || '').trim(); if (!v) return; send({ type: 'addPurifyWords', words: v }); this.value = ''; } }); /* 净化说明:默认收起,点标题展开/收起,标题栏带箭头标识 */ (function () { var help = document.getElementById('purifyHelp'); if (!help) return; var raw = help.innerHTML; // 去掉原标题
,只保留正文说明 raw = raw.replace(/^\s*[\s\S]*?<\/b>\s*/i, ''); var arrow = document.createElement('span'); arrow.id = 'purifyHelpArrow'; arrow.textContent = '▾'; arrow.style.cssText = 'flex:none;font-size:12px;line-height:1;display:inline-block;color:inherit;opacity:.7;transition:transform .25s;transform:rotate(-90deg);'; var head = document.createElement('div'); head.id = 'purifyHelpHead'; head.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer;user-select:none;-webkit-user-select:none;'; var title = document.createElement('b'); title.textContent = '净化说明(阅读和下载TXT都会生效)'; head.appendChild(title); head.appendChild(arrow); var body = document.createElement('div'); body.id = 'purifyHelpBody'; body.style.cssText = 'margin-top:8px;display:none;'; body.innerHTML = raw; help.innerHTML = ''; help.appendChild(head); help.appendChild(body); head.addEventListener('click', function (ev) { ev.stopPropagation(); var collapsed = body.style.display === 'none'; body.style.display = collapsed ? 'block' : 'none'; arrow.style.transform = collapsed ? 'rotate(0deg)' : 'rotate(-90deg)'; }); })(); function applyFont() { b.textContent = s.fontSize; // CSS 变量统一控制字号:只写根节点一次,不再遍历所有章节逐个写内联样式 document.documentElement.style.setProperty('--reader-fs', s.fontSize + 'px'); document.documentElement.style.setProperty('--reader-ts', (s.fontSize + 4) + 'px'); // 清除历史章节的内联字号(一次性),之后统一由 CSS 变量接管 var cs = f.querySelectorAll('.chapter-content'); for (var i = 0; i < cs.length; i++) cs[i].style.fontSize = ''; var ts = f.querySelectorAll('.chapter-title'); for (var k = 0; k < ts.length; k++) ts[k].style.fontSize = ''; } function syncThemeDots() { var dots = document.querySelectorAll('.theme-dot'); for (var i = 0; i < dots.length; i++) dots[i].classList.toggle('active', dots[i].dataset.theme === s.theme); } function applySettings() { document.body.className = 'theme-' + s.theme; applyFont(); document.documentElement.style.setProperty('--reader-lh', s.lineHeight); var btns = document.querySelectorAll('#spacingBtns button'); for (var i = 0; i < btns.length; i++) btns[i].classList.toggle('active', parseFloat(btns[i].dataset.val) === s.lineHeight); syncThemeDots(); syncNightBtn(); // 清除历史章节的内联行距(一次性),之后统一由 CSS 变量接管 var cs = f.querySelectorAll('.chapter-content'); for (var k = 0; k < cs.length; k++) { cs[k].style.lineHeight = ''; } } function saveSettings() { send({ type: 'saveSettings', settings: s }); } document.getElementById('fontDec').addEventListener('click', function (ev) { ev.stopPropagation(); s.fontSize = Math.max(12, s.fontSize - 2); applyFont(); saveSettings(); }); document.getElementById('fontInc').addEventListener('click', function (ev) { ev.stopPropagation(); s.fontSize = Math.min(32, s.fontSize + 2); applyFont(); saveSettings(); }); document.getElementById('spacingBtns').addEventListener('click', function (ev) { var tgt = ev.target; if (!tgt.dataset || !tgt.dataset.val) return; ev.stopPropagation(); var btns = this.querySelectorAll('button'); for (var i = 0; i < btns.length; i++) btns[i].classList.remove('active'); tgt.classList.add('active'); s.lineHeight = parseFloat(tgt.dataset.val); document.documentElement.style.setProperty('--reader-lh', s.lineHeight); saveSettings(); }); document.getElementById('themeDots').addEventListener('click', function (ev) { var tgt = ev.target; if (!tgt.dataset || !tgt.dataset.theme) return; ev.stopPropagation(); s.theme = tgt.dataset.theme; document.body.className = 'theme-' + s.theme; syncThemeDots(); if (s.theme !== 'dark') s.prevTheme = s.theme; syncNightBtn(); saveSettings(); }); function closeExtraPanel() { extraVisible = false; whitelistPanel.classList.remove('visible'); historyPanel.classList.remove('visible'); var pp = document.getElementById('purifyPanel'); if (pp) pp.classList.remove('visible'); extraMask.style.display = 'none'; } function openExtraPanel(panelId) { closeExtraPanel(); extraVisible = true; var panel = document.getElementById(panelId); if (!panel) return; panel.classList.add('visible'); extraMask.style.display = 'block'; if (panelId === 'whitelistPanel') { send({ type: 'getWhitelist' }); } else if (panelId === 'historyPanel') { send({ type: 'getHistory' }); } else if (panelId === 'purifyPanel') { send({ type: 'getPurify' }); } setTimeout(function () { var list = panel.querySelector('.panel-list'); if (list) setupFade(list); }, 50); } function bindExtraToggle(btnId, panelId) { document.getElementById(btnId).addEventListener('click', function (ev) { ev.stopPropagation(); var panel = document.getElementById(panelId); if (extraVisible && panel.classList.contains('visible')) { closeExtraPanel(); return; } openExtraPanel(panelId); }); } document.getElementById('whitelistClose').addEventListener('click', function (ev) { ev.stopPropagation(); closeExtraPanel(); }); document.getElementById('historyClose').addEventListener('click', function (ev) { ev.stopPropagation(); closeExtraPanel(); }); extraMask.addEventListener('click', function () { closeExtraPanel(); }); bindExtraToggle('btnWhitelist', 'whitelistPanel'); bindExtraToggle('btnHistory', 'historyPanel'); function setupFade(list) { var wrap = list.parentNode; if (!wrap) return; var topMask = wrap.querySelector('.fade-mask.top'); var bottomMask = wrap.querySelector('.fade-mask.bottom'); if (!topMask || !bottomMask) return; function checkFade() { var scrollTop = list.scrollTop; var maxScroll = list.scrollHeight - list.clientHeight; topMask.classList.toggle('hidden', scrollTop <= 2); bottomMask.classList.toggle('hidden', maxScroll - scrollTop <= 2); } list.addEventListener('scroll', checkFade); setTimeout(checkFade, 100); var observer = new MutationObserver(function () { checkFade(); }); observer.observe(list, { childList: true, subtree: false }); list._fadeObserver = observer; list._fadeCleanup = function () { observer.disconnect(); list.removeEventListener('scroll', checkFade); }; } function buildPanelItem(titleText, highlighted, onMainClick, onDelClick) { var item = document.createElement('div'); item.className = 'panel-item'; if (highlighted) { item.style.backgroundColor = getHighlightColor(); item.classList.add('panel-item-hi'); } var main = document.createElement('div'); main.className = 'item-main'; var titleSpan = document.createElement('span'); titleSpan.className = 'item-title'; titleSpan.textContent = titleText; main.appendChild(titleSpan); var delBtn = document.createElement('button'); delBtn.className = 'item-del'; delBtn.textContent = '✕'; delBtn.setAttribute('aria-label', '删除'); delBtn.addEventListener('click', function (ev) { ev.stopPropagation(); onDelClick(); }); main.addEventListener('click', onMainClick); item.appendChild(main); item.appendChild(delBtn); return item; } function renderPanelList(containerId, emptyHtml, items) { var container = document.getElementById(containerId); if (container._fadeCleanup) { container._fadeCleanup(); container._fadeCleanup = null; } container.innerHTML = ''; if (!items || items.length === 0) { container.innerHTML = '
' + emptyHtml + '
'; return; } items.forEach(function (item) { container.appendChild(item); }); setupFade(container); } function renderWhitelist(list) { var items = (list || []).map(function (entry) { var domain = entry.domain || ''; return buildPanelItem(entry.title || domain, domain === currentHost, function () { send({ type: 'navigateTo', url: 'https://' + domain }); }, function () { send({ type: 'removeWhitelist', domain: domain }); }); }); renderPanelList('whitelistList', '暂无白名单', items); // 当前网站高亮项可能被挤到列表底部不可见:打开面板后自动滚动到可视区域(居中) setTimeout(function () { var hi = document.getElementById('whitelistList').querySelector('.panel-item-hi'); if (hi) { try { hi.scrollIntoView({ block: 'center' }); } catch (e) { hi.scrollIntoView(); } } }, 100); } function renderHistory(list) { var sorted = (list || []).slice().sort(function (a, b) { return b.timestamp - a.timestamp; }); var items = sorted.map(function (entry) { var entryKey = entry.bookKey || getBookRootKey(entry.url); return buildPanelItem(entry.title || '未命名', entryKey === currentBookKey, function () { send({ type: 'navigateTo', url: entry.url }); }, function () { send({ type: 'removeHistory', url: entry.url }); }); }); renderPanelList('historyList', '暂无历史记录', items); } function renderPurify(list) { var items = (list || []).map(function (w) { return buildPanelItem(w, false, function () {}, function () { send({ type: 'removePurifyWord', word: w }); }); }); renderPanelList('purifyList', '暂无净化词', items); var pspan = document.getElementById('purifyPanel').querySelector('.panel-header span'); if (pspan) pspan.textContent = '净化词(' + (list || []).length + ')· 点✕删除'; } send({ type: 'ready' }); } var READER_JS = "(function () {\n'use strict';\n" + SHARED_SRC + "\n" + iframeMain.toString() + "\niframeMain();\n})();"; var READER_HTML = '' + '' + '阅读模式' + '' + READER_BODY + '