// ==UserScript==
// @name 阅读模式·纯净版
// @namespace https://viayoo.com/hqzw7k
// @version 22.3.0
// @description 阅读模式,纯净阅读,给你带来纯净阅读体验
// @author DeepSeek
// @homepageURL https://scriptcat.org/zh-CN/users/184036
// @license MIT
// @match *://*/*
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
const CONTENT_SELECTORS = [
'#chapter-content', '#chaptercontent', '#ChapterContent', '#nr1', '#booktext',
'#novel_content', '#read-content', '#chapter-body', '#txtContent', '#BookText',
'#chapter', '#contentBox', '#book_read', '.read-content', '.chapter-content',
'#J_read', '#reader', '.article-content', '.content-area', '.post-body',
'#js-read__content', '#readbox', '#zhengwen', '#kui-page-read-txt', '#chapter_cont',
'#BookTextRead', '#book_text', '#readtext', '#readcon', '#TextContent', '#text_c',
'#txt_td', '#TXT', '#txt', '#zjneirong', '.novel_content', '.readmain_inner',
'.noveltext', '.booktext', '.yd_text2', '#contentTxt', '#oldtext', '#a_content',
'#contents', '#content2', '#contentts', '#content1', '#novelcontent', '#text',
'.m-post', '.novel-content', 'pre.novel-content-txt', '.share-v2-chapter-content',
'.content_ul', '.file_content', '.submenu + .con', '#acontent',
'#article', '#articleBody', '#article-body', '#post-content', '#entry-content',
'#main-content', '#content-main', '#post', '.article-body', '.post-content',
'.entry-content', '.post-body', '.entry-body', '.article-content', '.content-inner',
'.articleText', '#js_content', '#baikan-content', '.news-content', '.rm_txt_con',
'#reader-container', '.rich_media_content', '#detail', '.detail_nr', '#chapterDiv',
'.article_content', '#contentText', '.fallback-content', '#mw-content-text',
'#content', '.article'
];
const TITLE_SELECTORS = [
'h1.article-title', 'h1.entry-title', 'h1.post-title', 'h1.title',
'h1#title', 'h1#articleTitle', 'h1[itemprop="headline"]', '#nr_title',
'.nr_function>h1', 'h1', '.article-title', '.entry-title', '.post-title',
'.title', '#title', '#article-title', '#post-title', '.title-color', 'h2.big.o'
];
const NAV_SELECTORS = {
prev: [
'a:contains(上一页)', 'a:contains(上一章)', 'a:contains(上一篇)',
'a.prev', 'a.previous', '.prev', '.previous', '#prev', '#previous',
'a[rel="prev"]', 'a[rel="previous"]', 'a[data-prev]'
],
next: [
'a:contains(下一页)', 'a:contains(下一章)', 'a:contains(下一篇)',
'a.next', '.next', '#next', 'a[rel="next"]', 'a[data-next]'
],
index: [
'a:contains(目录)', 'a:contains(章节目录)', 'a:contains(返回目录)',
'a:contains(书籍页)', 'a:contains(章节列表)',
'a.index', '.index', '#index', 'a.toc', '.toc', '#toc', 'a[rel="index"]'
]
};
const INDEX_URL_HINTS = /indexlist|chapterlist|catalog|mulu|list/i;
const PAGE_CLEAN_LINK_TEXTS = [
'举报', '回复', '首页', '返回',
'立即阅读', '开始阅读', '全文阅读',
'加入书架', '推荐本书', '下载TXT', '文本下载',
'『加入书签,方便阅读』'
];
const PAGE_CLEAN_AD_KEYWORDS = [
'收藏备用网址', '备用网址', '收藏本站', '收藏此页',
'防走丢', '谨防走丢', '请记住本站', '记住本站',
'永久网址', '最新网址', '发布页', '防丢失'
];
const AD_BLOCK_TAGS = 'p, article, section, ul, ol, table, pre, blockquote, h1, h2, h3, h4, h5, h6';
const INTERACTIVE_SELECTOR = 'a, img, button, summary, details, input, label, select, textarea, video, audio, [onclick], [role="button"], [contenteditable="true"]';
const SHOULD_AUTO_ACTIVATE = window.location.hash === '#readermode';
const ICONS = {
back: ``,
catalog: ``,
settings: ``,
moon: ``,
sun: ``,
crosshair: ``,
prev: ``,
next: ``,
bg: ``,
open: ``,
translate: ``,
catalogSortAsc: ``,
catalogSortDesc: ``,
missing: ``
};
const STORAGE_KEYS = {
BG_COLOR: 'reader_bg_color',
FONT_SIZE: 'reader_font_size',
THEME_MODE: 'reader_theme_mode',
FLOATING_BUTTON: 'reader_floating_button',
};
const DEFAULT_SETTINGS = {
bgColor: '#ffffff',
fontSize: '18px',
themeMode: 'day',
floatingButton: true,
};
const MIN_CONTENT_LENGTH = 30;
const settingsCache = {};
function gmGet(key, def) {
if (settingsCache[key] !== undefined) return settingsCache[key];
try {
const v = GM_getValue(key);
if (v !== undefined && v !== null && v !== '') { settingsCache[key] = v; return v; }
} catch(e) {}
return def;
}
function gmSet(key, val) {
settingsCache[key] = val;
try { GM_setValue(key, val); } catch(e) {}
}
function loadSettings() {
return {
bgColor: gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor),
fontSize: gmGet(STORAGE_KEYS.FONT_SIZE, DEFAULT_SETTINGS.fontSize),
themeMode: normalizeThemeMode(gmGet(STORAGE_KEYS.THEME_MODE, DEFAULT_SETTINGS.themeMode)),
floatingButton: gmGet(STORAGE_KEYS.FLOATING_BUTTON, DEFAULT_SETTINGS.floatingButton) === true,
};
}
function normalizeThemeMode(mode) { return mode === 'night' ? 'night' : 'day'; }
function saveSetting(key, value) { gmSet(key, value); }
function createSandboxIframe(src) {
const iframe = document.createElement('iframe');
iframe.style.cssText = 'position:absolute;width:0;height:0;border:none;visibility:hidden;';
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
iframe.src = src;
return iframe;
}
let toastHost = null, toastShadow = null, toastContainer = null;
function ensureToastHost() {
if (toastHost && toastShadow && toastContainer && document.documentElement.contains(toastHost)) return;
toastHost = document.createElement('div');
toastHost.id = 'reader-toast-host';
toastHost.style.cssText = 'all:initial;position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647;pointer-events:none;';
toastShadow = toastHost.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
.toast-container { position:fixed; left:0; right:0; bottom:75px; display:flex; flex-direction:column; align-items:center; gap:8px; pointer-events:none; z-index:2147483647; }
.toast-item { background:rgba(28,28,30,0.85); color:#fff; padding:14px 24px; border-radius:999px; font-size:14px; font-weight:500; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; box-shadow:0 8px 24px rgba(0,0,0,0.2); animation:toastIn 0.3s cubic-bezier(0.22,1,0.36,1); opacity:0; transform:translateY(20px); animation-fill-mode:forwards; max-width:80%; text-align:center; word-break:break-word; }
.toast-item.out { animation:toastOut 0.3s cubic-bezier(0.22,1,0.36,1) forwards; }
@keyframes toastIn { from { opacity:0; transform:translateY(20px); } to { opacity:1; transform:translateY(0); } }
@keyframes toastOut { from { opacity:1; transform:translateY(0); } to { opacity:0; transform:translateY(20px); } }
`;
toastShadow.appendChild(style);
toastContainer = document.createElement('div');
toastContainer.className = 'toast-container';
toastShadow.appendChild(toastContainer);
document.documentElement.appendChild(toastHost);
}
function showShadowToast(msg, duration = 2000) {
try {
ensureToastHost();
const item = document.createElement('div');
item.className = 'toast-item';
item.textContent = msg;
toastContainer.appendChild(item);
setTimeout(() => {
item.classList.add('out');
setTimeout(() => { try { item.remove(); } catch(e) {} }, 320);
}, duration);
} catch(e) {}
}
function notify(msg) {
if (window.via && typeof window.via.toast === 'function') {
try { window.via.toast(msg); return; } catch(e) {}
}
showShadowToast(msg);
}
function normalizeUrl(u) {
try { const url = new URL(u, window.location.href); return url.origin + url.pathname; }
catch(e) { return u; }
}
let isReaderMode = false;
let buttonHideTimeout = null;
let mainButton = null;
let originalUrl = '';
let menuCommandIds = [];
let readerOverlay = null;
let originalBodyOverflow = '', originalBodyPosition = '', originalBodyTop = '', originalBodyWidth = '';
let scrollY = 0;
let currentCatalogView = null;
let catalogCache = null;
let catalogCacheLoading = null;
let chapterCache = new Map();
let currentPreloadTask = null;
let preloadScheduleTimer = null;
let catalogOrder = 'asc';
const config = {
backgroundOptions: {
'默认白': '#ffffff', '米黄': '#f8f1e3',
'豆沙绿': '#c0edc7', '藕粉': '#f5e6e8',
'深灰': '#4a4a4d', '护眼黑': '#121212'
}
};
const NIGHT_THEME = {
bgColor: '#111d2b', textColor: '#c0c5cc', borderColor: '#2a3b4d',
controlBgColor: '#172535', controlBorderColor: '#2a3b4d',
subtleTextColor: '#8892a0', exitBtnColor: '#8892a0', navBtnColor: '#4a9eff',
codeBg: 'rgba(255,255,255,0.05)', codeBorder: 'rgba(255,255,255,0.18)',
codeTextColor: '#e6edf3', inlineCodeBg: 'rgba(255,255,255,0.12)',
tableHeaderBg: 'rgba(255,255,255,0.06)', tableBorder: 'rgba(255,255,255,0.18)'
};
function isValidHref(h) {
if (!h || h.trim() === '' || h === '#') return false;
try {
const url = new URL(h, window.location.href);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch (e) { return false; }
}
function removeSimulatedButtons(c) {
c.querySelectorAll('[role="button"]:not(a)').forEach(e => e.remove());
c.querySelectorAll('[rl-type="stop"]:not(a)').forEach(e => e.remove());
const kw = ['btn', 'button', 'copy', 'share', 'feedback', 'thumb', 'like', 'dislike', 'close', 'popup', 'modal', 'toolbar', 'interact', 'action'];
const s = kw.map(k => `[class*="${k}"]:not(a)`).join(',');
c.querySelectorAll(s).forEach(e => e.remove());
}
function cleanPageAds(root) {
root = root || document.body;
if (!root || !root.querySelectorAll) return;
if (PAGE_CLEAN_LINK_TEXTS.length) {
const esc = t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const exactSet = new Set(PAGE_CLEAN_LINK_TEXTS);
const suffixRe = new RegExp('^(' + PAGE_CLEAN_LINK_TEXTS.map(esc).join('|') + ')[((]\\d*[))]?$');
root.querySelectorAll('a').forEach(a => {
const t = (a.textContent || '').trim();
if (exactSet.has(t) || suffixRe.test(t)) {
try { a.remove(); } catch(e) {}
}
});
}
root.querySelectorAll('div, span, p, section, aside').forEach(el => {
if (!el.isConnected) return;
if (el.querySelector(AD_BLOCK_TAGS)) return;
const t = (el.textContent || '').trim();
if (!t || t.length > 120) return;
if (!PAGE_CLEAN_AD_KEYWORDS.some(k => t.includes(k))) return;
let target = el;
const p = el.parentElement;
if (p && p !== root && p.children.length === 1 && !p.querySelector(AD_BLOCK_TAGS)) target = p;
try { target.remove(); } catch(e) {}
});
}
function pruneCandidates(root) {
if (!root || !root.querySelectorAll) return;
const junkSel = [
'aside', 'nav', 'footer', 'header',
'[class*="comment"]', '[class*="sidebar"]', '[class*="footer"]',
'[class*="banner"]', '[class*="promo"]', '[class*="related"]',
'[class*="widget"]', '[class*="share"]', '[id*="comment"]', '[id*="sidebar"]'
].join(',');
root.querySelectorAll(junkSel).forEach(el => { try { el.remove(); } catch(e) {} });
}
function stripLeadingSpaces(c) {
const blockTags = ['p', 'div', 'li', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
c.querySelectorAll(blockTags.join(',')).forEach(el => {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
const first = walker.nextNode();
if (first) first.nodeValue = first.nodeValue.replace(/^[\s\u00A0\u3000\u2000-\u200B\uFEFF]+/, '');
});
c.querySelectorAll('pre').forEach(pre => {
const first = pre.firstChild;
if (first && first.nodeType === 3 && /^\s*$/.test(first.nodeValue)) first.nodeValue = '';
});
}
function splitBrParagraphs(root) {
const BLOCK_TAGS = new Set(['DIV','P','UL','OL','TABLE','BLOCKQUOTE','PRE','H1','H2','H3','H4','H5','H6','SECTION','ARTICLE','DL','FIGURE','FORM','HEADER','FOOTER','NAV','MAIN','ASIDE','DETAILS']);
Array.from(root.querySelectorAll('div')).forEach(block => { try { processBlock(block); } catch(e) {} });
function processBlock(block) {
const nodes = Array.from(block.childNodes);
const hasContent = nodes.some(n => (n.nodeType === 3 && (n.nodeValue || '').trim().length > 0) || (n.nodeType === 1 && n.tagName === 'BR'));
if (!hasContent) return;
const rawText = (block.textContent || '').trim();
if (rawText.length < 50) return;
const groups = [];
let current = [], brCount = 0;
const flushCurrent = () => { if (current.length > 0) { groups.push({ type: 'p', nodes: current }); current = []; } };
for (const node of nodes) {
if (node.nodeType === 1 && node.tagName === 'BR') {
brCount++;
if (brCount >= 2) { flushCurrent(); brCount = 0; } else current.push(node);
} else if (node.nodeType === 1 && BLOCK_TAGS.has(node.tagName)) {
flushCurrent(); groups.push({ type: 'keep', node }); brCount = 0;
} else if (node.nodeType === 1 || node.nodeType === 3) {
brCount = 0; current.push(node);
}
}
flushCurrent();
const pGroups = groups.filter(g => g.type === 'p');
if (pGroups.length === 0) return;
const totalPText = pGroups.reduce((s, g) => s + g.nodes.reduce((s2, n) => s2 + (n.textContent || '').replace(/\s+/g, '').length, 0), 0);
if (totalPText < 50) return;
while (block.firstChild) block.removeChild(block.firstChild);
for (const group of groups) {
if (group.type === 'keep') block.appendChild(group.node);
else {
const p = document.createElement('p');
for (const n of group.nodes) p.appendChild(n);
if (p.textContent.trim().length > 0 || p.querySelector('img')) block.appendChild(p);
}
}
}
}
function wrapLooseTextNodes(root) {
const BLOCK_TAGS = new Set(['DIV','P','UL','OL','TABLE','BLOCKQUOTE','PRE','H1','H2','H3','H4','H5','H6','SECTION','ARTICLE','DL','FIGURE','FORM','HEADER','FOOTER','NAV','MAIN','ASIDE','DETAILS','HR']);
Array.from(root.querySelectorAll('div')).forEach(div => {
try {
const children = Array.from(div.childNodes);
if (children.some(n => n.nodeType === 1 && n.tagName === 'P')) return;
const hasBlockChild = children.some(n => n.nodeType === 1 && BLOCK_TAGS.has(n.tagName));
if (!hasBlockChild) return;
const hasLooseText = children.some(n => n.nodeType === 3 && (n.nodeValue || '').trim().length > 0);
if (!hasLooseText) return;
const newChildren = [];
let buffer = [];
const flush = () => {
if (buffer.length === 0) return;
const hasContent = buffer.some(n => n.nodeType === 3 ? (n.nodeValue || '').trim().length > 0 : true);
if (!hasContent) { buffer = []; return; }
const p = document.createElement('p');
for (const n of buffer) {
if (n.nodeType === 3) {
const parts = (n.nodeValue || '').split('\n');
for (let i = 0; i < parts.length; i++) {
if (i > 0) p.appendChild(document.createElement('br'));
if (parts[i]) p.appendChild(document.createTextNode(parts[i]));
}
} else p.appendChild(n);
}
newChildren.push(p); buffer = [];
};
for (const node of children) {
if (node.nodeType === 1 && BLOCK_TAGS.has(node.tagName)) { flush(); newChildren.push(node); }
else buffer.push(node);
}
flush();
while (div.firstChild) div.removeChild(div.firstChild);
for (const n of newChildren) div.appendChild(n);
} catch(e) {}
});
}
function unwrapFakeLists(root) {
if (!root || !root.querySelectorAll) return;
const isFakeList = (list) => {
const items = Array.from(list.children).filter(c => c.tagName === 'LI');
if (items.length === 0) return false;
const longItem = items.some(li => (li.textContent || '').replace(/\s+/g, '').length > 300);
const manyBr = items.length > 0 && items.every(li => li.querySelectorAll('br').length >= 3);
return items.length === 1 || longItem || manyBr;
};
const buildReplacement = (list) => {
const items = Array.from(list.children).filter(c => c.tagName === 'LI');
const div = document.createElement('div');
for (const li of items) {
const innerDiv = document.createElement('div');
while (li.firstChild) innerDiv.appendChild(li.firstChild);
div.appendChild(innerDiv);
}
return div;
};
root.querySelectorAll('ul, ol').forEach(list => {
if (!isFakeList(list)) return;
try { list.replaceWith(buildReplacement(list)); } catch(e) {}
});
if (root.tagName === 'UL' || root.tagName === 'OL') {
if (isFakeList(root)) {
const div = buildReplacement(root);
while (root.firstChild) root.removeChild(root.firstChild);
while (div.firstChild) root.appendChild(div.firstChild);
}
}
}
function cleanContent(c, shouldSplit) {
c.querySelectorAll('[style*="display: none"], [style*="display:none"], [style*="visibility: hidden"], [style*="visibility:hidden"]').forEach(e => e.remove());
c.querySelectorAll('markdown-accessiblity-table').forEach(el => {
const p = el.parentNode;
if (p) { while (el.firstChild) p.insertBefore(el.firstChild, el); p.removeChild(el); }
});
cleanPageAds(c);
c.querySelectorAll('video, style, link[rel="stylesheet"], script, button, svg').forEach(e => e.remove());
c.querySelectorAll('form, textarea, select, input').forEach(e => e.remove());
removeSimulatedButtons(c);
c.querySelectorAll('i').forEach(e => { if (!e.textContent.trim() && !e.querySelector('img')) e.remove(); });
c.querySelectorAll('figure.highlight, div.highlight, figure[class*="highlight"], div[class*="highlight"]').forEach(container => {
const pre = container.querySelector('pre'); if (!pre) return;
const table = container.querySelector('table'); if (!table) return;
if ((table.textContent || '').replace(/\s+/g, '') !== (pre.textContent || '').replace(/\s+/g, '')) return;
const preClone = pre.cloneNode(true);
while (container.firstChild) container.removeChild(container.firstChild);
container.appendChild(preClone);
});
c.querySelectorAll('*').forEach(e => { e.removeAttribute('style'); e.removeAttribute('class'); });
['mark', 'span', 'ml-search', 'font'].forEach(t => {
c.querySelectorAll(t).forEach(e => {
const p = e.parentNode;
if (p) { while (e.firstChild) p.insertBefore(e.firstChild, e); p.removeChild(e); }
});
});
c.querySelectorAll('a').forEach(a => {
const h = a.getAttribute('href');
if (!h || !isValidHref(h)) {
const p = a.parentNode;
if (p) { while (a.firstChild) p.insertBefore(a.firstChild, a); p.removeChild(a); }
return;
}
const attrs = a.attributes;
for (let i = attrs.length - 1; i >= 0; i--) { if (attrs[i].name !== 'href') a.removeAttribute(attrs[i].name); }
});
c.querySelectorAll('img').forEach(img => {
const src = img.getAttribute('src') || img.getAttribute('data-original') || img.getAttribute('data-src') || img.getAttribute('data-lazy-src') || img.getAttribute('data-echo') || '';
const rawClass = img.getAttribute('class') || '';
const isEmoji = /emotion|emoji/i.test(src) || /emoji/i.test(rawClass);
const altVal = img.getAttribute('alt') || '';
const widthVal = img.getAttribute('width') || '';
const heightVal = img.getAttribute('height') || '';
const attrs = img.attributes;
for (let i = attrs.length - 1; i >= 0; i--) img.removeAttribute(attrs[i].name);
if (altVal) img.setAttribute('alt', altVal);
if (src) {
img.setAttribute('src', src);
if (isEmoji) img.setAttribute('data-emoji', '1');
else { if (widthVal) img.setAttribute('width', widthVal); if (heightVal) img.setAttribute('height', heightVal); }
} else img.remove();
});
unwrapFakeLists(c);
if (shouldSplit) splitBrParagraphs(c);
wrapLooseTextNodes(c);
stripLeadingSpaces(c);
}
function removeDuplicateTitle(c, titleText) {
if (!titleText) return;
const norm = s => (s || '').replace(/\s+/g, '').trim();
const target = norm(titleText);
if (target.length < 2) return;
c.querySelectorAll('h1, h2, h3, h4, h5, h6, p, div').forEach(el => {
if (el.querySelector('p, div, h1, h2, h3, h4, h5, h6, ul, ol, table')) return;
if (norm(el.textContent) === target) el.remove();
});
}
function detectIndent(el) {
const blocks = el.querySelectorAll('p, li, div');
if (blocks.length > 0 && el.ownerDocument && el.ownerDocument.defaultView) {
let cnt = 0;
const n = Math.min(blocks.length, 10);
for (let i = 0; i < n; i++) {
const ti = parseFloat(window.getComputedStyle(blocks[i]).textIndent) || 0;
if (ti > 1) cnt++;
}
if (cnt > n / 2) return true;
}
const ownerDoc = el.ownerDocument || document;
const walker = ownerDoc.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
let textCount = 0, indentCount = 0, node;
while ((node = walker.nextNode()) !== null && textCount < 30) {
const text = node.nodeValue || '';
if (text.trim().length < 5) continue;
textCount++;
if (/^(?:\u3000\u3000|\u3000|\s{3,})/.test(text)) indentCount++;
}
return textCount > 0 && indentCount > textCount * 0.5;
}
function collectHiddenPaths(root) {
const paths = [];
if (!root || !root.childNodes) return paths;
const doc = root.ownerDocument || document;
const win = doc && doc.defaultView;
if (!win || typeof win.getComputedStyle !== 'function') return paths;
(function walk(el, path) {
let hidden = false;
try {
const cs = win.getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') hidden = true;
} catch(e) {}
if (hidden) { paths.push(path); return; }
const children = el.childNodes;
for (let i = 0; i < children.length; i++) {
if (children[i].nodeType === 1) {
walk(children[i], path.concat([i]));
}
}
})(root, []);
return paths;
}
function removeByPaths(clone, paths) {
if (!clone || !paths || !paths.length) return;
const sorted = paths.slice().sort((a, b) => b.length - a.length);
for (const path of sorted) {
let cur = clone;
let ok = true;
for (let i = 0; i < path.length - 1; i++) {
cur = cur.childNodes[path[i]];
if (!cur) { ok = false; break; }
}
if (!ok) continue;
const lastIdx = path[path.length - 1];
const target = cur.childNodes[lastIdx];
if (target && target.parentNode === cur) {
try { cur.removeChild(target); } catch(e) {}
}
}
}
const SCORING_POSITIVE_RE = /(^|[-_])(content|article|post|read|chapter|text|entry|main|story|blog|body|detail|novel|txt|book|paper|doc|markdown)([-_]|$)/i;
const SCORING_NEGATIVE_RE = /(^|[-_])(comment|sidebar|nav|footer|header|menu|ad|banner|promo|share|social|related|recommend|widget|breadcrumb|pagination|meta|toolbar|popup|modal|login|register|subscribe|signup|download|app)([-_]|$)/i;
function findContentByScoring(doc) {
const root = doc.body || doc.documentElement;
if (!root) return null;
const candidates = root.querySelectorAll('p, div, article, section, td, pre, blockquote, main');
const scores = new Map();
const linkDensityCache = new Map();
const JUNK_TAG_SET = new Set(['ASIDE', 'NAV', 'FOOTER', 'HEADER']);
const JUNK_RE = /(^|[-_])(comment|sidebar|footer|banner|promo|related|widget|share|ad|nav)([-_]|$)/i;
function isJunkNode(el) {
if (JUNK_TAG_SET.has(el.tagName)) return true;
const cn = ((el.className || '') + ' ' + (el.id || '')).trim();
return cn ? JUNK_RE.test(cn) : false;
}
function getLinkDensity(el) {
if (linkDensityCache.has(el)) return linkDensityCache.get(el);
const totalText = (el.textContent || '').replace(/\s+/g, '').length;
if (totalText === 0) { linkDensityCache.set(el, 0); return 0; }
let linkText = 0;
el.querySelectorAll('a').forEach(a => { linkText += (a.textContent || '').replace(/\s+/g, '').length; });
const d = linkText / totalText;
linkDensityCache.set(el, d);
return d;
}
function effectiveTextLen(el) {
let total = 0;
let longPCount = 0;
el.querySelectorAll('p').forEach(p => {
const len = (p.textContent || '').replace(/\s+/g, '').length;
if (len >= 25) { total += len; longPCount++; }
});
if (longPCount === 0) {
return (el.textContent || '').replace(/\s+/g, '').length;
}
return total;
}
candidates.forEach(el => {
const tag = el.tagName;
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') return;
if (isJunkNode(el)) return;
const rawText = (el.textContent || '').trim();
if (!rawText) return;
const textLen = effectiveTextLen(el);
if (textLen < 30) return;
let score = 0;
if (tag === 'ARTICLE') score += 30;
else if (tag === 'MAIN') score += 20;
else if (tag === 'SECTION') score += 15;
else if (tag === 'DIV') score += 5;
else if (tag === 'P') score += 3;
else if (tag === 'PRE' || tag === 'BLOCKQUOTE') score += 3;
else if (tag === 'OL' || tag === 'UL' || tag === 'LI' || tag === 'FORM' || tag === 'ADDRESS') score -= 3;
else if (tag === 'H1' || tag === 'H2' || tag === 'H3' || tag === 'H4' || tag === 'H5' || tag === 'H6' || tag === 'TH') score -= 5;
score += Math.min(textLen / 25, 30);
const punctCount = (rawText.match(/[,。!?;:,.!?;:]/g) || []).length;
score += Math.min(punctCount, 10);
const pCount = el.querySelectorAll('p').length;
if (pCount >= 3) score += Math.min(pCount, 10);
const cn = ((el.className || '') + ' ' + (el.id || '')).trim();
if (cn) {
if (SCORING_POSITIVE_RE.test(cn)) score += 25;
if (SCORING_NEGATIVE_RE.test(cn)) score -= 25;
}
const ld = getLinkDensity(el);
if (ld >= 0.5) return;
if (ld > 0.3) score *= (1 - ld);
scores.set(el, score);
});
if (scores.size === 0) return null;
const totals = new Map();
scores.forEach((s, el) => {
let cur = el, weight = 1, depth = 0;
while (cur && depth < 4) {
totals.set(cur, (totals.get(cur) || 0) + s * weight);
cur = cur.parentElement; weight *= 0.5; depth++;
}
});
let best = null, bestScore = 0;
totals.forEach((s, el) => {
const textLen = (el.textContent || '').replace(/\s+/g, '').length;
if (textLen < 100) return;
if (s > bestScore) { bestScore = s; best = el; }
});
if (!best || bestScore < 60) return null;
if (best === doc.body || best === doc.documentElement) return null;
const cn = ((best.className || '') + ' ' + (best.id || '')).trim();
console.log(`[阅读模式] 评分器命中:<${best.tagName.toLowerCase()}${cn ? ' class="' + cn + '"' : ''}> 得分 ${bestScore.toFixed(1)}`);
return best;
}
function findLinkByText(doc, selectors) {
for (const s of selectors) {
if (s.includes(':contains(')) {
const m = s.match(/:contains\((.+?)\)/);
if (m) {
const text = m[1];
const base = s.replace(/:contains\(.+?\)/, '');
const els = base ? doc.querySelectorAll(base) : doc.querySelectorAll('a');
for (const el of els) {
if (el.tagName === 'A' && el.href && el.textContent.includes(text) && isValidHref(el.href)) return el;
}
}
} else {
const el = doc.querySelector(s);
if (el && el.tagName === 'A' && el.href && isValidHref(el.href)) return el;
}
}
return null;
}
function collectLinksByText(doc, selectors) {
const results = [], seen = new Set();
for (const s of selectors) {
if (s.includes(':contains(')) {
const m = s.match(/:contains\((.+?)\)/);
if (m) {
const text = m[1];
const base = s.replace(/:contains\(.+?\)/, '');
let els;
try { els = base ? doc.querySelectorAll(base) : doc.querySelectorAll('a'); } catch(e) { continue; }
for (const el of els) {
if (el.tagName === 'A' && el.href && el.textContent.includes(text) && isValidHref(el.href)) {
const key = el.href;
if (!seen.has(key)) { seen.add(key); results.push(el); }
}
}
}
} else {
let els;
try { els = doc.querySelectorAll(s); } catch(e) { continue; }
for (const el of els) {
if (el.tagName === 'A' && el.href && isValidHref(el.href)) {
const key = el.href;
if (!seen.has(key)) { seen.add(key); results.push(el); }
}
}
}
}
return results;
}
function findNavLinksByContext(doc) {
const res = { prev: null, next: null, index: null };
const xpaths = {
prev: '//*[contains(text(),"上一章") or contains(text(),"上一页") or contains(text(),"上一篇")]/following-sibling::a[1]',
next: '//*[contains(text(),"下一章") or contains(text(),"下一页") or contains(text(),"下一篇")]/following-sibling::a[1]',
index: '//*[text()="目录" or text()="章节目录" or text()="返回目录" or text()="书籍页" or text()="章节列表"]/following-sibling::a[1]'
};
for (let key in xpaths) {
const node = doc.evaluate(xpaths[key], doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
if (node && node.tagName === 'A' && node.href && isValidHref(node.href)) res[key] = node;
}
const containers = [
{ key: 'prev', texts: ['上一章', '上一页', '上一篇'], exact: false },
{ key: 'next', texts: ['下一章', '下一页', '下一篇'], exact: false },
{ key: 'index', texts: ['目录', '章节目录', '返回目录', '书籍页', '章节列表'], exact: true }
];
for (const item of containers) {
if (!res[item.key]) {
for (const txt of item.texts) {
const xpath = item.exact
? `//*[text()="${txt}"]`
: `//*[contains(text(),"${txt}")]`;
const container = doc.evaluate(xpath, doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
if (container) {
const link = container.querySelector('a[href]');
if (link && isValidHref(link.href)) { res[item.key] = link; break; }
}
}
}
}
return res;
}
function extractNavFromScriptVars(doc) {
const res = { prev: null, next: null, index: null };
let win = null;
try {
win = (doc && doc.defaultView) || (doc === document ? window : null);
} catch(e) { win = null; }
if (!win) return res;
const VAR_NAMES = ['ReadParams', 'readParams', 'bookParams', 'chapterParams', 'novelParams', 'chapterInfo', 'readInfo', 'novelInfo'];
const PREV_KEYS = ['url_previous', 'url_prev', 'url_pre', 'prevUrl', 'prev_url', 'previousUrl', 'previous_url', 'pre_url', 'preUrl'];
const NEXT_KEYS = ['url_next', 'urlNext', 'nextUrl', 'next_url', 'url_nextpage', 'nextPageUrl'];
const INDEX_KEYS = ['url_index', 'url_catalog', 'url_catelog', 'indexUrl', 'index_url', 'catalogUrl', 'catalog_url', 'muluUrl', 'url_mulu', 'url_list'];
const pickUrl = (obj, keys) => {
for (const k of keys) {
let v;
try { v = obj[k]; } catch(e) { continue; }
if (typeof v === 'string' && v.trim()) {
const s = v.trim();
if (s === '#' || s.startsWith('javascript:')) continue;
try { return new URL(s, win.location.href).href; } catch(e) { return s; }
}
}
return null;
};
const makeFakeLink = (href, text) => ({ href, text, textContent: text, tagName: 'A', nodeType: 1, __fromVars: true });
for (const name of VAR_NAMES) {
let obj;
try { obj = win[name]; } catch(e) { continue; }
if (!obj || typeof obj !== 'object') continue;
if (!res.prev) {
const u = pickUrl(obj, PREV_KEYS);
if (u && isValidHref(u)) res.prev = makeFakeLink(u, '上一页');
}
if (!res.next) {
const u = pickUrl(obj, NEXT_KEYS);
if (u && isValidHref(u)) res.next = makeFakeLink(u, '下一页');
}
if (!res.index) {
const u = pickUrl(obj, INDEX_KEYS);
if (u && isValidHref(u)) res.index = makeFakeLink(u, '目录');
}
if (res.prev && res.next && res.index) break;
}
if (!res.prev || !res.next || !res.index) {
let inlineText = '';
try {
const scripts = doc.querySelectorAll ? doc.querySelectorAll('script:not([src])') : [];
for (const s of scripts) {
const t = s.textContent || '';
if (/url_(previous|next|index|prev|pre)/i.test(t) || /(prevUrl|nextUrl|catalogUrl|indexUrl)/i.test(t)) {
inlineText += t + '\n';
}
}
} catch(e) {}
if (inlineText) {
const grab = (keys) => {
for (const k of keys) {
const re = new RegExp(k + "\\s*:\\s*['\"]([^'\"]+)['\"]", 'i');
const m = inlineText.match(re);
if (m && m[1] && m[1].trim() && m[1] !== '#' && !m[1].startsWith('javascript:')) {
try { return new URL(m[1].trim(), win.location.href).href; } catch(e) { return m[1].trim(); }
}
}
return null;
};
if (!res.prev) { const u = grab(PREV_KEYS); if (u && isValidHref(u)) res.prev = makeFakeLink(u, '上一页'); }
if (!res.next) { const u = grab(NEXT_KEYS); if (u && isValidHref(u)) res.next = makeFakeLink(u, '下一页'); }
if (!res.index) { const u = grab(INDEX_KEYS); if (u && isValidHref(u)) res.index = makeFakeLink(u, '目录'); }
}
}
return res;
}
const INDEX_STANDARD_TEXTS = new Set([
'目录', '章节目录', '返回目录', '书籍页', '章节列表',
'全部目录', '完整目录', '查看目录', '本书目录', '目录页', '返回书目',
'目録', '目錄', '返回目錄', '章節目錄'
]);
function isIndexLinkAcceptable(a) {
if (!a || !a.href || !isValidHref(a.href)) return false;
const t = (a.textContent || '').trim();
if (INDEX_STANDARD_TEXTS.has(t)) return true;
if (t.includes('目录') && INDEX_URL_HINTS.test(a.href)) return true;
return false;
}
function findNavLinks(doc) {
doc = doc || document;
let links = {
prev: findLinkByText(doc, NAV_SELECTORS.prev),
next: findLinkByText(doc, NAV_SELECTORS.next),
index: findLinkByText(doc, NAV_SELECTORS.index)
};
const cl = findNavLinksByContext(doc);
if (!links.prev && cl.prev) links.prev = cl.prev;
if (!links.next && cl.next) links.next = cl.next;
if (!links.index && cl.index) links.index = cl.index;
const sv = extractNavFromScriptVars(doc);
if (!links.prev && sv.prev) {
links.prev = sv.prev;
console.log(`[阅读模式] 变量兜底 prev: ${sv.prev.href}`);
}
if (!links.next && sv.next) {
links.next = sv.next;
console.log(`[阅读模式] 变量兜底 next: ${sv.next.href}`);
}
if (!links.index && sv.index) {
links.index = sv.index;
console.log(`[阅读模式] 变量兜底 index: ${sv.index.href}`);
}
if (links.index && !isIndexLinkAcceptable(links.index)) {
console.log(`[阅读模式] 目录候选被过滤(文本/URL 不符):"${(links.index.textContent || '').trim()}" → ${links.index.href}`);
links.index = null;
}
try {
const indexCandidates = collectLinksByText(doc, NAV_SELECTORS.index).filter(isIndexLinkAcceptable);
if (cl.index && isIndexLinkAcceptable(cl.index) && !indexCandidates.some(a => a.href === cl.index.href)) indexCandidates.push(cl.index);
if (links.index && links.index.__fromVars && isValidHref(links.index.href) && !indexCandidates.some(a => a.href === links.index.href)) {
indexCandidates.push(links.index);
}
if (indexCandidates.length > 0) {
indexCandidates.sort((a, b) => {
const aHit = INDEX_URL_HINTS.test(a.href) ? 0 : 1;
const bHit = INDEX_URL_HINTS.test(b.href) ? 0 : 1;
return aHit - bHit;
});
const best = indexCandidates[0];
if (best && best.href && isValidHref(best.href)) {
if (!links.index || links.index.href !== best.href) {
console.log(`[阅读模式] 目录优选:主目录 ${links.index ? links.index.href : '(无)'} → ${best.href}`);
}
links.index = best;
}
}
links.indexCandidates = indexCandidates.map(a => a.href);
} catch(e) {}
const isParentPath = (href) => {
if (!href) return false;
try {
const link = new URL(href, window.location.href);
const cur = new URL(window.location.href);
if (link.origin !== cur.origin) return false;
const toDir = p => p.replace(/\/[^/]*$/, '/').replace(/\/+$/, '/');
return link.pathname.replace(/\/+$/, '/') === toDir(cur.pathname);
} catch(e) { return false; }
};
['prev', 'next'].forEach(key => {
const l = links[key];
if (l && isParentPath(l.href)) {
if (!links.index) links.index = l;
links[key] = null;
}
});
function isSameChapterUrl(curHref, tgtHref) {
if (!curHref || !tgtHref) return false;
try {
const cur = new URL(curHref);
const tgt = new URL(tgtHref);
if (cur.origin !== tgt.origin) return false;
const curNums = cur.pathname.match(/\d+/g) || [];
const tgtNums = tgt.pathname.match(/\d+/g) || [];
if (curNums.length === 0 || tgtNums.length === 0) return false;
if (curNums.length === tgtNums.length && curNums.every((n, i) => n === tgtNums[i])) return true;
if (tgtNums.length > curNums.length) {
const suffix = tgtNums.slice(-curNums.length);
if (suffix.every((n, i) => n === curNums[i])) return true;
}
return false;
} catch(e) { return false; }
}
let curHrefForDedup = window.location.href;
try {
if (doc && doc !== document && doc.defaultView && doc.defaultView.location) {
curHrefForDedup = doc.defaultView.location.href;
}
} catch(e) {}
const indexKeyForDedup = (links.index && links.index.href) ? normalizeUrl(links.index.href) : null;
['prev', 'next'].forEach(key => {
const l = links[key];
if (!l || !l.href) return;
if (isSameChapterUrl(curHrefForDedup, l.href)) {
links[key] = null;
return;
}
if (indexKeyForDedup && normalizeUrl(l.href) === indexKeyForDedup) {
links[key] = null;
}
});
return links;
}
function extractContent(doc) {
doc = doc || document;
let contentElement = null;
let fromSelector = false;
for (const s of CONTENT_SELECTORS) {
let els;
try { els = doc.querySelectorAll(s); } catch(e) { continue; }
if (els.length) {
let max = els[0], maxLen = els[0].innerHTML.length;
for (let i = 1; i < els.length; i++) {
const len = els[i].innerHTML.length;
if (len > maxLen) { maxLen = len; max = els[i]; }
}
contentElement = max;
fromSelector = true;
console.log(`[阅读模式] 使用选择器 "${s}"`);
break;
}
}
if (!contentElement) {
console.log('[阅读模式] 白名单未命中,启用评分器');
contentElement = findContentByScoring(doc);
fromSelector = false;
}
if (!contentElement) return null;
const autoHasIndent = detectIndent(contentElement);
const hiddenPaths = collectHiddenPaths(contentElement);
const clone = contentElement.cloneNode(true);
removeByPaths(clone, hiddenPaths);
pruneCandidates(clone);
cleanContent(clone, autoHasIndent);
let title = '';
let titleFromSelector = false;
for (const s of TITLE_SELECTORS) {
const el = doc.querySelector(s);
if (el && el.textContent.trim()) {
if (el.querySelector('a')) {
const cloneEl = el.cloneNode(true);
cloneEl.querySelectorAll('a').forEach(a => a.remove());
title = cloneEl.textContent.replace(/^[\s>»›\-—::]+/, '').trim();
} else title = el.textContent.trim();
titleFromSelector = true;
console.log(`[阅读模式] 使用标题选择器 "${s}" 匹配到标题: "${title}"`);
break;
}
}
if (!title) { title = doc.title || ''; console.log(`[阅读模式] 使用网页标题: "${title}"(不参与正文去重)`); }
if (titleFromSelector) removeDuplicateTitle(clone, title);
const html = clone.innerHTML.trim();
const textContent = clone.textContent.trim();
if (!html || !textContent || textContent.length <= MIN_CONTENT_LENGTH) return null;
const nav = findNavLinks(doc);
return { title, content: html, nav, autoHasIndent, fromSelector };
}
function addReaderModeHash(url) {
try {
const u = new URL(url, window.location.origin);
u.hash = 'readermode';
return u.href;
} catch(e) {
return (url || '').split('#')[0] + '#readermode';
}
}
function stripReaderModeHash(url) {
try {
const u = new URL(url, window.location.origin);
u.hash = '';
return u.href;
} catch(e) {
return (url || '').split('#')[0];
}
}
function isDarkColor(bg) {
if (!bg) return false;
let r, g, b;
if (bg.startsWith('#') && (bg.length === 7 || bg.length === 4)) {
let hex = bg.slice(1);
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
r = parseInt(hex.slice(0, 2), 16);
g = parseInt(hex.slice(2, 4), 16);
b = parseInt(hex.slice(4, 6), 16);
} else if (bg.startsWith('rgb')) {
const m = bg.match(/\d+/g);
if (!m || m.length < 3) return false;
r = parseInt(m[0], 10); g = parseInt(m[1], 10); b = parseInt(m[2], 10);
} else return false;
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5;
}
const getDividerColor = actualBg => isDarkColor(actualBg) ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)';
function calculateColors(bg, isNight) {
if (isNight) {
return {
textColor: NIGHT_THEME.textColor, borderColor: NIGHT_THEME.borderColor,
controlBgColor: NIGHT_THEME.controlBgColor, controlBorderColor: NIGHT_THEME.controlBorderColor,
subtleTextColor: NIGHT_THEME.subtleTextColor, exitBtnColor: NIGHT_THEME.exitBtnColor,
navBtnColor: NIGHT_THEME.navBtnColor, codeBg: NIGHT_THEME.codeBg,
codeBorder: NIGHT_THEME.codeBorder, codeTextColor: NIGHT_THEME.codeTextColor,
inlineCodeBg: NIGHT_THEME.inlineCodeBg, tableHeaderBg: NIGHT_THEME.tableHeaderBg,
tableBorder: NIGHT_THEME.tableBorder
};
}
const dark = isDarkColor(bg);
return {
textColor: dark ? '#e0e0e0' : '#000',
borderColor: getDividerColor(bg),
controlBgColor: bg,
controlBorderColor: dark ? '#555' : '#ddd',
subtleTextColor: dark ? '#aaa' : '#666',
exitBtnColor: dark ? '#999' : '#666',
navBtnColor: dark ? '#4a9eff' : '#007bff',
codeBg: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)',
codeBorder: dark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)',
codeTextColor: dark ? '#e6edf3' : '#24292e',
inlineCodeBg: dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.06)',
tableHeaderBg: dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
tableBorder: dark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)'
};
}
const getEffectiveBgColor = (userBg, isNight) => isNight ? NIGHT_THEME.bgColor : userBg;
const getAccentColor = (userBg, isNight) => isNight ? NIGHT_THEME.navBtnColor : (isDarkColor(userBg) ? '#4a9eff' : '#007bff');
const getIndentRule = autoHasIndent => autoHasIndent
? '.reader-chapter-content p, .reader-chapter-content li{text-indent:2em;}'
: '.reader-chapter-content p, .reader-chapter-content li{text-indent:0;}';
function buildContentStyleCSS(userBg, autoHasIndent, isNight) {
const colors = calculateColors(userBg, isNight);
const indentRule = getIndentRule(autoHasIndent);
return `
.reader-chapter-content p,
.reader-chapter-content div,
.reader-chapter-content h1, .reader-chapter-content h2, .reader-chapter-content h3,
.reader-chapter-content h4, .reader-chapter-content h5, .reader-chapter-content h6,
.reader-chapter-content li,
.reader-chapter-content blockquote,
.reader-chapter-content pre,
.reader-chapter-content table,
.reader-chapter-content td,
.reader-chapter-content th {
max-width: 100%;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-all;
box-sizing: border-box;
}
.reader-chapter-content p {
text-align: justify;
margin: 0 0 1.8em 0;
white-space: normal !important;
}
${indentRule}
.reader-chapter-content p[align] {
text-indent: 0;
}
.reader-chapter-content p[align="left"] { text-align: left; }
.reader-chapter-content p[align="center"] { text-align: center; }
.reader-chapter-content p[align="right"] { text-align: right; }
.reader-chapter-content img { max-width: 100%; height: auto; }
.reader-chapter-content img[data-emoji] {
display: inline; max-width: none;
width: 1.2em; height: 1.2em; vertical-align: -0.15em;
}
.reader-chapter-content p > img:only-child,
.reader-chapter-content div > img:only-child {
display: block;
margin: 1.2em auto;
}
.reader-chapter-content blockquote {
margin-left: 0; padding-left: 1.2em;
border-left: 3px solid ${colors.borderColor};
color: ${colors.subtleTextColor};
}
.reader-chapter-content a { color: ${colors.navBtnColor}; text-decoration: none; cursor: pointer; }
.reader-chapter-content a:hover { text-decoration: none; }
.reader-chapter-content strong, .reader-chapter-content b { font-weight: bold; }
.reader-chapter-content em, .reader-chapter-content i { font-style: italic; }
.reader-chapter-content h1, .reader-chapter-content h2, .reader-chapter-content h3,
.reader-chapter-content h4, .reader-chapter-content h5, .reader-chapter-content h6 {
font-weight: bold; margin-top: 1.2em; margin-bottom: 0.6em;
}
.reader-chapter-content h1 { font-size: 1.8em; }
.reader-chapter-content h2 { font-size: 1.5em; margin-top: 2em; margin-bottom: 0.8em; }
.reader-chapter-content h3 { font-size: 1.3em; }
.reader-chapter-content ul, .reader-chapter-content ol {
padding-left: 0; margin: 0.5em 0; list-style: none;
}
.reader-chapter-content li {
display: block; list-style: none; margin: 0.2em 0;
padding-left: 1.2em; position: relative;
}
.reader-chapter-content ul > li::before {
content: ''; position: absolute; left: 0.2em; top: 0.85em;
width: 5px; height: 5px; border-radius: 50%;
background: currentColor;
}
.reader-chapter-content code {
background: ${colors.inlineCodeBg}; border-radius: 3px;
padding: 0.15em 0.4em;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em; color: ${colors.codeTextColor};
}
.reader-chapter-content pre {
background: ${colors.codeBg}; border: 1px solid ${colors.codeBorder};
border-radius: 6px; padding: 12px 16px; overflow-x: auto;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em; line-height: 1.6; white-space: pre;
margin: 0 0 1.8em 0;
}
.reader-chapter-content pre code {
background: none; border: none; padding: 0; border-radius: 0;
font-size: inherit; color: inherit;
}
.reader-chapter-content table {
border-collapse: collapse; width: 100%; margin: 1em 0;
}
.reader-chapter-content th,
.reader-chapter-content td {
border: 1px solid ${colors.tableBorder}; padding: 6px 10px; text-align: left;
}
.reader-chapter-content th { background: ${colors.tableHeaderBg}; font-weight: 600; }
.reader-chapter-block + .reader-chapter-block {
margin-top: 60px;
}
`;
}
function chineseNumToArabic(s) {
if (!s) return null;
if (/^\d+$/.test(s)) return parseInt(s, 10);
const digitMap = { '零': 0, '一': 1, '二': 2, '两': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9 };
const unitMap = { '十': 10, '百': 100, '千': 1000 };
let total = 0, cur = 0;
for (const ch of s) {
if (digitMap[ch] !== undefined) cur = cur * 10 + digitMap[ch];
else if (unitMap[ch] !== undefined) {
const u = unitMap[ch];
if (cur === 0) cur = 1;
total += cur * u; cur = 0;
}
}
total += cur;
return total > 0 ? total : null;
}
function extractNumFromTitle(text) {
if (!text) return null;
const m = text.match(/第\s*([0-90-9]+|[一二三四五六七八九十百千万零两]+)\s*[章节回卷话集]/);
return m ? chineseNumToArabic(m[1]) : null;
}
const PRE_CHAPTER_RE = /^(楔子|序章|序言|序篇|序曲|序幕|引子|引言|前言|开篇|题记|prologue|プロローグ)/i;
function isPreChapterText(text) {
if (!text) return false;
const t = (text || '').replace(/[\s\u3000【】\[\]()()::、,.,。·\-—_~~!!??]+/g, '').toLowerCase();
if (!t || t.length > 6) return false;
return PRE_CHAPTER_RE.test(t);
}
function liftPreChapters(links) {
if (!links || links.length < 2) return links;
const pre = [];
const rest = [];
for (const l of links) {
if (l && !l.__missing && isPreChapterText(l.text)) pre.push(l);
else rest.push(l);
}
if (pre.length === 0) return links;
return pre.concat(rest);
}
function sortChapterLinksByOrder(links, extractNumFromHref) {
if (!links || links.length < 2) return links;
let titleHit = 0, total = 0;
const titleNums = new Map();
for (const l of links) {
if (l.__missing) continue;
total++;
const n = extractNumFromTitle(l.text);
if (n !== null) { titleNums.set(l, n); titleHit++; }
}
const titleReliable = total > 0 && titleHit >= total * 0.8;
const entries = [];
for (let i = 0; i < links.length; i++) {
const l = links[i];
if (l.__missing) {
if (l._num !== null && l._num !== undefined) entries.push({ pos: i, key: l._num, item: l });
continue;
}
if (titleReliable) {
const tn = titleNums.get(l);
if (tn !== null && tn !== undefined) entries.push({ pos: i, key: tn, item: l });
} else {
const un = extractNumFromHref(l.href);
if (un !== null && un !== undefined) entries.push({ pos: i, key: un, item: l });
}
}
if (entries.length < 2) return links;
const sorted = entries.slice().sort((a, b) => a.key - b.key);
let changed = 0;
for (let i = 0; i < entries.length; i++) {
if (links[entries[i].pos] !== sorted[i].item) changed++;
links[entries[i].pos] = sorted[i].item;
}
if (changed > 0) console.log(`[阅读模式] 目录排序:${entries.length} 项带编号章节已按编号排列(无编号章节原位不动)`);
return links;
}
function extractMissingChapterItems(doc, indexUrl, existingNums, minNum, maxNum) {
if (!doc) return [];
const results = [];
const seenNums = new Set();
const titleRe = /第\s*(?:[0-90-9]+|[一二三四五六七八九十百千万零两]+)\s*[章节回卷话集]/;
doc.querySelectorAll('li, dd').forEach(el => {
if (el.querySelector('a[href]')) return;
const text = (el.textContent || '').trim();
if (!text || text.length < 2 || text.length > 80) return;
if (!titleRe.test(text)) return;
const num = extractNumFromTitle(text);
if (num === null) return;
if (num < minNum || num > maxNum) return;
if (existingNums.has(num)) return;
if (seenNums.has(num)) return;
seenNums.add(num);
results.push({ href: null, text, __missing: true, _num: num });
});
return results;
}
function extractChapterLinksFromDoc(doc, indexUrl) {
if (!doc) return [];
const containerSelectors = [
'ul.chapter', '#chapterlist', '.chapterlist', '#list', '.listmain',
'.chapter', '#chapter', '.book_list', '.booklist', '.volume',
'#chapter-list', '.chapter-list', '.mulu', '#mulu', '.catalog',
'.catalog-list', '#readerlist', '.list-chapter', '#list-chapter',
'dl#list', 'dl.chapterlist', '.section-list', '.chapters',
'#jsList1', '.list_li', '.lb_mulu', '.chapterList',
'#volumes', '.volume-chapters'
];
function findBestContainer(root) {
let container = null, maxLinks = 0;
for (const sel of containerSelectors) {
try {
const els = root.querySelectorAll(sel);
for (const el of els) {
const count = el.querySelectorAll('a').length;
if (count > maxLinks) { maxLinks = count; container = el; }
}
} catch(e) {}
}
return { container, maxLinks };
}
let basePath = '', indexOrigin = '';
try {
const u = new URL(indexUrl);
indexOrigin = u.origin;
basePath = u.pathname.replace(/index(_\d+)?\.html?$/i, '');
if (!basePath.endsWith('/')) basePath = basePath.replace(/[^/]+$/, '');
} catch(e) {}
const excludeTexts = [
'上一页', '下一页', '上一章', '下一章', '首页', '返回', '目录', '书架', '顶部', '底部',
'开始阅读', '加入书架', '存书签', '我的书架', '阅读记录', '回到顶部', '章节目录',
'返回目录', '返回书页', '直达底部', '点击阅读', '换手', '关灯', '倒序', '字体', '大', '中', '小',
'正序', '升序', '降序', '顺序', '逆序', '排序', '展开', '收起',
'展开全部', '收起全部', '全部章节', '完整目录', '查看全部', '显示全部',
'圈子', '排行榜', '文库', '完本', '账户', '反馈', '帮助', '繁體', '客户端'
];
const DOWNLOAD_TEXT_RE = /下载|TXT|电子书|全本|手机阅读|epub|mobi/i;
const DOWNLOAD_LAST_SEG_RE = /^(?:txt|down|download|dload|xiazai|epub|mobi|pdf|zip|rar)(?:\.html?)?$/i;
const VOLUME_URL_RE = /(?:^|\/)(?:vol|volume)[-_]?\d+(?:\.html?)?\/?$/i;
const VOLUME_PARENT_CLS_RE = /(^|[\s_-])(chapter-bar|volume-cover|vol-cover|volume-header|vol-header|book-cover)([\s_-]|$)/i;
function isChapterCandidate(a) {
const href = a.getAttribute('href');
if (!href || href === '#' || href.startsWith('javascript:')) return false;
try {
const absObj = new URL(href, indexUrl);
if (VOLUME_URL_RE.test(absObj.pathname)) return false;
} catch(e) {}
if (a.closest) {
const parentLi = a.closest('li');
if (parentLi) {
const pcls = ((parentLi.className || '') + ' ' + (parentLi.id || '')).trim();
if (pcls && VOLUME_PARENT_CLS_RE.test(pcls)) return false;
}
}
const text = (a.textContent || '').trim();
if (!text || text.length > 80 || text.length < 2) return false;
const normText = text.replace(/^[\[【((]+/, '').replace(/[\]】))]+$/, '').trim();
if (excludeTexts.some(w => text === w || normText === w)) return false;
if (/^\d{1,4}$/.test(text)) return false;
if (DOWNLOAD_TEXT_RE.test(text)) return false;
try {
const absObj = new URL(href, indexUrl);
const lastSeg = absObj.pathname.split('/').pop() || '';
if (DOWNLOAD_LAST_SEG_RE.test(lastSeg)) return false;
} catch(e) {}
return true;
}
function toAbs(a) {
const href = a.getAttribute('href');
try { return new URL(href, indexUrl).href; } catch(e) { return null; }
}
function extractNumericKey(u) {
try {
const urlObj = new URL(u);
const m = urlObj.pathname.match(/(\d+)(?=\.html?$)/i);
if (m) return parseInt(m[1], 10);
const m2 = urlObj.pathname.match(/(\d+)/g);
if (m2 && m2.length) return parseInt(m2[m2.length - 1], 10);
} catch(e) {}
return null;
}
function scoreOfText(t) {
if (!t) return 0;
let s = 0;
if (/第\s*[\d一二三四五六七八九十百千万零两]+\s*[章节回卷话集]/.test(t)) s += 100;
if (/chapter\s*\d+/i.test(t)) s += 100;
if (/\d/.test(t)) s += 10;
s += Math.min(t.length, 60);
return s;
}
function pathSig(href) {
try {
const u = new URL(href);
const p = u.pathname.replace(/\d+/g, '#').replace(/\/+/g, '/');
return p + (u.search ? '?' + u.search.replace(/\d+/g, '#') : '');
} catch(e) { return href; }
}
function clusterByPathPattern(links) {
if (!links || links.length < 5) return links;
const groups = new Map();
for (const l of links) {
if (!l || l.__missing) continue;
const s = pathSig(l.href);
let arr = groups.get(s);
if (!arr) { arr = []; groups.set(s, arr); }
arr.push(l);
}
if (groups.size <= 1) return links;
let bestPattern = null, bestSize = 0;
for (const [s, arr] of groups) { if (arr.length > bestSize) { bestSize = arr.length; bestPattern = s; } }
if (bestSize >= 5 && bestSize >= links.length * 0.6) {
const kept = links.filter(l => l.__missing || pathSig(l.href) === bestPattern);
if (kept.length < links.length) console.log(`[阅读模式] 路径模式聚类:${links.length} → ${kept.length} 条`);
return kept;
}
return links;
}
function finalizeResult(result) {
if (!result.length) return result;
result = clusterByPathPattern(result);
const existingNums = new Set();
for (const r of result) {
const n = extractNumericKey(r.href);
if (n !== null) existingNums.add(n);
}
if (existingNums.size >= 5) {
const numsArr = Array.from(existingNums).sort((a, b) => a - b);
const minN = numsArr[0], maxN = numsArr[numsArr.length - 1];
try {
const missing = extractMissingChapterItems(doc, indexUrl, existingNums, minN, maxN);
if (missing.length > 0) for (const m of missing) result.push(m);
} catch(e) {}
}
result = liftPreChapters(result);
sortChapterLinksByOrder(result, extractNumericKey);
return result;
}
if (basePath && indexOrigin) {
const normBase = basePath.endsWith('/') ? basePath : basePath + '/';
const { container: preContainer, maxLinks: preMax } = findBestContainer(doc);
const scanRoot = (preContainer && preMax >= 5) ? preContainer : doc;
if (scanRoot !== doc) console.log(`[阅读模式] 路径前缀策略:在容器内扫描(${preMax} 条 a)`);
const allLinks = Array.from(scanRoot.querySelectorAll('a[href]'));
const seen = new Map();
const result = [];
for (const a of allLinks) {
if (!isChapterCandidate(a)) continue;
const abs = toAbs(a); if (!abs) continue;
let absObj;
try { absObj = new URL(abs); } catch(e) { continue; }
if (absObj.origin !== indexOrigin) continue;
if (!absObj.pathname.startsWith(normBase)) continue;
if (/index(_\d+)?\.html?$/i.test(absObj.pathname)) continue;
if (absObj.pathname === normBase || absObj.pathname === basePath) continue;
const text = (a.textContent || '').trim();
if (seen.has(abs)) {
const idx = seen.get(abs);
if (scoreOfText(text) > scoreOfText(result[idx].text)) result[idx].text = text;
continue;
}
seen.set(abs, result.length);
result.push({ href: abs, text });
}
if (result.length >= 5) {
console.log(`[阅读模式] 目录:基于路径前缀抓取到 ${result.length} 条章节链接`);
return finalizeResult(result);
}
}
let { container, maxLinks } = findBestContainer(doc);
if (!container || maxLinks < 5) {
const candidates = Array.from(doc.querySelectorAll('ul, ol, div, article, section, dl, table'));
let best = null, bestScore = 0;
for (const c of candidates) {
const links = c.querySelectorAll('a[href]');
if (links.length < 5) continue;
let score = 0;
for (const a of links) {
const href = a.getAttribute('href') || '';
if (basePath && href.startsWith(basePath)) score += 3;
else score += 1;
}
if (score > bestScore) { bestScore = score; best = c; }
}
if (best) container = best;
}
const rawLinks = container ? Array.from(container.querySelectorAll('a')) : Array.from(doc.querySelectorAll('a'));
const seen = new Map();
const result = [];
for (const a of rawLinks) {
if (!isChapterCandidate(a)) continue;
const abs = toAbs(a); if (!abs) continue;
const text = (a.textContent || '').trim();
if (seen.has(abs)) {
const idx = seen.get(abs);
if (scoreOfText(text) > scoreOfText(result[idx].text)) result[idx].text = text;
continue;
}
seen.set(abs, result.length);
result.push({ href: abs, text });
}
if (basePath && result.length > 0) {
const sameDir = result.filter(item => {
try { return new URL(item.href).pathname.startsWith(basePath); } catch(e) { return false; }
});
if (sameDir.length >= 5 && sameDir.length >= result.length * 0.5) {
console.log(`[阅读模式] 目录同路径过滤:${sameDir.length}/${result.length} 条`);
return finalizeResult(sameDir);
}
}
return finalizeResult(result);
}
function safeGetIframeDoc(iframe) {
try {
const doc = iframe.contentDocument;
if (doc) return { doc, error: null };
if (iframe.contentWindow) return { doc: iframe.contentWindow.document, error: null };
return { doc: null, error: 'empty' };
} catch (e) {
return { doc: null, error: e.name === 'SecurityError' ? 'cross-origin' : 'unknown' };
}
}
async function fetchDocTemp(url, timeoutMs) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs || 10000);
try {
const resp = await fetch(url, { credentials: 'include', signal: controller.signal });
clearTimeout(t);
if (!resp.ok) return null;
const html = await resp.text();
if (!html || html.length < 100) return null;
return new DOMParser().parseFromString(html, 'text/html');
} catch (e) {
clearTimeout(t);
return null;
}
}
function loadDocTemp(url, timeoutMs) {
return new Promise((resolve) => {
const iframe = createSandboxIframe(url);
let done = false;
const cleanup = (result) => {
if (done) return;
done = true;
try { if (iframe.parentNode) iframe.remove(); } catch(e) {}
resolve(result);
};
const t = setTimeout(() => {
fetchDocTemp(url, timeoutMs).then((doc) => cleanup(doc)).catch(() => cleanup(null));
}, timeoutMs || 12000);
iframe.onload = () => {
clearTimeout(t);
const result = safeGetIframeDoc(iframe);
if (result.doc && result.doc.body && result.doc.querySelectorAll('a').length > 0) {
cleanup(result.doc);
} else {
fetchDocTemp(url, timeoutMs).then((doc) => cleanup(doc)).catch(() => cleanup(null));
}
};
document.body.appendChild(iframe);
});
}
function fetchChapterArticleViaIframe(url, timeoutMs) {
return new Promise((resolve) => {
const iframe = createSandboxIframe(url);
let done = false;
let pollTimer = null;
const cleanup = (article) => {
if (done) return;
done = true;
if (pollTimer) clearTimeout(pollTimer);
try { if (iframe.parentNode) iframe.remove(); } catch(e) {}
resolve(article);
};
const timeout = setTimeout(() => cleanup(null), timeoutMs || 12000);
iframe.onload = () => {
clearTimeout(timeout);
const result = safeGetIframeDoc(iframe);
if (!result.doc) { cleanup(null); return; }
let pollCount = 0;
const MAX_POLL = 15, INTERVAL = 200;
const tryExtract = () => {
if (done) return;
let article = null;
try { article = extractContent(result.doc); } catch(e) {}
if (article) { cleanup(article); return; }
pollCount++;
if (pollCount < MAX_POLL) pollTimer = setTimeout(tryExtract, INTERVAL);
else cleanup(null);
};
tryExtract();
};
document.body.appendChild(iframe);
});
}
function isSameChapterPager(currentUrl, nextUrl) {
if (!currentUrl || !nextUrl) return false;
try {
const cur = new URL(currentUrl);
const nxt = new URL(nextUrl);
if (cur.origin !== nxt.origin) return false;
const curPath = cur.pathname;
const nxtPath = nxt.pathname;
const curBase = curPath.match(/^(.*\/)(\d+)(?:_(\d+))?\.html?$/i);
const nxtBase = nxtPath.match(/^(.*\/)(\d+)_(\d+)\.html?$/i);
if (!curBase || !nxtBase) return false;
if (curBase[1] !== nxtBase[1]) return false;
if (curBase[2] !== nxtBase[2]) return false;
const curPage = curBase[3] ? parseInt(curBase[3], 10) : 1;
const nxtPage = parseInt(nxtBase[3], 10);
return nxtPage === curPage + 1;
} catch(e) { return false; }
}
async function fetchChapterArticle(url, depth) {
depth = depth || 0;
if (depth > 6) return null;
let article = await fetchChapterArticleViaIframe(url, 12000);
if (!article) {
console.log('[阅读模式] iframe 通道未取到正文,fetch 兜底:', url);
const doc = await fetchDocTemp(url, 12000);
if (!doc) return null;
try { article = extractContent(doc); } catch(e) { return null; }
if (!article) return null;
}
if (article.nav && article.nav.next && isSameChapterPager(url, article.nav.next.href)) {
const nextArticle = await fetchChapterArticle(article.nav.next.href, depth + 1);
if (nextArticle) {
return {
...article,
content: article.content + nextArticle.content,
nav: {
prev: article.nav.prev,
next: nextArticle.nav.next,
index: nextArticle.nav.index || article.nav.index,
indexCandidates: nextArticle.nav.indexCandidates || article.nav.indexCandidates
}
};
}
}
return article;
}
function preloadNextChapter(url) {
if (!url) return;
const cacheKey = normalizeUrl(url);
if (chapterCache.has(cacheKey)) return;
if (currentPreloadTask && normalizeUrl(currentPreloadTask.url) === cacheKey) return;
cancelCurrentPreload();
const task = { url, cancelled: false };
currentPreloadTask = task;
(async () => {
try {
const article = await fetchChapterArticle(url);
if (task.cancelled) return;
if (article) {
chapterCache.set(cacheKey, { article, timestamp: Date.now() });
if (chapterCache.size > 2) {
const firstKey = chapterCache.keys().next().value;
chapterCache.delete(firstKey);
}
}
} catch(e) {}
if (currentPreloadTask === task) currentPreloadTask = null;
})();
}
function cancelCurrentPreload() {
if (!currentPreloadTask) return;
currentPreloadTask.cancelled = true;
currentPreloadTask = null;
}
function schedulePreloadNext(overlay) {
if (preloadScheduleTimer) { clearTimeout(preloadScheduleTimer); preloadScheduleTimer = null; }
preloadScheduleTimer = setTimeout(() => {
preloadScheduleTimer = null;
const nav = overlay.__nav;
if (!nav || !nav.next) return;
preloadNextChapter(nav.next.href);
}, 500);
}
function preloadCatalog(indexUrl, overlay) {
if (catalogCache && catalogCache.url === indexUrl) return;
if (catalogCacheLoading && catalogCacheLoading.url === indexUrl) return;
catalogCacheLoading = { url: indexUrl };
let finalized = false;
const finalize = (links) => {
if (finalized) return;
finalized = true;
catalogCache = { url: indexUrl, links: links || [] };
catalogCacheLoading = null;
if (links && links.length > 0) console.log(`[阅读模式] 目录预加载成功,共 ${links.length} 条章节链接`);
else console.warn('[阅读模式] 目录预加载失败: 未抓取到章节链接');
if (overlay) overlay.dispatchEvent(new CustomEvent('reader-catalog-ready'));
};
const hardTimeout = setTimeout(() => finalize([]), 60000);
function extractNum(u) {
try {
const p = new URL(u).pathname;
const m = p.match(/\/(\d+)\.html?$/i) || p.match(/(\d+)(?=\.html?$)/i);
return m ? parseInt(m[1], 10) : null;
} catch(e) { return null; }
}
function findPagerUrls(doc, baseUrl) {
if (!doc) return [];
const found = new Set();
const baseKey = normalizeUrl(baseUrl);
const pathBase = (() => {
try {
const u = new URL(baseUrl);
const p = u.pathname;
if (p.endsWith('/')) return p;
return p.replace(/[^/]+$/, '');
} catch(e) { return '/'; }
})();
const consider = (absUrl) => {
const abs = absUrl.href;
if (normalizeUrl(abs) === baseKey) return;
if (!absUrl.pathname.startsWith(pathBase)) return;
const path = absUrl.pathname, search = absUrl.search;
if (/\/index_\d+\.html?$/i.test(path) || /\/page[-_]?\d+\.html?$/i.test(path) || /\/list[-_]?\d+\.html?$/i.test(path) || /[?&]page=\d+/i.test(search)) found.add(abs);
};
const tryRoot = (root) => {
if (!root || !root.querySelectorAll) return;
root.querySelectorAll('a[href]').forEach(a => {
const href = a.getAttribute('href');
if (!href || href === '#' || href.startsWith('javascript:')) return;
if (!isValidHref(href)) return;
try { consider(new URL(href, baseUrl)); } catch(e) {}
});
root.querySelectorAll('select option[value]').forEach(opt => {
const href = opt.value;
if (!href || href === '#' || href.startsWith('javascript:')) return;
if (!isValidHref(href)) return;
try { consider(new URL(href, baseUrl)); } catch(e) {}
});
};
const containers = ['.pages', '.pagination', '.page-list', '.pagelist', '.pager', '.page-nav', '.page_nav', 'nav.pagination', '.listpage', '.paginate', '.wp-pagenavi', '#pages', '#pagination', '#pagenavi', '#pagelink', '.pagelink', '.page'];
let hitInContainer = 0;
for (const sel of containers) {
try {
doc.querySelectorAll(sel).forEach(el => {
const before = found.size;
tryRoot(el);
if (found.size > before) hitInContainer++;
});
} catch(e) {}
}
if (hitInContainer === 0 && doc.body) tryRoot(doc.body);
return Array.from(found);
}
function detectExpandNeed(doc) {
const partialContainers = ['.catalog-all', '.all-chapter', '.show-all', '.show-more', '.more-chapter', '.cfirst', '.latest', '.recent', '.newest', '.chapter-more', '[class*="cfirst"]', '[class*="latest"]', '[class*="recent"]', '[class*="newest"]', '[class*="all-chapter"]', '[class*="show-all"]'];
let hasPartialContainer = false;
for (const sel of partialContainers) { try { if (doc.querySelector(sel)) { hasPartialContainer = true; break; } } catch(e) {} }
const triggerRe = /查看完整|完整目录|完整章节|全部章节|全部目录|展开目录|展开全部|展开更多|显示全部|显示更多|显示完整|加载更多|更多章节|更多目录|查看全部|点击展开|查看所有|所有章节|全部章節|展開目錄|展開全部|加載更多|更多章節|顯示全部/;
const triggerEls = [];
const seen = new Set();
doc.querySelectorAll('a, button, div, span, li, input[type="button"], input[type="submit"]').forEach(el => {
if (seen.has(el)) return;
const t = (el.textContent || '').trim();
if (t && t.length <= 30 && triggerRe.test(t)) { triggerEls.push(el); seen.add(el); }
});
for (const sel of partialContainers) {
try { doc.querySelectorAll(sel).forEach(el => { if (!seen.has(el)) { triggerEls.push(el); seen.add(el); } }); } catch(e) {}
}
return { needsExpand: hasPartialContainer || triggerEls.length > 0, triggerEls };
}
function dispatchRealClick(el) {
if (!el || !el.dispatchEvent) return;
const doc = el.ownerDocument;
const win = doc && doc.defaultView;
const base = { bubbles: true, cancelable: true, view: win || undefined };
try { el.dispatchEvent(new PointerEvent('pointerdown', base)); } catch(e) {}
try { el.dispatchEvent(new MouseEvent('mousedown', base)); } catch(e) {}
try { el.dispatchEvent(new PointerEvent('pointerup', base)); } catch(e) {}
try { el.dispatchEvent(new MouseEvent('mouseup', base)); } catch(e) {}
try { el.click(); } catch(e) {}
}
async function runPagerStrategy(pagerUrls, initialLinks) {
const allUrls = [indexUrl];
const seenUrls = new Set([normalizeUrl(indexUrl)]);
for (const u of pagerUrls) {
const k = normalizeUrl(u);
if (!seenUrls.has(k)) { seenUrls.add(k); allUrls.push(u); }
}
const collectedLinks = [];
const seenHrefs = new Map();
const pushLinks = (links) => {
for (const l of links) {
const k = l.__missing ? ('__missing__' + l._num) : normalizeUrl(l.href);
if (!seenHrefs.has(k)) { seenHrefs.set(k, collectedLinks.length); collectedLinks.push(l); }
}
};
pushLinks(initialLinks);
const MAX_CONCURRENT = 3;
let nextIdx = 1;
const workers = [];
for (let w = 0; w < MAX_CONCURRENT; w++) {
workers.push((async () => {
while (!finalized) {
const myIdx = nextIdx++;
if (myIdx >= allUrls.length) return;
const url = allUrls[myIdx];
const doc = await loadDocTemp(url, 12000);
if (finalized) return;
if (doc) {
try { pushLinks(extractChapterLinksFromDoc(doc, url)); } catch(e) {}
}
}
})());
}
await Promise.all(workers);
if (finalized) return;
sortChapterLinksByOrder(collectedLinks, extractNum);
clearTimeout(hardTimeout);
finalize(collectedLinks);
}
function runExpandStrategy(doc, triggerEls, iframe, initialLinks) {
const fireClicks = () => {
for (const el of triggerEls) {
let cur = el, depth = 0;
while (cur && depth < 3) { try { dispatchRealClick(cur); } catch(e) {} cur = cur.parentElement; depth++; }
}
};
fireClicks();
let pollCount = 0;
const MAX_POLLS = 28, POLL_INTERVAL = 250, RE_ACTION_EVERY = 2;
const tryExtract = () => {
if (finalized) return;
let newLinks = [];
try { newLinks = extractChapterLinksFromDoc(doc, indexUrl); } catch(e) {}
if (newLinks.length > initialLinks.length && newLinks.length >= 5) {
if (iframe && iframe.parentNode) iframe.remove();
clearTimeout(hardTimeout);
finalize(newLinks);
return;
}
pollCount++;
if (pollCount < MAX_POLLS) {
if (pollCount % RE_ACTION_EVERY === 0) fireClicks();
setTimeout(tryExtract, POLL_INTERVAL);
} else {
if (iframe && iframe.parentNode) iframe.remove();
clearTimeout(hardTimeout);
finalize(newLinks.length > initialLinks.length ? newLinks : initialLinks);
}
};
setTimeout(tryExtract, POLL_INTERVAL);
}
const firstIframe = createSandboxIframe(indexUrl);
let firstLoaded = false;
const runFallbackFetch = async () => {
if (finalized) return;
console.log('[阅读模式] iframe 加载失败或被拒,fetch 兜底');
const doc = await fetchDocTemp(indexUrl, 12000);
if (finalized) return;
if (!doc) { clearTimeout(hardTimeout); finalize([]); return; }
let firstLinks = [];
try { firstLinks = extractChapterLinksFromDoc(doc, indexUrl); } catch(e) {}
const pagerUrls = findPagerUrls(doc, indexUrl);
if (pagerUrls.length > 0) { runPagerStrategy(pagerUrls, firstLinks); return; }
clearTimeout(hardTimeout);
finalize(firstLinks);
};
const firstTimeout = setTimeout(() => {
if (!firstLoaded) {
firstLoaded = true;
if (firstIframe.parentNode) firstIframe.remove();
runFallbackFetch();
}
}, 15000);
firstIframe.onload = () => {
if (finalized) return;
firstLoaded = true;
clearTimeout(firstTimeout);
const result = safeGetIframeDoc(firstIframe);
if (!result.doc || !result.doc.body || result.doc.querySelectorAll('a').length === 0) {
if (firstIframe.parentNode) firstIframe.remove();
runFallbackFetch();
return;
}
const doc = result.doc;
let firstLinks = [];
try { firstLinks = extractChapterLinksFromDoc(doc, indexUrl); } catch(e) {}
const pagerUrls = findPagerUrls(doc, indexUrl);
if (pagerUrls.length > 0) {
if (firstIframe.parentNode) firstIframe.remove();
runPagerStrategy(pagerUrls, firstLinks);
return;
}
const { needsExpand, triggerEls } = detectExpandNeed(doc);
if (needsExpand && triggerEls.length > 0 && firstLinks.length < 30) {
runExpandStrategy(doc, triggerEls, firstIframe, firstLinks);
return;
}
if (firstIframe.parentNode) firstIframe.remove();
clearTimeout(hardTimeout);
finalize(firstLinks);
};
document.body.appendChild(firstIframe);
}
function cleanReaderContent(overlay) {
if (!overlay || !overlay.isConnected) return;
overlay.querySelectorAll('.reader-chapter-content').forEach(sec => cleanPageAds(sec));
}
function scheduleReaderContentClean(overlay) {
if (!overlay) return;
if (overlay.__cleanTimers) { overlay.__cleanTimers.forEach(t => clearTimeout(t)); }
overlay.__cleanTimers = [];
cleanReaderContent(overlay);
[100, 300, 800].forEach(t => {
const timer = setTimeout(() => {
if (overlay.isConnected) cleanReaderContent(overlay);
}, t);
overlay.__cleanTimers.push(timer);
});
}
function updateProgressBar(overlay, targetUrl) {
const thumb = overlay.querySelector('#reader-progress-thumb');
if (!thumb) return;
const container = thumb.parentNode;
const canDrag = catalogCache && catalogCache.links && catalogCache.links.length > 1;
if (container) {
container.style.cursor = canDrag ? 'pointer' : 'default';
container.style.opacity = canDrag ? '1' : '0.5';
}
if (catalogCacheLoading) return;
if (!catalogCache || !catalogCache.links || !catalogCache.links.length) { thumb.style.left = 'calc(100% - 9px)'; return; }
const total = catalogCache.links.length;
if (total <= 1) { thumb.style.left = 'calc(100% - 9px)'; return; }
const url = targetUrl || window.location.href;
const currentKey = normalizeUrl(url);
let currentIdx = -1;
for (let i = 0; i < total; i++) {
const l = catalogCache.links[i];
if (!l || l.__missing || !l.href) continue;
if (normalizeUrl(l.href) === currentKey) { currentIdx = i; break; }
}
if (currentIdx !== -1) {
const percent = (currentIdx / (total - 1)) * 100;
thumb.style.left = `calc(${percent}% - 9px)`;
return;
}
const extractNum = (u) => {
try {
const p = new URL(u).pathname;
const m = p.match(/\/(\d+)\.html?$/i) || p.match(/(\d+)(?=\.html?$)/i);
return m ? parseInt(m[1], 10) : null;
} catch(e) { return null; }
};
const curNum = extractNum(url);
let maxNum = 0;
for (const l of catalogCache.links) {
if (!l || l.__missing || !l.href) continue;
const n = extractNum(l.href);
if (n !== null && n > maxNum) maxNum = n;
}
if (curNum !== null && maxNum > 0 && curNum <= maxNum) {
thumb.style.left = `calc(${(curNum / maxNum) * 100}% - 9px)`;
return;
}
thumb.style.left = 'calc(0% - 9px)';
}
function removeCatalogImmediately(overlay) {
if (currentCatalogView) {
if (currentCatalogView.__pollInterval) { clearInterval(currentCatalogView.__pollInterval); currentCatalogView.__pollInterval = null; }
currentCatalogView.remove();
currentCatalogView = null;
}
const mask = overlay.querySelector('#reader-catalog-mask');
if (mask) mask.remove();
setIconBtnActive(overlay, 'catalog', false);
}
function removeSettingsImmediately(overlay) {
const panel = overlay.querySelector('#reader-settings-view');
const mask = overlay.querySelector('#reader-settings-mask');
if (panel) panel.remove();
if (mask) mask.remove();
setIconBtnActive(overlay, 'settings', false);
}
function renderCatalogLinks(container, links, colors, onLinkClick, currentUrl, isNight, order) {
container.innerHTML = '';
if (!links.length) {
const errorDiv = document.createElement('div');
errorDiv.style.cssText = `padding: 30px; text-align: center; color: ${colors.subtleTextColor}; font-size: 14px;`;
errorDiv.textContent = '未找到章节链接。';
container.appendChild(errorDiv);
return;
}
const curKey = normalizeUrl(currentUrl || window.location.href);
const displayLinks = (order === 'desc') ? links.slice().reverse() : links;
displayLinks.forEach(link => {
const isMissing = link.__missing === true;
const isCurrent = !isMissing && link.href && normalizeUrl(link.href) === curKey;
if (isMissing) {
const div = document.createElement('div');
div.dataset.missing = 'true';
div.style.cssText = `display: flex; align-items: center; gap: 8px; padding: 12px 12px; color: ${colors.subtleTextColor}; font-size: 15px; background: transparent; line-height: 1.5; font-style: italic; opacity: 0.62; cursor: not-allowed; -webkit-tap-highlight-color: transparent;`;
const iconSpan = document.createElement('span');
iconSpan.innerHTML = ICONS.missing;
iconSpan.style.cssText = `flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; color: ${colors.subtleTextColor};`;
div.appendChild(iconSpan);
const textSpan = document.createElement('span');
textSpan.textContent = link.text;
textSpan.style.cssText = 'flex: 1; overflow: hidden; text-overflow: ellipsis; text-decoration: line-through; text-decoration-thickness: 1px;';
div.appendChild(textSpan);
const tag = document.createElement('span');
tag.textContent = '缺失';
tag.style.cssText = `flex-shrink: 0; font-style: normal; font-size: 11px; color: ${colors.subtleTextColor}; border: 1px solid ${colors.borderColor}; border-radius: 4px; padding: 1px 6px; opacity: 0.8;`;
div.appendChild(tag);
div.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); notify('此章在原站缺失,无法阅读'); });
container.appendChild(div);
return;
}
const a = document.createElement('a');
a.href = link.href;
if (isCurrent) a.dataset.current = 'true';
a.style.cssText = `display: flex; align-items: center; gap: 8px; padding: 12px 12px; color: ${isCurrent ? colors.navBtnColor : colors.textColor}; text-decoration: none; font-size: 15px; font-weight: ${isCurrent ? '600' : 'normal'}; background: transparent; line-height: 1.5; transition: background 0.2s; -webkit-tap-highlight-color: transparent;`;
if (isCurrent) {
const cross = document.createElement('span');
cross.className = 'reader-current-crosshair';
cross.innerHTML = ICONS.crosshair;
cross.style.cssText = `flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; color: ${colors.navBtnColor};`;
a.appendChild(cross);
}
const textSpan = document.createElement('span');
textSpan.textContent = link.text;
textSpan.style.cssText = 'flex: 1; overflow: hidden; text-overflow: ellipsis;';
a.appendChild(textSpan);
a.addEventListener('mouseenter', () => a.style.background = colors.controlBgColor);
a.addEventListener('mouseleave', () => a.style.background = 'transparent');
if (onLinkClick) a.addEventListener('click', (e) => { e.preventDefault(); onLinkClick(link.href); });
container.appendChild(a);
});
}
function refreshCatalogColors(userBg, isNight) {
if (!currentCatalogView) return;
const actualBg = getEffectiveBgColor(userBg, isNight);
currentCatalogView.style.backgroundColor = actualBg;
const catalogColors = calculateColors(userBg, isNight);
currentCatalogView.querySelectorAll('a').forEach(a => {
const isCurrent = a.dataset.current === 'true';
a.style.color = isCurrent ? catalogColors.navBtnColor : catalogColors.textColor;
a.style.background = 'transparent';
const cross = a.querySelector('.reader-current-crosshair');
if (cross) cross.style.color = catalogColors.navBtnColor;
});
currentCatalogView.querySelectorAll('[data-missing="true"]').forEach(div => {
div.style.color = catalogColors.subtleTextColor;
const iconSpan = div.querySelector('span');
if (iconSpan) iconSpan.style.color = catalogColors.subtleTextColor;
const tag = div.querySelector('span:last-child');
if (tag) { tag.style.color = catalogColors.subtleTextColor; tag.style.borderColor = catalogColors.borderColor; }
});
const locateBtn = currentCatalogView.querySelector('#reader-catalog-locate');
if (locateBtn) { locateBtn.style.background = catalogColors.controlBgColor; locateBtn.style.color = catalogColors.navBtnColor; }
const sortBtn = currentCatalogView.querySelector('.reader-catalog-sort-btn');
if (sortBtn) { sortBtn.style.background = 'transparent'; sortBtn.style.color = catalogColors.subtleTextColor; }
const toolbarLabel = currentCatalogView.querySelector('.reader-catalog-toolbar-label');
if (toolbarLabel) toolbarLabel.style.color = catalogColors.subtleTextColor;
const headerSpan = currentCatalogView.querySelector('div > span');
if (headerSpan) headerSpan.style.color = catalogColors.textColor;
}
function showCatalog(indexUrl, overlay, onChapterNav) {
const existingCatalog = overlay.querySelector('#reader-catalog-view');
if (existingCatalog) {
if (typeof existingCatalog.__doClose === 'function') existingCatalog.__doClose();
return;
}
removeSettingsImmediately(overlay);
setIconBtnActive(overlay, 'catalog', true);
const isNight = overlay.__isNight === true;
const userBg = overlay.__userBgColor || gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor);
const actualBg = getEffectiveBgColor(userBg, isNight);
const colors = calculateColors(userBg, isNight);
const immersiveBottom = overlay.querySelector('#reader-immersive-bottom');
if (immersiveBottom) {
immersiveBottom.classList.add('above-drawer');
applyImmersiveBottomStyle(immersiveBottom, colors, immersiveBottom.dataset.style, true);
}
const mask = document.createElement('div');
mask.id = 'reader-catalog-mask';
mask.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); z-index: 1000; opacity: 0; transition: opacity 0.3s; touch-action: none;';
overlay.appendChild(mask);
const catalogView = document.createElement('div');
catalogView.id = 'reader-catalog-view';
catalogView.style.cssText = `position: fixed; bottom: 0; left: 0; width: 100%; height: 78%; background-color: ${actualBg}; z-index: 1001; border-radius: 16px 16px 0 0; display: flex; flex-direction: column; transform: translateY(100%); transition: transform 0.32s cubic-bezier(0.2, 0.8, 0.2, 1); box-sizing: border-box; box-shadow: 0 -4px 12px rgba(0,0,0,0.15); overflow: hidden; overscroll-behavior: contain;`;
const lineColor = getDividerColor(actualBg);
const header = document.createElement('div');
header.style.cssText = `display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; flex-shrink: 0; border-bottom: 1px solid ${lineColor};`;
const headerTitle = document.createElement('span');
headerTitle.style.cssText = `font-size: 16px; font-weight: bold; color: ${colors.textColor};`;
headerTitle.textContent = '目录';
const closeBtn = document.createElement('span');
closeBtn.textContent = '收起';
closeBtn.style.cssText = `font-size: 13px; color: ${colors.subtleTextColor}; cursor: pointer; padding: 5px; font-weight: normal; -webkit-tap-highlight-color: transparent;`;
header.appendChild(headerTitle);
header.appendChild(closeBtn);
const doClose = () => {
if (catalogView.__closing) return;
catalogView.__closing = true;
setIconBtnActive(overlay, 'catalog', false);
if (catalogView.__pollInterval) { clearInterval(catalogView.__pollInterval); catalogView.__pollInterval = null; }
catalogView.style.transform = 'translateY(100%)';
mask.style.opacity = '0';
setTimeout(() => {
catalogView.remove();
mask.remove();
if (currentCatalogView === catalogView) currentCatalogView = null;
if (overlay.querySelector('#reader-catalog-view') || overlay.querySelector('#reader-settings-view')) return;
if (immersiveBottom) {
immersiveBottom.classList.remove('above-drawer');
const restoreIsNight = overlay.__isNight === true;
const restoreUserBg = overlay.__userBgColor || gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor);
const restoreColors = calculateColors(restoreUserBg, restoreIsNight);
applyImmersiveBottomStyle(immersiveBottom, restoreColors, immersiveBottom.dataset.style, immersiveBottom.dataset.visible === 'true');
}
}, 300);
};
catalogView.__doClose = doClose;
closeBtn.addEventListener('click', doClose);
mask.addEventListener('click', doClose);
catalogView.appendChild(header);
const toolbar = document.createElement('div');
toolbar.className = 'reader-catalog-toolbar';
toolbar.style.cssText = `display: flex; justify-content: space-between; align-items: center; padding: 8px 20px 6px; flex-shrink: 0;`;
const toolbarLabel = document.createElement('span');
toolbarLabel.className = 'reader-catalog-toolbar-label';
toolbarLabel.textContent = '';
toolbarLabel.style.cssText = `font-size: 12px; color: ${colors.subtleTextColor}; user-select: none;`;
function updateToolbarCount(links) {
if (links && links.length > 0) {
let missingCount = 0;
for (const l of links) if (l && l.__missing === true) missingCount++;
let text = `共 ${links.length} 章`;
if (missingCount > 0) text += `(其中 ${missingCount} 章原站缺失)`;
toolbarLabel.textContent = text;
} else toolbarLabel.textContent = '';
}
const sortBtn = document.createElement('div');
sortBtn.className = 'reader-catalog-sort-btn';
sortBtn.title = '切换正序/倒序';
sortBtn.style.cssText = `display: inline-flex; align-items: center; gap: 5px; padding: 5px 8px; border-radius: 6px; font-size: 12px; font-weight: 500; line-height: 1; color: ${colors.subtleTextColor}; background: transparent; border: none; cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; transition: transform 0.12s ease;`;
function updateSortBtn() {
const isAsc = catalogOrder === 'asc';
const label = isAsc ? '倒序' : '正序';
const icon = isAsc ? ICONS.catalogSortDesc : ICONS.catalogSortAsc;
sortBtn.innerHTML = icon + `${label}`;
}
updateSortBtn();
sortBtn.addEventListener('mousedown', () => { sortBtn.style.transform = 'scale(0.95)'; });
sortBtn.addEventListener('mouseup', () => { sortBtn.style.transform = ''; });
sortBtn.addEventListener('mouseleave', () => { sortBtn.style.transform = ''; });
sortBtn.addEventListener('click', (e) => {
e.stopPropagation();
catalogOrder = catalogOrder === 'asc' ? 'desc' : 'asc';
updateSortBtn();
if (catalogCache && catalogCache.url === indexUrl) {
try {
renderCatalogLinks(listContainer, catalogCache.links, colors, handleLinkClick, overlay.__currentUrl || window.location.href, isNight, catalogOrder);
listContainer.scrollTop = 0;
scheduleCheck();
} catch(err) {}
}
});
toolbar.appendChild(toolbarLabel);
toolbar.appendChild(sortBtn);
catalogView.appendChild(toolbar);
const listContainer = document.createElement('div');
listContainer.id = 'reader-catalog-list';
listContainer.style.cssText = 'flex: 1; overflow-y: auto; padding: 10px 20px 100px; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;';
catalogView.appendChild(listContainer);
const locateBtn = document.createElement('div');
locateBtn.id = 'reader-catalog-locate';
locateBtn.className = 'reader-locate-btn';
locateBtn.title = '定位到当前章节';
locateBtn.innerHTML = ICONS.crosshair;
locateBtn.style.cssText = `position: absolute; right: 16px; bottom: 80px; width: 40px; height: 40px; border-radius: 50%; background: ${colors.controlBgColor}; color: ${colors.navBtnColor}; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 5; box-shadow: 0 2px 10px rgba(0,0,0,0.18); -webkit-tap-highlight-color: transparent; outline: none; touch-action: none;`;
locateBtn.addEventListener('click', (e) => {
e.stopPropagation();
const currentEl = listContainer.querySelector('a[data-current="true"]');
if (currentEl) {
try {
const listRect = listContainer.getBoundingClientRect();
const elRect = currentEl.getBoundingClientRect();
listContainer.scrollTo({ top: listContainer.scrollTop + (elRect.top - listRect.top), behavior: 'smooth' });
} catch(e) { currentEl.scrollIntoView(); }
} else notify('未找到当前章节');
});
catalogView.appendChild(locateBtn);
function updateLocateBtnVisibility() {
const currentEl = listContainer.querySelector('a[data-current="true"]');
if (!currentEl) { locateBtn.classList.remove('reader-locate-visible'); return; }
const listRect = listContainer.getBoundingClientRect();
const elRect = currentEl.getBoundingClientRect();
const isCompletelyOut = elRect.bottom <= listRect.top || elRect.top >= listRect.bottom;
locateBtn.classList.toggle('reader-locate-visible', isCompletelyOut);
}
catalogView.__updateLocateBtn = updateLocateBtnVisibility;
listContainer.addEventListener('scroll', updateLocateBtnVisibility, { passive: true });
function autoScrollToCurrent() {
if (catalogOrder !== 'asc') return;
const currentEl = listContainer.querySelector('a[data-current="true"]');
if (!currentEl) return;
try {
const listRect = listContainer.getBoundingClientRect();
const elRect = currentEl.getBoundingClientRect();
listContainer.scrollTop = listContainer.scrollTop + (elRect.top - listRect.top);
} catch(e) {
try { currentEl.scrollIntoView({ block: 'start', behavior: 'auto' }); } catch(e2) {}
}
}
const scheduleAutoLocate = () => {
requestAnimationFrame(() => requestAnimationFrame(autoScrollToCurrent));
};
overlay.appendChild(catalogView);
currentCatalogView = catalogView;
requestAnimationFrame(() => {
catalogView.style.transform = 'translateY(0)';
mask.style.opacity = '1';
});
const handleLinkClick = (url) => {
doClose();
if (onChapterNav) onChapterNav(url);
else window.location.href = url;
};
const curUrl = overlay.__currentUrl || window.location.href;
const scheduleCheck = () => { setTimeout(() => { try { updateLocateBtnVisibility(); } catch(e) {} }, 200); };
if (catalogCache && catalogCache.url === indexUrl) {
updateToolbarCount(catalogCache.links);
renderCatalogLinks(listContainer, catalogCache.links, colors, handleLinkClick, curUrl, isNight, catalogOrder);
scheduleCheck();
scheduleAutoLocate();
return;
}
if (catalogCacheLoading && catalogCacheLoading.url === indexUrl) {
const loadingDiv = document.createElement('div');
loadingDiv.id = 'reader-catalog-loading';
loadingDiv.style.cssText = `padding: 30px; text-align: center; color: ${colors.subtleTextColor}; font-size: 14px;`;
loadingDiv.textContent = '正在加载目录...';
listContainer.appendChild(loadingDiv);
const check = setInterval(() => {
if (catalogCache && catalogCache.url === indexUrl) {
clearInterval(check);
catalogView.__pollInterval = null;
const el = overlay.querySelector('#reader-catalog-loading');
if (el) el.remove();
updateToolbarCount(catalogCache.links);
renderCatalogLinks(listContainer, catalogCache.links, colors, handleLinkClick, curUrl, isNight, catalogOrder);
scheduleCheck();
scheduleAutoLocate();
} else if (!catalogCacheLoading) {
clearInterval(check);
catalogView.__pollInterval = null;
const el = overlay.querySelector('#reader-catalog-loading');
if (el) el.remove();
renderError(listContainer, '目录预加载失败,请稍后重试。', indexUrl, colors);
}
}, 150);
catalogView.__pollInterval = check;
return;
}
const loadingDiv = document.createElement('div');
loadingDiv.id = 'reader-catalog-loading';
loadingDiv.style.cssText = `padding: 30px; text-align: center; color: ${colors.subtleTextColor}; font-size: 14px;`;
loadingDiv.textContent = '正在加载目录...';
listContainer.appendChild(loadingDiv);
(async () => {
const doc = await loadDocTemp(indexUrl, 8000);
const el = overlay.querySelector('#reader-catalog-loading');
if (el) el.remove();
if (!doc) {
renderError(listContainer, '目录页无法访问(iframe 与 fetch 均失败)。', indexUrl, colors);
return;
}
try {
const links = extractChapterLinksFromDoc(doc, indexUrl);
if (links.length === 0) {
renderError(listContainer, '未能从目录页提取到章节链接。', indexUrl, colors);
return;
}
catalogCache = { url: indexUrl, links };
console.log(`[阅读模式] 目录抓取成功,共 ${links.length} 条章节链接`);
overlay.dispatchEvent(new CustomEvent('reader-catalog-ready'));
updateToolbarCount(links);
renderCatalogLinks(listContainer, links, colors, handleLinkClick, curUrl, isNight, catalogOrder);
scheduleCheck();
scheduleAutoLocate();
} catch (e) {
console.error('[阅读模式] 解析目录失败:', e);
renderError(listContainer, '解析目录失败。', indexUrl, colors);
}
})();
function renderError(container, msg, url, c) {
const errorDiv = document.createElement('div');
errorDiv.style.cssText = `padding: 20px; text-align: center; color: ${c.subtleTextColor}; font-size: 14px;`;
errorDiv.textContent = msg;
const jumpBtn = document.createElement('button');
jumpBtn.textContent = '点此手动打开目录页';
jumpBtn.style.cssText = `margin-top: 15px; padding: 10px 20px; background: ${c.navBtnColor}; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; -webkit-tap-highlight-color: transparent; outline: none;`;
jumpBtn.addEventListener('click', () => { window.open(url, '_blank'); });
errorDiv.appendChild(document.createElement('br'));
errorDiv.appendChild(jumpBtn);
container.appendChild(errorDiv);
}
}
function showSettingsPanel(overlay, opts) {
const existingSettings = overlay.querySelector('#reader-settings-view');
if (existingSettings) {
if (typeof existingSettings.__doClose === 'function') existingSettings.__doClose();
return;
}
removeCatalogImmediately(overlay);
setIconBtnActive(overlay, 'settings', true);
const isNight = overlay.__isNight === true;
const userBg = overlay.__userBgColor || gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor);
const actualBg = getEffectiveBgColor(userBg, isNight);
const colors = calculateColors(userBg, isNight);
const immersiveBottom = overlay.querySelector('#reader-immersive-bottom');
if (immersiveBottom) {
immersiveBottom.classList.add('above-drawer');
applyImmersiveBottomStyle(immersiveBottom, colors, immersiveBottom.dataset.style, true);
}
const mask = document.createElement('div');
mask.id = 'reader-settings-mask';
mask.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); z-index: 1000; opacity: 0; transition: opacity 0.3s; touch-action: none;';
overlay.appendChild(mask);
const panel = document.createElement('div');
panel.id = 'reader-settings-view';
panel.style.cssText = `position: fixed; bottom: 0; left: 0; width: 100%; background-color: ${actualBg}; z-index: 1001; border-radius: 16px 16px 0 0; display: flex; flex-direction: column; transform: translateY(100%); transition: transform 0.32s cubic-bezier(0.2, 0.8, 0.2, 1); box-sizing: border-box; box-shadow: 0 -4px 12px rgba(0,0,0,0.15); padding: 0 0 env(safe-area-inset-bottom, 0px); max-height: 90%; overscroll-behavior: contain; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;`;
const lineColor = getDividerColor(actualBg);
const header = document.createElement('div');
header.style.cssText = `display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-bottom: 1px solid ${lineColor}; flex-shrink: 0;`;
const headerLeft = document.createElement('div');
headerLeft.style.cssText = 'display: flex; align-items: center; gap: 10px;';
const titleSpan = document.createElement('span');
titleSpan.textContent = '设置';
titleSpan.style.cssText = `font-size: 16px; font-weight: bold; color: ${colors.textColor};`;
headerLeft.appendChild(titleSpan);
const closeBtn = document.createElement('span');
closeBtn.textContent = '收起';
closeBtn.style.cssText = `font-size: 13px; color: ${colors.subtleTextColor}; cursor: pointer; padding: 5px; -webkit-tap-highlight-color: transparent;`;
const doClose = () => {
if (panel.__closing) return;
panel.__closing = true;
setIconBtnActive(overlay, 'settings', false);
panel.style.transform = 'translateY(100%)';
mask.style.opacity = '0';
setTimeout(() => {
panel.remove();
mask.remove();
if (overlay.querySelector('#reader-catalog-view') || overlay.querySelector('#reader-settings-view')) return;
if (immersiveBottom) {
immersiveBottom.classList.remove('above-drawer');
const restoreIsNight = overlay.__isNight === true;
const restoreUserBg = overlay.__userBgColor || gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor);
const restoreColors = calculateColors(restoreUserBg, restoreIsNight);
applyImmersiveBottomStyle(immersiveBottom, restoreColors, immersiveBottom.dataset.style, immersiveBottom.dataset.visible === 'true');
}
}, 300);
};
panel.__doClose = doClose;
closeBtn.addEventListener('click', doClose);
mask.addEventListener('click', doClose);
header.appendChild(headerLeft);
header.appendChild(closeBtn);
panel.appendChild(header);
const body = document.createElement('div');
body.style.cssText = 'flex: 1; overflow-y: auto; padding: 16px 18px 90px; display: flex; flex-direction: column; gap: 14px; overscroll-behavior: contain; -webkit-overflow-scrolling: touch;';
panel.appendChild(body);
function renderMainView() {
const colors = (() => {
const nowIsNight = overlay.__isNight === true;
const nowUserBg = overlay.__userBgColor || gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor);
return calculateColors(nowUserBg, nowIsNight);
})();
titleSpan.textContent = '设置';
body.innerHTML = '';
const fontSizeRow = document.createElement('div');
fontSizeRow.style.cssText = `display: flex; align-items: center; gap: 10px;`;
const fontSizeLabel = document.createElement('div');
fontSizeLabel.textContent = '字号';
fontSizeLabel.style.cssText = `font-size: 13px; color: ${colors.subtleTextColor}; flex-shrink: 0; width: 40px;`;
fontSizeRow.appendChild(fontSizeLabel);
const sizeBtns = document.createElement('div');
sizeBtns.style.cssText = `display: flex; align-items: center; flex: 1; background: ${colors.controlBgColor}; border-radius: 8px; border: 1px solid ${colors.borderColor}; overflow: hidden;`;
const btnMinus = document.createElement('div');
btnMinus.textContent = 'A-';
btnMinus.style.cssText = `flex: 1; text-align: center; padding: 8px 0; cursor: pointer; font-size: 14px; color: ${colors.textColor}; user-select: none; -webkit-tap-highlight-color: transparent;`;
const sizeLabel = document.createElement('div');
sizeLabel.textContent = parseInt(gmGet(STORAGE_KEYS.FONT_SIZE, DEFAULT_SETTINGS.fontSize), 10) || 18;
sizeLabel.style.cssText = `flex: 1; text-align: center; font-size: 13px; color: ${colors.textColor}; font-weight: 600; border-left: 1px solid ${colors.borderColor}; border-right: 1px solid ${colors.borderColor}; padding: 8px 0;`;
const btnPlus = document.createElement('div');
btnPlus.textContent = 'A+';
btnPlus.style.cssText = btnMinus.style.cssText;
btnMinus.addEventListener('mouseenter', () => btnMinus.style.background = colors.borderColor);
btnMinus.addEventListener('mouseleave', () => btnMinus.style.background = 'transparent');
btnPlus.addEventListener('mouseenter', () => btnPlus.style.background = colors.borderColor);
btnPlus.addEventListener('mouseleave', () => btnPlus.style.background = 'transparent');
btnMinus.addEventListener('click', () => {
const cur = parseInt(gmGet(STORAGE_KEYS.FONT_SIZE, DEFAULT_SETTINGS.fontSize), 10) || 18;
const next = Math.max(10, cur - 1);
opts.onFontSizeChange(next + 'px');
sizeLabel.textContent = next;
});
btnPlus.addEventListener('click', () => {
const cur = parseInt(gmGet(STORAGE_KEYS.FONT_SIZE, DEFAULT_SETTINGS.fontSize), 10) || 18;
const next = Math.min(30, cur + 1);
opts.onFontSizeChange(next + 'px');
sizeLabel.textContent = next;
});
sizeBtns.appendChild(btnMinus);
sizeBtns.appendChild(sizeLabel);
sizeBtns.appendChild(btnPlus);
fontSizeRow.appendChild(sizeBtns);
body.appendChild(fontSizeRow);
const bgRow = document.createElement('div');
bgRow.style.cssText = `display: flex; align-items: center; gap: 10px;`;
const bgLabel = document.createElement('div');
bgLabel.textContent = '背景';
bgLabel.style.cssText = `font-size: 13px; color: ${colors.subtleTextColor}; flex-shrink: 0; width: 40px;`;
bgRow.appendChild(bgLabel);
const swatchRow = document.createElement('div');
swatchRow.style.cssText = `display: flex; align-items: center; gap: 10px; flex: 1;`;
const activeBg = gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor);
Object.keys(config.backgroundOptions).forEach(name => {
const color = config.backgroundOptions[name];
const isSelected = color === activeBg;
const swatch = document.createElement('div');
swatch.title = name;
swatch.dataset.color = color;
swatch.style.cssText = `width: 32px; height: 32px; border-radius: 50%; background: ${color}; cursor: pointer; position: relative; box-shadow: 0 1px 3px rgba(0,0,0,0.12); border: 2px solid ${isSelected ? colors.navBtnColor : 'transparent'}; transition: transform 0.2s; box-sizing: border-box; -webkit-tap-highlight-color: transparent;`;
swatch.addEventListener('mouseenter', () => swatch.style.transform = 'scale(1.1)');
swatch.addEventListener('mouseleave', () => swatch.style.transform = 'scale(1)');
swatch.addEventListener('click', () => {
opts.onBgChange(color);
panel.style.background = color;
swatchRow.querySelectorAll('div').forEach(el => { el.style.border = '2px solid transparent'; });
swatch.style.border = `2px solid ${colors.navBtnColor}`;
});
swatchRow.appendChild(swatch);
});
bgRow.appendChild(swatchRow);
body.appendChild(bgRow);
}
renderMainView();
panel.__refreshColors = () => {
const nowIsNight = overlay.__isNight === true;
const nowUserBg = overlay.__userBgColor || gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor);
const nowActualBg = getEffectiveBgColor(nowUserBg, nowIsNight);
const nowColors = calculateColors(nowUserBg, nowIsNight);
panel.style.backgroundColor = nowActualBg;
header.style.borderBottom = `1px solid ${getDividerColor(nowActualBg)}`;
titleSpan.style.color = nowColors.textColor;
closeBtn.style.color = nowColors.subtleTextColor;
renderMainView();
};
overlay.appendChild(panel);
requestAnimationFrame(() => {
panel.style.transform = 'translateY(0)';
mask.style.opacity = '1';
});
}
function applyImmersiveTopStyle(el, colors, style, visible) {
if (!el) return;
const transform = visible ? 'translateY(0)' : 'translateY(-100%)';
const opacity = visible ? '1' : '0';
el.style.cssText = `position: fixed; z-index: 90; display: flex; align-items: center; background: ${colors.controlBgColor}; transition: transform 0.32s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.32s cubic-bezier(0.2, 0.8, 0.2, 1), background 0.3s ease, border-color 0.3s ease, border-radius 0.3s ease, box-shadow 0.3s ease; transform: ${transform}; opacity: ${opacity}; pointer-events: ${visible ? 'auto' : 'none'}; -webkit-tap-highlight-color: transparent; outline: none; touch-action: none; overscroll-behavior: contain; color: ${colors.textColor}; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; top: 0; left: 0; right: 0; padding: 8px 16px; border-radius: 0; box-shadow: none;`;
}
function applyImmersiveBottomStyle(el, colors, style, visible) {
if (!el) return;
const aboveDrawer = el.classList.contains('above-drawer');
const effectiveVisible = aboveDrawer ? true : visible;
const zIndex = aboveDrawer ? 1010 : 90;
const transform = effectiveVisible ? 'translateY(0)' : 'translateY(100%)';
const opacity = effectiveVisible ? '1' : '0';
el.style.cssText = `position: fixed; z-index: ${zIndex}; display: flex; flex-direction: column; background: ${colors.controlBgColor}; transition: transform 0.32s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.32s cubic-bezier(0.2, 0.8, 0.2, 1), background 0.3s ease, border-color 0.3s ease, border-radius 0.3s ease, box-shadow 0.3s ease; transform: ${transform}; opacity: ${opacity}; pointer-events: ${effectiveVisible ? 'auto' : 'none'}; -webkit-tap-highlight-color: transparent; outline: none; touch-action: manipulation; overscroll-behavior: contain; color: ${colors.textColor}; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; bottom: 0; left: 0; right: 0; padding: 0 0 env(safe-area-inset-bottom, 0px); border-radius: 0; box-shadow: none;`;
}
function refreshImmersiveColors(container, colors, readingBg) {
if (!container) return;
const style = container.dataset.style || 'edge';
const visible = container.dataset.visible === 'true';
if (container.id === 'reader-immersive-top') applyImmersiveTopStyle(container, colors, style, visible);
else applyImmersiveBottomStyle(container, colors, style, visible);
container.querySelectorAll('.rt-btn, .rt-title').forEach(el => { el.style.color = colors.textColor; });
container.querySelectorAll('.rt-icon-btn[data-accent="true"]').forEach(el => {
if (el.dataset.disabled === 'true') return;
const isActive = el.dataset.active === 'true';
el.style.color = isActive ? colors.navBtnColor : colors.textColor;
});
container.querySelectorAll('.rt-icon-btn[data-accent="false"]').forEach(el => { el.style.color = colors.textColor; });
container.querySelectorAll('[data-rt-color="progress-bg"]').forEach(el => {
el.style.background = isDarkColor(colors.controlBgColor) ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)';
});
}
function setIconBtnActive(overlay, action, active) {
if (!overlay) return;
const btn = overlay.querySelector(`#reader-immersive-bottom .rt-icon-btn[data-action="${action}"]`);
if (!btn || btn.dataset.disabled === 'true') return;
btn.dataset.active = active ? 'true' : 'false';
const isNight = overlay.__isNight === true;
const userBg = overlay.__userBgColor || gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor);
const colors = calculateColors(userBg, isNight);
btn.style.color = active ? colors.navBtnColor : colors.textColor;
}
function toggleImmersiveVisibility(overlay, show) {
const topEl = overlay.querySelector('#reader-immersive-top');
const botEl = overlay.querySelector('#reader-immersive-bottom');
if (!topEl || !botEl) return;
topEl.dataset.visible = show ? 'true' : 'false';
botEl.dataset.visible = show ? 'true' : 'false';
const isNight = overlay.__isNight === true;
const userBg = overlay.__userBgColor || gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor);
const colors = calculateColors(userBg, isNight);
applyImmersiveTopStyle(topEl, colors, topEl.dataset.style, show);
applyImmersiveBottomStyle(botEl, colors, botEl.dataset.style, show);
if (botEl && typeof botEl.__setImmersiveVisible === 'function') botEl.__setImmersiveVisible(show);
}
function createImmersiveTopBar(title, colors, onExit, style) {
const bar = document.createElement('div');
bar.id = 'reader-immersive-top';
bar.dataset.style = style;
bar.dataset.visible = 'false';
applyImmersiveTopStyle(bar, colors, style, false);
const back = document.createElement('div');
back.className = 'rt-btn';
back.style.cssText = `display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 8px; border-radius: 6px; flex-shrink: 0; color: ${colors.textColor}; -webkit-tap-highlight-color: transparent; outline: none;`;
back.innerHTML = ICONS.back;
back.addEventListener('click', onExit);
const titleEl = document.createElement('div');
titleEl.className = 'rt-title';
titleEl.textContent = title || '';
titleEl.style.cssText = `flex: 1; text-align: left; font-size: 14px; font-weight: 500; color: ${colors.textColor}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin: 0 0 0 4px; transition: color 0.3s ease;`;
bar.appendChild(back);
bar.appendChild(titleEl);
return bar;
}
function createImmersiveBottomBar(colors, onCatalogClick, onSettingsClick, onNightModeClick, getThemeMode, nav, style, getCurrentNav, onChapterNav, getAccentForClick) {
const bar = document.createElement('div');
bar.id = 'reader-immersive-bottom';
bar.dataset.style = style;
bar.dataset.visible = 'false';
applyImmersiveBottomStyle(bar, colors, style, false);
let lastContextMenuTime = 0;
const isAfterContextMenu = () => Date.now() - lastContextMenuTime < 800;
const row1 = document.createElement('div');
row1.className = 'rib-row1';
const prevBtn = document.createElement('a');
prevBtn.className = 'rt-btn';
prevBtn.id = 'reader-prev-btn';
prevBtn.textContent = '上一章';
if (nav && nav.prev) prevBtn.href = addReaderModeHash(nav.prev.href);
prevBtn.style.cssText = `font-size: 13px; color: ${colors.textColor}; cursor: ${nav && nav.prev ? 'pointer' : 'not-allowed'}; opacity: ${nav && nav.prev ? 1 : 0.4}; padding: 4px 8px; text-decoration: none; transition: color 0.3s ease; touch-action: manipulation; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: default;`;
prevBtn.addEventListener('contextmenu', () => { lastContextMenuTime = Date.now(); });
prevBtn.addEventListener('click', (e) => {
e.preventDefault();
if (isAfterContextMenu()) return;
const currentNav = getCurrentNav ? getCurrentNav() : nav;
if (currentNav && currentNav.prev) {
if (onChapterNav) onChapterNav(currentNav.prev.href);
else window.location.href = currentNav.prev.href;
}
});
const progressContainer = document.createElement('div');
progressContainer.setAttribute('data-rt-color', 'progress-bg');
progressContainer.style.cssText = `flex: 1; height: 9px; background: ${isDarkColor(colors.controlBgColor) ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}; border-radius: 4.5px; margin: 0 12px; position: relative; cursor: default; opacity: 0.5; touch-action: none;`;
const progressThumb = document.createElement('div');
progressThumb.id = 'reader-progress-thumb';
progressThumb.style.cssText = `position: absolute; left: calc(0% - 9px); top: 50%; transform: translateY(-50%); width: 18px; height: 18px; background: ${colors.navBtnColor}; border-radius: 50%; box-shadow: 0 1px 4px rgba(0,0,0,0.3); transition: left 0.3s ease; z-index: 3;`;
progressContainer.appendChild(progressThumb);
const progressMarkerA = document.createElement('div');
progressMarkerA.className = 'reader-progress-marker-a';
progressMarkerA.style.cssText = `position: absolute; top: 50%; left: 0; width: 10px; height: 10px; background: #999; border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; opacity: 0; transition: left 0.2s ease, opacity 0.2s ease; z-index: 2;`;
progressContainer.appendChild(progressMarkerA);
const markerAHitArea = document.createElement('div');
markerAHitArea.style.cssText = `position: absolute; top: 50%; left: 0; width: 44px; height: 44px; transform: translate(-50%, -50%); border-radius: 50%; cursor: pointer; display: none; z-index: 4; -webkit-tap-highlight-color: transparent;`;
progressContainer.appendChild(markerAHitArea);
const progressBubble = document.createElement('div');
progressBubble.className = 'reader-progress-bubble';
progressBubble.style.cssText = `position: absolute; bottom: calc(100% + 20px); left: 50%; padding: 10px 20px; width: 200px; box-sizing: border-box; background: rgba(28,28,30,0.85); color: #fff; border-radius: 999px; font-size: 13px; font-weight: 500; line-height: 1.4; white-space: nowrap; pointer-events: none; overflow: hidden; text-overflow: ellipsis; opacity: 0; transform: translateX(-50%) translateY(6px); transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); z-index: 100; box-shadow: 0 8px 24px rgba(0,0,0,0.2); font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; text-align: center; word-break: break-word;`;
const bubblePercentNode = document.createElement('div');
bubblePercentNode.style.cssText = 'font-size: 12px; color: rgba(255,255,255,0.75); margin-bottom: 2px;';
progressBubble.appendChild(bubblePercentNode);
const bubbleChapterNode = document.createElement('div');
bubbleChapterNode.style.cssText = 'font-size: 13px; font-weight: 500; color: #fff; overflow: hidden; text-overflow: ellipsis;';
progressBubble.appendChild(bubbleChapterNode);
progressContainer.appendChild(progressBubble);
let isDraggingProgress = false, markerAPosition = null, hasSnappedToA = false, bubbleHideTimer = null;
const canDragProgress = () => catalogCache && catalogCache.links && catalogCache.links.length > 1;
const SNAP_THRESHOLD_PX = 10;
const updateThumbByX = (clientX) => {
const rect = progressContainer.getBoundingClientRect();
if (!rect.width) return 0;
let percent = (clientX - rect.left) / rect.width;
percent = Math.max(0, Math.min(1, percent));
if (markerAPosition !== null) {
const distPx = Math.abs(percent - markerAPosition) * rect.width;
if (distPx <= SNAP_THRESHOLD_PX) {
percent = markerAPosition;
if (!hasSnappedToA) {
hasSnappedToA = true;
if (navigator.vibrate) { try { navigator.vibrate(15); } catch (e) {} }
}
} else {
hasSnappedToA = false;
}
}
progressThumb.style.transition = 'none';
progressThumb.style.left = `calc(${percent * 100}% - 9px)`;
return percent;
};
const showBubble = (percent) => {
if (!catalogCache || !catalogCache.links || !catalogCache.links.length) return;
const total = catalogCache.links.length;
const idx = Math.round(percent * (total - 1));
const link = catalogCache.links[idx];
if (!link) return;
let chapterName = link.text || '未知章节';
if (chapterName.length > 15) chapterName = chapterName.slice(0, 15) + '…';
bubblePercentNode.textContent = Math.round(percent * 100) + '%';
bubbleChapterNode.textContent = chapterName;
progressBubble.style.opacity = '1';
progressBubble.style.transform = 'translateX(-50%) translateY(0)';
};
const hideBubble = () => {
progressBubble.style.opacity = '0';
progressBubble.style.transform = 'translateX(-50%) translateY(6px)';
};
const setMarkerA = (percent) => {
markerAPosition = percent;
progressMarkerA.style.left = `calc(${percent * 100}%)`;
markerAHitArea.style.left = `calc(${percent * 100}%)`;
progressMarkerA.style.opacity = '1';
markerAHitArea.style.display = 'block';
};
const clearMarkerA = () => {
markerAPosition = null; hasSnappedToA = false;
progressMarkerA.style.opacity = '0';
markerAHitArea.style.display = 'none';
};
const tryJumpToA = () => {
if (markerAPosition === null) return;
if (!catalogCache || !catalogCache.links || !catalogCache.links.length) return;
const total = catalogCache.links.length;
const idx = Math.round(markerAPosition * (total - 1));
const target = catalogCache.links[idx];
if (!target || target.__missing || !target.href) return;
if (onChapterNav) onChapterNav(target.href);
else window.location.href = target.href;
};
markerAHitArea.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); tryJumpToA(); });
const finishProgressDrag = (clientX) => {
progressThumb.style.transition = 'left 0.3s ease';
if (!canDragProgress()) return;
const percent = updateThumbByX(clientX);
const idx = Math.round(percent * (catalogCache.links.length - 1));
const target = catalogCache.links[idx];
if (!target || target.__missing || !target.href) {
if (target && target.__missing) notify('此章在原站缺失');
return;
}
if (onChapterNav) onChapterNav(target.href);
else window.location.href = target.href;
};
const onProgressTouchStart = (e) => {
if (!canDragProgress()) return;
if (!e.touches || !e.touches.length) return;
isDraggingProgress = true;
const percent = updateThumbByX(e.touches[0].clientX);
if (markerAPosition === null) { setMarkerA(percent); hasSnappedToA = true; }
else { hasSnappedToA = false; }
if (bubbleHideTimer) { clearTimeout(bubbleHideTimer); bubbleHideTimer = null; }
showBubble(percent);
e.stopPropagation();
};
const onProgressTouchMove = (e) => {
if (!isDraggingProgress) return;
if (!e.touches || !e.touches.length) return;
const percent = updateThumbByX(e.touches[0].clientX);
showBubble(percent);
e.stopPropagation();
};
const onProgressTouchEnd = (e) => {
if (!isDraggingProgress) return;
isDraggingProgress = false;
const t = (e.changedTouches && e.changedTouches[0]) ? e.changedTouches[0].clientX : null;
if (t !== null) finishProgressDrag(t);
bubbleHideTimer = setTimeout(() => { hideBubble(); bubbleHideTimer = null; }, 2000);
};
const onProgressMouseDown = (e) => {
if (!canDragProgress()) return;
isDraggingProgress = true;
const percent = updateThumbByX(e.clientX);
if (markerAPosition === null) { setMarkerA(percent); hasSnappedToA = true; }
else { hasSnappedToA = false; }
if (bubbleHideTimer) { clearTimeout(bubbleHideTimer); bubbleHideTimer = null; }
showBubble(percent);
e.stopPropagation();
};
const onProgressMouseMove = (e) => {
if (!isDraggingProgress) return;
const percent = updateThumbByX(e.clientX);
showBubble(percent);
};
const onProgressMouseUp = (e) => {
if (!isDraggingProgress) return;
isDraggingProgress = false;
finishProgressDrag(e.clientX);
bubbleHideTimer = setTimeout(() => { hideBubble(); bubbleHideTimer = null; }, 2000);
};
progressContainer.addEventListener('touchstart', onProgressTouchStart, { passive: true });
progressContainer.addEventListener('touchmove', onProgressTouchMove, { passive: true });
progressContainer.addEventListener('touchend', onProgressTouchEnd);
progressContainer.addEventListener('mousedown', onProgressMouseDown);
window.addEventListener('mousemove', onProgressMouseMove);
window.addEventListener('mouseup', onProgressMouseUp);
bar.__cleanup = () => {
window.removeEventListener('mousemove', onProgressMouseMove);
window.removeEventListener('mouseup', onProgressMouseUp);
if (bubbleHideTimer) { clearTimeout(bubbleHideTimer); bubbleHideTimer = null; }
};
bar.__setImmersiveVisible = (visible) => {
if (visible) {
if (markerAPosition !== null) { progressMarkerA.style.opacity = '1'; markerAHitArea.style.display = 'block'; }
} else {
clearMarkerA();
hideBubble();
if (bubbleHideTimer) { clearTimeout(bubbleHideTimer); bubbleHideTimer = null; }
}
};
const nextBtn = document.createElement('a');
nextBtn.className = 'rt-btn';
nextBtn.id = 'reader-next-btn';
nextBtn.textContent = '下一章';
if (nav && nav.next) nextBtn.href = addReaderModeHash(nav.next.href);
nextBtn.style.cssText = `font-size: 13px; color: ${colors.textColor}; cursor: ${nav && nav.next ? 'pointer' : 'not-allowed'}; opacity: ${nav && nav.next ? 1 : 0.4}; padding: 4px 8px; text-decoration: none; transition: color 0.3s ease; touch-action: manipulation; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: default;`;
nextBtn.addEventListener('contextmenu', () => { lastContextMenuTime = Date.now(); });
nextBtn.addEventListener('click', (e) => {
e.preventDefault();
if (isAfterContextMenu()) return;
const currentNav = getCurrentNav ? getCurrentNav() : nav;
if (currentNav && currentNav.next) {
if (onChapterNav) onChapterNav(currentNav.next.href);
else window.location.href = currentNav.next.href;
}
});
row1.appendChild(prevBtn);
row1.appendChild(progressContainer);
row1.appendChild(nextBtn);
bar.appendChild(row1);
const row2 = document.createElement('div');
row2.style.cssText = 'display: flex; align-items: center; justify-content: space-around; height: 72px; padding: 0 16px;';
function makeIconBtn(iconSvg, label, onClick, disabled, action, href) {
const useLink = !!href && !disabled;
const btn = document.createElement(useLink ? 'a' : 'div');
btn.className = 'rt-icon-btn';
btn.dataset.accent = 'true';
btn.dataset.action = action || '';
if (disabled) btn.dataset.disabled = 'true';
if (useLink) {
btn.href = href;
btn.style.textDecoration = 'none';
btn.style.touchAction = 'manipulation';
btn.style.webkitTouchCallout = 'default';
}
const baseColor = disabled ? colors.subtleTextColor : colors.textColor;
btn.style.cssText += `;display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 6px 12px; cursor: ${disabled ? 'not-allowed' : 'pointer'}; user-select: none; border-radius: 8px; transition: color 0.25s ease, transform 0.15s ease, opacity 0.2s ease; opacity: ${disabled ? 0.4 : 1}; color: ${baseColor}; -webkit-tap-highlight-color: transparent; outline: none;`;
btn.innerHTML = iconSvg + `${label}`;
if (!disabled) {
btn.addEventListener('contextmenu', () => { lastContextMenuTime = Date.now(); });
btn.addEventListener('click', (e) => {
if (useLink) e.preventDefault();
if (isAfterContextMenu()) return;
const accent = getAccentForClick();
btn.style.color = accent;
btn.style.transform = 'scale(0.92)';
setTimeout(() => {
btn.style.transform = '';
const restoreColors = bar.__getRestoreColors ? bar.__getRestoreColors() : colors;
btn.style.color = (btn.dataset.active === 'true') ? restoreColors.navBtnColor : restoreColors.textColor;
}, 150);
onClick();
});
}
return btn;
}
row2.appendChild(makeIconBtn(ICONS.catalog, '目录', () => {
const currentNav = getCurrentNav ? getCurrentNav() : nav;
if (currentNav && currentNav.index) onCatalogClick(currentNav.index.href);
}, !nav || !nav.index, 'catalog', nav && nav.index ? nav.index.href : null));
const themeBtn = document.createElement('div');
themeBtn.className = 'rt-icon-btn';
themeBtn.dataset.accent = 'false';
themeBtn.style.cssText = `display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 6px 12px; cursor: pointer; user-select: none; border-radius: 8px; transition: color 0.25s ease; color: ${colors.textColor}; -webkit-tap-highlight-color: transparent; outline: none;`;
function updateThemeBtn() {
const mode = getThemeMode();
let icon, label;
if (mode === 'night') { icon = ICONS.sun; label = '日间'; }
else { icon = ICONS.moon; label = '夜间'; }
themeBtn.innerHTML = icon + `${label}`;
}
updateThemeBtn();
themeBtn.addEventListener('click', () => { onNightModeClick(); updateThemeBtn(); });
row2.appendChild(themeBtn);
row2.appendChild(makeIconBtn(ICONS.settings, '设置', onSettingsClick, false, 'settings'));
bar.appendChild(row2);
bar.__updateThemeBtn = updateThemeBtn;
return bar;
}
function refreshImmersiveNavButtons(overlay, nav) {
const prevBtn = overlay.querySelector('#reader-prev-btn');
const nextBtn = overlay.querySelector('#reader-next-btn');
const catalogBtn = overlay.querySelector('#reader-immersive-bottom .rt-icon-btn[data-action="catalog"]');
if (prevBtn) {
prevBtn.style.cursor = nav && nav.prev ? 'pointer' : 'not-allowed';
prevBtn.style.opacity = nav && nav.prev ? '1' : '0.4';
if (prevBtn.tagName === 'A') {
if (nav && nav.prev) prevBtn.href = addReaderModeHash(nav.prev.href);
else prevBtn.removeAttribute('href');
}
}
if (nextBtn) {
nextBtn.style.cursor = nav && nav.next ? 'pointer' : 'not-allowed';
nextBtn.style.opacity = nav && nav.next ? '1' : '0.4';
if (nextBtn.tagName === 'A') {
if (nav && nav.next) nextBtn.href = addReaderModeHash(nav.next.href);
else nextBtn.removeAttribute('href');
}
}
if (catalogBtn && catalogBtn.tagName === 'A') {
if (nav && nav.index) catalogBtn.href = nav.index.href;
else catalogBtn.removeAttribute('href');
}
}
function createShadowHost(hostId, bgColor) {
const host = document.createElement('div');
host.id = hostId;
host.style.cssText = 'all:initial;position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647;pointer-events:none;';
if (bgColor) host.style.background = bgColor;
const shadow = host.attachShadow({ mode: 'open' });
document.documentElement.appendChild(host);
return { host, shadow };
}
function buildReaderCore(article, initialUserBg, initialFontSize, onExit, overlayId) {
let isNight = normalizeThemeMode(gmGet(STORAGE_KEYS.THEME_MODE, DEFAULT_SETTINGS.themeMode)) === 'night';
const initialActualBg = getEffectiveBgColor(initialUserBg, isNight);
const initialColors = calculateColors(initialUserBg, isNight);
const { host, shadow } = createShadowHost(overlayId + '-host', initialActualBg);
const baseStyle = document.createElement('style');
baseStyle.textContent = `
.rib-row1 { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; }
#reader-immersive-bottom.above-drawer .rib-row1 { display: none; }
.reader-locate-btn { opacity: 0; transform: scale(0.85) translateY(6px); pointer-events: none; transition: opacity 0.3s cubic-bezier(0.2,0.8,0.2,1), transform 0.3s cubic-bezier(0.2,0.8,0.2,1), box-shadow 0.25s ease, background 0.25s ease, color 0.25s ease, border-color 0.25s ease; will-change: opacity, transform; }
.reader-locate-btn.reader-locate-visible { opacity: 1; transform: scale(1) translateY(0); pointer-events: auto; }
.reader-locate-btn.reader-locate-visible:hover { transform: scale(1.08) translateY(0); box-shadow: 0 4px 14px rgba(0,0,0,0.24); }
.reader-locate-btn.reader-locate-visible:active { transform: scale(0.97) translateY(0); box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
`;
shadow.appendChild(baseStyle);
['click', 'mousedown', 'mouseup', 'pointerdown', 'pointerup', 'touchstart', 'touchend', 'contextmenu', 'dblclick', 'auxclick'].forEach(evt => {
host.addEventListener(evt, (e) => { e.stopPropagation(); }, false);
});
const overlay = document.createElement('div');
overlay.id = overlayId;
overlay.__shadowHost = host;
overlay.__isNight = isNight;
overlay.__userBgColor = initialUserBg;
overlay.style.cssText = `position:absolute;top:0;left:0;width:100%;height:100%;background-color:${initialActualBg};overflow-y:auto;overscroll-behavior-y:contain;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;-webkit-tap-highlight-color:transparent;outline:none;`;
const mainContainer = document.createElement('div');
mainContainer.id = 'reader-container';
mainContainer.style.cssText = `width:92%;max-width:800px;margin:0 auto;padding:2.7em 10px 20px;color:${initialColors.textColor};font-size:${initialFontSize};line-height:1.8;user-select:text;overflow-wrap:break-word;word-wrap:break-word;text-align:left;`;
const contentArea = document.createElement('div');
contentArea.id = 'reader-content-area';
contentArea.style.cssText = 'text-align:left;';
const style = document.createElement('style');
style.id = 'reader-content-style';
style.textContent = buildContentStyleCSS(initialUserBg, article.autoHasIndent, isNight);
mainContainer.appendChild(style);
overlay.__nav = article.nav;
overlay.__currentUrl = window.location.href;
const chapterState = {
list: [],
currentUrl: window.location.href,
isAppending: false,
isJumping: false
};
const getNowColors = () => {
const nowIsNight = overlay.__isNight === true;
const nowUserBg = overlay.__userBgColor || gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor);
return calculateColors(nowUserBg, nowIsNight);
};
const getNowFontSize = () => mainContainer.style.fontSize || initialFontSize;
function createChapterBlock(art, url) {
const colors = getNowColors();
const fontSize = getNowFontSize();
const block = document.createElement('div');
block.className = 'reader-chapter-block';
block.dataset.url = url;
if (art.title && art.title.trim()) {
const title = document.createElement('h1');
title.className = 'reader-chapter-title';
title.textContent = art.title;
title.style.cssText = `margin:0 0 30px 0;font-size:1.4em;font-weight:700;line-height:1.4;color:${colors.textColor};text-align:left;user-select:text;overflow-wrap:break-word;word-wrap:break-word;`;
block.appendChild(title);
}
const content = document.createElement('div');
content.className = 'reader-chapter-content';
content.innerHTML = art.content;
content.style.cssText = `font-size:${fontSize};line-height:1.8;color:${colors.textColor};user-select:text;overflow-wrap:break-word;word-wrap:break-word;max-width:100%;text-align:left;`;
block.appendChild(content);
return block;
}
async function appendNextChapter() {
if (chapterState.isAppending || chapterState.isJumping) return;
const last = chapterState.list[chapterState.list.length - 1];
if (!last || !last.article || !last.article.nav || !last.article.nav.next) return;
const nextUrl = last.article.nav.next.href;
if (!nextUrl) return;
chapterState.isAppending = true;
const loadingHint = document.createElement('div');
loadingHint.className = 'reader-append-loading';
loadingHint.textContent = '正在加载...';
const hintColors = getNowColors();
loadingHint.style.cssText = `text-align:center; padding:40px 0; color:${hintColors.subtleTextColor}; font-size:14px;`;
contentArea.appendChild(loadingHint);
try {
const nextKey = normalizeUrl(nextUrl);
const cached = chapterCache.get(nextKey);
let art = null;
if (cached && cached.article) {
chapterCache.delete(nextKey);
art = cached.article;
} else {
art = await fetchChapterArticle(nextUrl);
}
if (!overlay.isConnected) return;
if (!art) return;
const block = createChapterBlock(art, nextUrl);
contentArea.appendChild(block);
chapterState.list.push({ url: nextUrl, article: art, blockEl: block });
scheduleReaderContentClean(overlay);
if (art.nav && art.nav.next) {
preloadNextChapter(art.nav.next.href);
}
} catch (e) {
console.error('[阅读模式] 拼接下一章失败:', e);
} finally {
try { loadingHint.remove(); } catch(e) {}
chapterState.isAppending = false;
}
}
async function jumpToChapter(url) {
if (chapterState.isJumping) return;
chapterState.isJumping = true;
while (contentArea.firstChild) contentArea.removeChild(contentArea.firstChild);
chapterState.list = [];
const loadingHint = document.createElement('div');
loadingHint.className = 'reader-jump-loading';
loadingHint.textContent = '正在加载...';
const hintColors = getNowColors();
loadingHint.style.cssText = `text-align:center; padding:60px 0; color:${hintColors.subtleTextColor}; font-size:14px;`;
contentArea.appendChild(loadingHint);
try {
const key = normalizeUrl(url);
const cached = chapterCache.get(key);
let art = null;
if (cached && cached.article) {
chapterCache.delete(key);
art = cached.article;
} else {
art = await fetchChapterArticle(url);
}
if (!overlay.isConnected) return;
try { loadingHint.remove(); } catch(e) {}
if (!art) {
notify('章节加载失败,跳转到新页面');
setTimeout(() => { window.location.href = url; }, 800);
return;
}
const block = createChapterBlock(art, url);
contentArea.appendChild(block);
chapterState.list = [{ url, article: art, blockEl: block }];
chapterState.currentUrl = url;
overlay.__currentUrl = url;
overlay.__nav = art.nav;
refreshImmersiveNavButtons(overlay, art.nav);
const immersiveTitle = overlay.querySelector('#reader-immersive-top .rt-title');
if (immersiveTitle) immersiveTitle.textContent = art.title || '';
try { history.pushState({}, '', addReaderModeHash(url)); } catch(e) {}
overlay.scrollTo(0, 0);
updateProgressBar(overlay, url);
scheduleReaderContentClean(overlay);
if (art.nav && art.nav.next) {
preloadNextChapter(art.nav.next.href);
}
} catch (e) {
console.error('[阅读模式] 跳转章节失败:', e);
try { loadingHint.remove(); } catch(e2) {}
} finally {
chapterState.isJumping = false;
}
}
function scrollToBlock(block) {
try {
const overlayRect = overlay.getBoundingClientRect();
const blockRect = block.getBoundingClientRect();
const targetTop = overlay.scrollTop + (blockRect.top - overlayRect.top);
const distance = Math.abs(blockRect.top - overlayRect.top);
const useSmooth = distance < overlay.clientHeight * 1.5;
overlay.scrollTo({ top: targetTop, behavior: useSmooth ? 'smooth' : 'auto' });
} catch(e) {
try { block.scrollIntoView(); } catch(e2) {}
}
}
function navigateToChapter(url) {
const key = normalizeUrl(url);
const blocks = contentArea.querySelectorAll('.reader-chapter-block');
for (const b of blocks) {
if (normalizeUrl(b.dataset.url) === key) {
scrollToBlock(b);
return;
}
}
jumpToChapter(url);
}
overlay.__onChapterNav = navigateToChapter;
const onCatalogClick = (url) => { showCatalog(url, overlay, navigateToChapter); };
const applyReaderColors = () => {
const nowIsNight = overlay.__isNight === true;
const nowUserBg = overlay.__userBgColor || gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor);
const nowActualBg = getEffectiveBgColor(nowUserBg, nowIsNight);
const colors = calculateColors(nowUserBg, nowIsNight);
overlay.style.backgroundColor = nowActualBg;
if (overlay.__shadowHost) overlay.__shadowHost.style.background = nowActualBg;
mainContainer.style.color = colors.textColor;
contentArea.querySelectorAll('.reader-chapter-title').forEach(el => { el.style.color = colors.textColor; });
contentArea.querySelectorAll('.reader-chapter-content').forEach(el => { el.style.color = colors.textColor; });
style.textContent = buildContentStyleCSS(nowUserBg, article.autoHasIndent, nowIsNight);
refreshImmersiveColors(overlay.querySelector('#reader-immersive-top'), colors, nowActualBg);
refreshImmersiveColors(overlay.querySelector('#reader-immersive-bottom'), colors, nowActualBg);
refreshCatalogColors(nowUserBg, nowIsNight);
const settingsPanel = overlay.querySelector('#reader-settings-view');
if (settingsPanel && typeof settingsPanel.__refreshColors === 'function') settingsPanel.__refreshColors();
};
const handleBgChange = (newColor) => {
if (overlay.__isNight) {
overlay.__isNight = false;
gmSet(STORAGE_KEYS.THEME_MODE, 'day');
const bottomBar = overlay.querySelector('#reader-immersive-bottom');
if (bottomBar && typeof bottomBar.__updateThemeBtn === 'function') bottomBar.__updateThemeBtn();
}
overlay.__userBgColor = newColor;
saveSetting(STORAGE_KEYS.BG_COLOR, newColor);
applyReaderColors();
};
const handleFontSizeChange = (newSize) => {
mainContainer.style.fontSize = newSize;
contentArea.querySelectorAll('.reader-chapter-content').forEach(el => { el.style.fontSize = newSize; });
saveSetting(STORAGE_KEYS.FONT_SIZE, newSize);
};
const handleNightModeClick = () => {
overlay.__isNight = !overlay.__isNight;
gmSet(STORAGE_KEYS.THEME_MODE, overlay.__isNight ? 'night' : 'day');
applyReaderColors();
};
const getThemeMode = () => overlay.__isNight ? 'night' : 'day';
const getAccentForClick = () => {
const nowIsNight = overlay.__isNight === true;
const nowUserBg = overlay.__userBgColor || gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor);
return getAccentColor(nowUserBg, nowIsNight);
};
const getRestoreColors = () => {
const nowIsNight = overlay.__isNight === true;
const nowUserBg = overlay.__userBgColor || gmGet(STORAGE_KEYS.BG_COLOR, DEFAULT_SETTINGS.bgColor);
return calculateColors(nowUserBg, nowIsNight);
};
const firstBlock = createChapterBlock(article, window.location.href);
contentArea.appendChild(firstBlock);
chapterState.list = [{ url: window.location.href, article, blockEl: firstBlock }];
chapterState.currentUrl = window.location.href;
mainContainer.appendChild(contentArea);
overlay.appendChild(mainContainer);
const immersiveTop = createImmersiveTopBar(article.title, initialColors, onExit, 'edge');
const immersiveBottom = createImmersiveBottomBar(
initialColors, onCatalogClick,
() => showSettingsPanel(overlay, { onBgChange: handleBgChange, onFontSizeChange: handleFontSizeChange }),
handleNightModeClick, getThemeMode, article.nav, 'edge',
() => overlay.__nav, navigateToChapter, getAccentForClick
);
immersiveBottom.__getRestoreColors = getRestoreColors;
overlay.appendChild(immersiveTop);
overlay.appendChild(immersiveBottom);
overlay.addEventListener('reader-catalog-ready', () => { updateProgressBar(overlay); });
updateProgressBar(overlay);
function updateCurrentChapter() {
const blocks = contentArea.querySelectorAll('.reader-chapter-block');
if (!blocks.length) return;
const overlayRect = overlay.getBoundingClientRect();
const checkY = overlayRect.top + overlay.clientHeight * 0.3;
let currentBlock = null;
for (const b of blocks) {
const rect = b.getBoundingClientRect();
if (rect.top <= checkY) currentBlock = b;
else break;
}
if (!currentBlock) currentBlock = blocks[0];
const url = currentBlock.dataset.url;
if (chapterState.currentUrl !== url) {
chapterState.currentUrl = url;
overlay.__currentUrl = url;
const ch = chapterState.list.find(c => c.url === url);
if (ch && ch.article) {
overlay.__nav = ch.article.nav;
refreshImmersiveNavButtons(overlay, ch.article.nav);
const immersiveTitle = overlay.querySelector('#reader-immersive-top .rt-title');
if (immersiveTitle) immersiveTitle.textContent = ch.article.title || '';
}
updateProgressBar(overlay, url);
}
}
function checkLoadOnScroll() {
if (chapterState.isAppending || chapterState.isJumping) return;
const last = chapterState.list[chapterState.list.length - 1];
if (!last || !last.article || !last.article.nav || !last.article.nav.next) return;
const scrollTop = overlay.scrollTop;
const vh = overlay.clientHeight;
const totalH = overlay.scrollHeight;
if (scrollTop + vh * 2.5 >= totalH) appendNextChapter();
}
let immersiveVisible = false, hadSelection = false, lastSelectionActivityTime = 0, selectionPressTime = 0;
const markSelectionOnPress = () => {
const sel = window.getSelection();
if (sel && sel.toString().trim().length > 0) selectionPressTime = Date.now();
};
overlay.addEventListener('pointerdown', markSelectionOnPress, true);
overlay.addEventListener('touchstart', markSelectionOnPress, true);
overlay.addEventListener('mousedown', markSelectionOnPress, true);
const handleSelectionChange = () => {
if (!overlay.isConnected) { document.removeEventListener('selectionchange', handleSelectionChange); return; }
const sel = window.getSelection();
if (!sel) return;
const text = (sel.toString() || '').trim();
const anchorInOverlay = sel.anchorNode && (overlay === sel.anchorNode || overlay.contains(sel.anchorNode));
if (text.length > 0 && anchorInOverlay) {
hadSelection = true;
lastSelectionActivityTime = Date.now();
if (immersiveVisible) { immersiveVisible = false; toggleImmersiveVisibility(overlay, false); }
} else if (hadSelection) {
hadSelection = false;
lastSelectionActivityTime = Date.now();
}
};
document.addEventListener('selectionchange', handleSelectionChange);
overlay.addEventListener('click', (e) => {
if (Date.now() - selectionPressTime < 500) return;
if (Date.now() - lastSelectionActivityTime < 2000) return;
const t = e.target;
if (t && typeof t.closest === 'function' && t.closest(INTERACTIVE_SELECTOR)) return;
if (t.closest('#reader-immersive-top') || t.closest('#reader-immersive-bottom') || t.closest('#reader-top-nav') ||
t.closest('#reader-catalog-view') || t.closest('#reader-catalog-mask') ||
t.closest('#reader-settings-view') || t.closest('#reader-settings-mask')) return;
immersiveVisible = !immersiveVisible;
toggleImmersiveVisibility(overlay, immersiveVisible);
});
let scrollThrottleTimer = null;
overlay.addEventListener('scroll', () => {
if (immersiveVisible) { immersiveVisible = false; toggleImmersiveVisibility(overlay, false); }
updateCurrentChapter();
if (scrollThrottleTimer) return;
scrollThrottleTimer = setTimeout(() => {
scrollThrottleTimer = null;
checkLoadOnScroll();
}, 180);
}, { passive: true });
shadow.appendChild(overlay);
overlay.focus();
if (article.nav && article.nav.next) {
preloadNextChapter(article.nav.next.href);
}
if (article.nav && article.nav.index) {
setTimeout(() => { preloadCatalog(article.nav.index.href, overlay); }, 200);
}
scheduleReaderContentClean(overlay);
return overlay;
}
function buildOverlayReaderUI(article, initialBgColor = '#ffffff', initialFontSize = '18px') {
return buildReaderCore(article, initialBgColor, initialFontSize, exitLegacyReaderMode, 'reader-overlay');
}
function clearButtonTimer() {
if (buttonHideTimeout) { clearTimeout(buttonHideTimeout); buttonHideTimeout = null; }
}
function createMainButtonOnce() {
if (mainButton) return;
const btn = document.createElement('button');
btn.id = 'reader-main-btn';
btn.textContent = '阅读模式';
btn.style.cssText = 'position:fixed;bottom:60px;right:20px;z-index:10000;padding:8px 16px;background:#007bff;color:white;border:none;border-radius:4px;cursor:pointer;font-size:14px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;opacity:0.9;box-shadow:0 2px 8px rgba(0,0,0,0.2);transition:opacity 0.3s;user-select:none;-webkit-tap-highlight-color:transparent;outline:none;';
btn.addEventListener('mouseenter', () => { if (isReaderMode) return; btn.style.opacity = '1'; clearButtonTimer(); });
btn.addEventListener('mouseleave', () => {
if (isReaderMode) return;
btn.style.opacity = '0.9'; clearButtonTimer();
buttonHideTimeout = setTimeout(() => hideButton(btn), 3000);
});
btn.addEventListener('click', () => enterLegacyReaderMode());
document.body.appendChild(btn);
mainButton = btn;
buttonHideTimeout = setTimeout(() => hideButton(btn), 3000);
}
function hideButton(btn) {
if (!isReaderMode && btn && btn.parentNode) {
btn.style.opacity = '0';
setTimeout(() => { if (btn.parentNode && !isReaderMode) btn.style.display = 'none'; }, 300);
}
}
function hideMainButtonPermanently() {
if (mainButton) { clearButtonTimer(); mainButton.style.display = 'none'; }
}
function applyFloatingButtonSetting(on) {
if (on) {
if (!mainButton) createMainButtonOnce();
else {
clearButtonTimer();
mainButton.style.display = '';
mainButton.style.opacity = '0.9';
buttonHideTimeout = setTimeout(() => hideButton(mainButton), 3000);
}
} else {
clearButtonTimer();
if (mainButton && mainButton.parentNode) mainButton.parentNode.removeChild(mainButton);
mainButton = null;
}
}
function enterLegacyReaderMode(preArticle) {
if (isReaderMode) return;
hideMainButtonPermanently();
const proceed = () => {
const article = preArticle || extractContent();
if (!article) { notify('未找到可阅读的正文内容'); return; }
originalUrl = window.location.href.replace(/#.*$/, '');
scrollY = window.scrollY;
const body = document.body;
originalBodyOverflow = body.style.overflow;
originalBodyPosition = body.style.position;
originalBodyTop = body.style.top;
originalBodyWidth = body.style.width;
body.style.overflow = 'hidden';
body.style.position = 'fixed';
body.style.top = `-${scrollY}px`;
body.style.width = '100%';
const settings = loadSettings();
readerOverlay = buildOverlayReaderUI(article, settings.bgColor, settings.fontSize);
isReaderMode = true;
try { history.replaceState({}, '', addReaderModeHash(window.location.href)); } catch(e) {}
updateMenuCommands();
};
if (preArticle) proceed();
else setTimeout(proceed, 50);
}
function exitLegacyReaderMode() {
if (!isReaderMode || !readerOverlay) return;
const immersiveBottom = readerOverlay.querySelector('#reader-immersive-bottom');
if (immersiveBottom && typeof immersiveBottom.__cleanup === 'function') immersiveBottom.__cleanup();
if (readerOverlay.__cleanTimers) {
readerOverlay.__cleanTimers.forEach(t => clearTimeout(t));
readerOverlay.__cleanTimers = null;
}
if (preloadScheduleTimer) { clearTimeout(preloadScheduleTimer); preloadScheduleTimer = null; }
cancelCurrentPreload();
chapterCache.clear();
const target = readerOverlay.__shadowHost || readerOverlay;
target.remove();
readerOverlay = null;
currentCatalogView = null;
catalogCache = null;
catalogCacheLoading = null;
const body = document.body;
body.style.overflow = originalBodyOverflow;
body.style.position = originalBodyPosition;
body.style.top = originalBodyTop;
body.style.width = originalBodyWidth;
window.scrollTo(0, scrollY);
isReaderMode = false;
try { history.replaceState({}, '', stripReaderModeHash(originalUrl || window.location.href)); } catch(e) {}
updateMenuCommands();
}
function updateMenuCommands() {
menuCommandIds.forEach(id => { try { GM_unregisterMenuCommand(id); } catch(e) {} });
menuCommandIds = [];
if (isReaderMode) {
menuCommandIds.push(GM_registerMenuCommand('❌ 退出阅读模式', () => exitLegacyReaderMode()));
} else {
menuCommandIds.push(GM_registerMenuCommand('打开阅读模式', () => enterLegacyReaderMode()));
const floatOn = gmGet(STORAGE_KEYS.FLOATING_BUTTON, DEFAULT_SETTINGS.floatingButton) === true;
menuCommandIds.push(GM_registerMenuCommand(floatOn ? '✅ 显示悬浮按钮' : '⬜ 显示悬浮按钮', () => {
const next = !(gmGet(STORAGE_KEYS.FLOATING_BUTTON, DEFAULT_SETTINGS.floatingButton) === true);
gmSet(STORAGE_KEYS.FLOATING_BUTTON, next);
if (!next) {
applyFloatingButtonSetting(false);
} else {
let article = null;
try { article = extractContent(); } catch(e) {}
if (article && article.fromSelector) applyFloatingButtonSetting(true);
}
updateMenuCommands();
}));
}
}
function init() {
if (SHOULD_AUTO_ACTIVATE) {
let attempts = 0;
const maxAttempts = 5;
const tryExtract = () => {
const article = extractContent();
if (article) enterLegacyReaderMode(article);
else { attempts++; if (attempts < maxAttempts) setTimeout(tryExtract, 300); }
};
tryExtract();
} else {
let retryCount = 0;
const maxRetries = 5;
const intervalId = setInterval(() => {
retryCount++;
let article = null;
if (!isReaderMode) {
try { article = extractContent(); } catch(e) {}
}
if (article && article.fromSelector) {
clearInterval(intervalId);
if (gmGet(STORAGE_KEYS.FLOATING_BUTTON, DEFAULT_SETTINGS.floatingButton) === true) createMainButtonOnce();
updateMenuCommands();
return;
}
if (retryCount >= maxRetries) {
clearInterval(intervalId);
updateMenuCommands();
}
}, 300);
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();