// ==UserScript==
// @name X阅读模式
// @namespace https://scriptcat.org/scripts/code/7614/X阅读模式.user.js
// @version 1.5.2.5
// @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
// @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;
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];
}
_tocPageCache[url] = { html: html, time: Date.now() };
}
/* ==================== 存储 ==================== */
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 [];
}
}
function saveWhitelist(list) {
_whitelistCache = null;
try {
GM_setValue(KEY_WHITELIST, JSON.stringify(list));
} catch (e) {}
}
function inWhitelist(host) {
var list = getWhitelist();
for (var i = 0; i < list.length; i++) {
if (list[i].domain === host) 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|‹|«|←|<)$/i.test(t)) return 1;
return 0;
}
/* ==================== 增强模块:规则库 ==================== */
var SITE_RULES = [
// 站点规则只参与“命中判定”(rule.must 全命中)与 content 收集;
// 原 prev/next/chapter 选择器字段仅被已删除的死代码 ruleHref 消费,已一并移除。
// 注意:无 must 的规则永远不会被选中(matchedRule 要求 rule.must 全命中),
// 原 T2/T3/T4/T5/T6/T7/T8/T9/T12 即为此类死规则,已删除,仅保留实际可命中的 T1/T10/T11。
{ name: 'T1', must: ['.pagee > .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";
/* ==================== 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');
}
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) {} }
}
// 去掉章节标题里的分页页码后缀((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 +
'