// ==UserScript==
// @name 小说追章阅读器
// @namespace https://tampermonkey.net/
// @version 7.5.7
// @description 从任意章节开始边读边下;找不到下一章时自动读目录;目录按章节号排序;支持原网站模式(左右翻页,翻完才跳章 / 中间呼出菜单自动隐藏)
// @author You
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_notification
// @connect *
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ==================== 配置 ====================
const CONFIG = {
contentSelectors: [
'#content', '#chaptercontent', '#booktxt', '#chapter-content',
'.content', '.chapter-content', '.reading-content', '.showtxt',
'.txt', 'article', '#htmlContent', '.read-content'
],
nextSelectors: [
'#next', '#next_url', '#pb_next', 'a[rel="next"]',
'.next', '.next-chapter', '.chapter-next a',
'.bottem2 a:last-child', '.page_chapter a:last-child',
'#page_next', '.section-opt a:last-child'
],
prevSelectors: [
'#prev', '#prev_url', '#pb_prev', 'a[rel="prev"]',
'.prev', '.prev-chapter', '.chapter-prev a',
'.bottem2 a:first-child', '.page_chapter a:first-child',
'#page_prev', '.section-opt a:first-child'
],
tocLinkSelectors: [
'a[href*="list"]', 'a[href*="index"]', 'a[href*="directory"]',
'.bookname a', '.book-name a', '#bookinfo a',
'.bottem2 a:nth-child(2)', '.page_chapter a:nth-child(2)',
'.section-opt a:nth-child(2)'
],
tocListSelectors: [
'#list a', '#chapterlist a', '.listmain a', '#chapter-list a',
'.chapter-list a', '#chapters a', '.chapters a', '.book-list a',
'#booklist a', '.booklist a', '#list-chapterAll a'
],
requestDelay: 800,
maxChapters: 20000,
tapLeftRatio: 0.33,
tapRightRatio: 0.67,
tocMinCount: 1,
};
const SESSION_KEY = 'ndr_session_v7';
const NATIVE_KEY = 'ndr_native_active_v2';
const NATIVE_TIMEOUT = 30 * 60 * 1000; // 30 分钟有效
// 原网站模式:工具栏自动隐藏延时
const NATIVE_BAR_AUTO_HIDE = 3000;
const CHAPTER_TITLE_RE = /^第\s*[\d一二三四五六七八九十百千万零两]+\s*[章节回]/;
const END_MARKERS_RE = /(完本|本书完|已完结|全书完|全剧终|没有下一章|最后一章|这是最后一章|已看完)/;
// ==================== 工具 ====================
const sleep = ms => new Promise(r => setTimeout(r, ms));
const log = (...a) => console.log('[阅读器]', ...a);
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[c]));
}
function getSetting(k, d) {
try { const v = GM_getValue('ndr_' + k); return v === undefined ? d : v; }
catch (e) { return d; }
}
function setSetting(k, v) {
try { GM_setValue('ndr_' + k, v); } catch (e) {}
}
function normalizeUrl(u) {
try { const url = new URL(u); url.hash = ''; return url.href; }
catch (e) { return u; }
}
function isBadHref(href) {
if (!href) return true;
if (href.startsWith('javascript:')) return true;
if (href === '#' || href.endsWith('#')) return true;
return false;
}
// ==================== 章节号解析(目录排序用) ====================
function chineseToNumber(ch) {
if (!ch) return 0;
const map = { '零':0,'〇':0,'○':0,'一':1,'二':2,'两':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,'十':10,'百':100,'千':1000,'万':10000,'亿':100000000 };
let hasWei = false;
for (let w = 0; w < ch.length; w++) {
if (map[ch[w]] >= 10) { hasWei = true; break; }
}
if (!hasWei) {
let n = 0;
for (let d = 0; d < ch.length; d++) {
const dv = map[ch[d]];
if (dv === undefined || dv >= 10) continue;
n = n * 10 + dv;
}
return n;
}
let num = 0, temp = 0;
for (let i = 0; i < ch.length; i++) {
const val = map[ch[i]];
if (val === undefined) continue;
if (val >= 10) {
if (temp === 0) temp = 1;
num += temp * val;
temp = 0;
} else {
temp = val;
}
}
return num + temp;
}
function parseChapterNumber(title) {
if (!title) return null;
const s = String(title).replace(/[\s\u3000]+/g, '');
let m = s.match(/第\s*(\d+)\s*[章节回]/);
if (m) return parseInt(m[1], 10);
m = s.match(/第\s*([零一二三四五六七八九十百千万两〇○]+)\s*[章节回]/);
if (m) return chineseToNumber(m[1]);
if (/^(楔子|序章|引子|前言|序言|开篇)/.test(s)) return 0;
return null;
}
// ==================== 网络 ====================
function gmFetch(url) {
let referer = location.href;
try { referer = new URL(url).origin + '/'; } catch (e) {}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url, responseType: 'arraybuffer', timeout: 30000,
headers: {
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; SM-G975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
'Referer': referer,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9',
},
onload: res => {
if (res.status >= 200 && res.status < 300) {
try { resolve(decodeResponse(res)); } catch (e) { reject(e); }
} else reject(new Error('HTTP ' + res.status));
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('请求超时')),
});
});
}
function decodeResponse(res) {
const buffer = res.response;
const headers = res.responseHeaders || '';
let charset = '';
const m = headers.match(/charset=([\w-]+)/i);
if (m) charset = m[1].toLowerCase();
if (!charset) {
const head = new Uint8Array(buffer.slice(0, 2048));
const probe = new TextDecoder('utf-8', { fatal: false }).decode(head);
charset = probe.includes('\uFFFD') ? 'gbk' : 'utf-8';
}
try { return new TextDecoder(charset).decode(buffer); }
catch (e) { return new TextDecoder('utf-8').decode(buffer); }
}
function toDoc(html, baseUrl) {
if (baseUrl) {
const tag = ``;
if (/
]*>/i.test(html)) html = html.replace(/]*>/i, m => m + tag);
else if (/]*>/i.test(html)) html = html.replace(/]*>/i, m => m + '' + tag + '');
else html = tag + html;
}
return new DOMParser().parseFromString(html, 'text/html');
}
// ==================== 正文解析 ====================
function extractContent(doc) {
let el = null;
for (const sel of CONFIG.contentSelectors) {
const e = doc.querySelector(sel);
if (e) {
const t = (e.innerText || e.textContent || '').trim();
if (t.length > 50) { el = e; break; }
}
}
if (!el) el = doc.body;
el.querySelectorAll(
'script,style,nav,header,footer,.ad,.ads,.advertisement,' +
'.bottem,.bottem1,.bottem2,.readmiddle,.chapter-nav,.nav,.tips'
).forEach(n => n.remove());
let text = (el.innerText || el.textContent || '').trim();
return text.replace(/\n{3,}/g, '\n\n');
}
function pageLooksEnded(doc, content) {
const tail = (content || '').slice(-300);
if (END_MARKERS_RE.test(tail)) return true;
const bodyText = (doc.body && doc.body.innerText || '').slice(-500);
if (END_MARKERS_RE.test(bodyText)) return true;
return false;
}
function pickTitleFromContent(content) {
if (!content) return '';
const lines = content.split('\n').map(l => l.trim()).filter(Boolean);
for (let i = 0; i < Math.min(lines.length, 6); i++) {
const line = lines[i];
const m = line.match(/^(第\s*[\d一二三四五六七八九十百千万零两]+\s*[章节回][^。!?!?\n]{0,60})/);
if (m && m[1]) {
let title = m[1].replace(/\s+/g, ' ').trim();
title = title.replace(/[((]\s*第\s*\d+\s*\/\s*\d+\s*页\s*[))]/g, '').trim();
if (title.length >= 2) return title;
}
}
return '';
}
function extractTitle(doc, i, content) {
const fromContent = pickTitleFromContent(content);
if (fromContent) return fromContent;
const sels = ['#chapter-title', '.chapter-title', '#title', '.title', 'h1', 'h2'];
for (const s of sels) {
const el = doc.querySelector(s);
if (el) {
const t = (el.textContent || '').trim().replace(/\s+/g, ' ');
if (!t || t.length > 100) continue;
if (!CHAPTER_TITLE_RE.test(t)) continue;
return t;
}
}
return '';
}
function extractBookName(doc) {
const title = (doc.title || '').trim();
let m = title.match(/[_\-||]([^_\-||]+)$/);
if (m) {
const n = m[1].trim();
if (n.length >= 2 && n.length <= 40 && !/^第\s*\d/.test(n)) return n;
}
m = title.match(/^([^_\-||]+)[_\-||]/);
if (m) {
const n = m[1].trim();
if (n.length >= 2 && n.length <= 40 && !/^第\s*\d/.test(n)) return n;
}
const meta = doc.querySelector('meta[property="og:novel:book_name"]');
if (meta && meta.content) return meta.content.trim();
return '';
}
function extractBookId(url) {
try {
const u = new URL(url);
let m = u.pathname.match(/\/(?:book|read|txt|novel|article|xiaoshuo)\/([A-Za-z0-9_]+)\//i);
if (m) return m[1];
const parts = u.pathname.split('/').filter(Boolean);
if (parts.length >= 2) {
const cand = parts[parts.length - 2];
if (/^\d+$/.test(cand)) return cand;
}
return '';
} catch (e) { return ''; }
}
// ==================== 找下一章/上一章 ====================
function findNextUrl(doc, currentUrl) {
const cur = normalizeUrl(currentUrl);
for (const sel of CONFIG.nextSelectors) {
const a = doc.querySelector(sel);
if (a && a.getAttribute('href') && !isBadHref(a.href)) {
const u = normalizeUrl(a.href);
if (u !== cur) return u;
}
}
const links = Array.from(doc.querySelectorAll('a'));
const pats = [/^下一[章页节篇]/, /^下章/, /^next\b/i, /下一[章页节篇]$/];
for (const a of links) {
const t = (a.textContent || '').trim().replace(/\s+/g, ' ');
if (!t || t.length > 12 || isBadHref(a.href)) continue;
if (pats.some(p => p.test(t))) {
const u = normalizeUrl(a.href);
if (u !== cur) return u;
}
}
for (const a of links) {
const t = (a.textContent || '').trim();
if (isBadHref(a.href)) continue;
if (/下一[章页节]/.test(t) && t.length <= 20) {
const u = normalizeUrl(a.href);
if (u !== cur) return u;
}
}
return null;
}
function findPrevUrl(doc, currentUrl) {
const cur = normalizeUrl(currentUrl);
for (const sel of CONFIG.prevSelectors) {
const a = doc.querySelector(sel);
if (a && a.getAttribute('href') && !isBadHref(a.href)) {
const u = normalizeUrl(a.href);
if (u !== cur) return u;
}
}
const links = Array.from(doc.querySelectorAll('a'));
const pats = [/^上一[章页节篇]/, /^上章/, /^prev\b/i, /上一[章页节篇]$/];
for (const a of links) {
const t = (a.textContent || '').trim().replace(/\s+/g, ' ');
if (!t || t.length > 12 || isBadHref(a.href)) continue;
if (pats.some(p => p.test(t))) {
const u = normalizeUrl(a.href);
if (u !== cur) return u;
}
}
for (const a of links) {
const t = (a.textContent || '').trim();
if (isBadHref(a.href)) continue;
if (/上一[章页节]/.test(t) && t.length <= 20) {
const u = normalizeUrl(a.href);
if (u !== cur) return u;
}
}
return null;
}
// ==================== 目录 ====================
function findTOCUrl(doc, currentUrl) {
for (const sel of CONFIG.tocLinkSelectors) {
const a = doc.querySelector(sel);
if (a && a.href && !isBadHref(a.href)) {
const u = normalizeUrl(a.href);
if (u !== normalizeUrl(currentUrl)) return u;
}
}
const links = Array.from(doc.querySelectorAll('a'));
for (const a of links) {
const t = (a.textContent || '').trim();
if (/^(目录|返回目录|章节目录|书籍目录|目 录|目錄|回目录)$/.test(t)) {
if (a.href && !isBadHref(a.href)) {
const u = normalizeUrl(a.href);
if (u !== normalizeUrl(currentUrl)) return u;
}
}
}
try {
const u = new URL(currentUrl);
const m = u.pathname.match(/^(\/(?:book|read|txt|novel|article|xiaoshuo)\/[^\/]+\/)/i);
if (m) return u.origin + m[1];
const parts = u.pathname.split('/').filter(Boolean);
if (parts.length >= 2) {
parts.pop();
return u.origin + '/' + parts.join('/') + '/';
}
} catch (e) {}
return null;
}
function parseTOCList(doc) {
const results = [];
const seen = new Set();
for (const sel of CONFIG.tocListSelectors) {
const as = doc.querySelectorAll(sel);
if (as.length >= CONFIG.tocMinCount) {
as.forEach(a => {
if (!a.href || isBadHref(a.href)) return;
const title = (a.textContent || '').trim();
if (!title || title.length > 100) return;
const u = normalizeUrl(a.href);
if (seen.has(u)) return;
seen.add(u);
results.push({ title, url: u });
});
if (results.length >= CONFIG.tocMinCount) return results;
results.length = 0;
seen.clear();
}
}
const all = Array.from(doc.querySelectorAll('a'));
for (const a of all) {
if (!a.href || isBadHref(a.href)) continue;
const title = (a.textContent || '').trim();
if (!title || title.length > 80) continue;
if (!CHAPTER_TITLE_RE.test(title)) continue;
const u = normalizeUrl(a.href);
if (seen.has(u)) continue;
seen.add(u);
results.push({ title, url: u });
}
return results;
}
async function fetchTOCList(tocUrl) {
const html = await gmFetch(tocUrl);
const doc = toDoc(html, tocUrl);
return parseTOCList(doc);
}
function parseUrlStructure(url) {
try {
const u = new URL(url);
const m = u.pathname.match(/^(.*\/)(\d+)(?:_(\d+))?(\.\w+)?$/);
if (!m) return null;
return {
origin: u.origin, dir: m[1], stem: m[2],
page: m[3] ? parseInt(m[3], 10) : 1,
ext: m[4] || '', hasPageSuffix: !!m[3],
};
} catch (e) { return null; }
}
function findNextFromTOC(tocList, currentUrl) {
const cur = normalizeUrl(currentUrl);
let idx = tocList.findIndex(item => normalizeUrl(item.url) === cur);
if (idx >= 0) {
if (idx + 1 < tocList.length) return tocList[idx + 1].url;
return null;
}
const st = parseUrlStructure(currentUrl);
if (st) {
const curNum = parseInt(st.stem, 10);
let best = null, bestNum = Infinity;
for (const item of tocList) {
const s2 = parseUrlStructure(item.url);
if (!s2) continue;
const n = parseInt(s2.stem, 10);
if (n > curNum && n < bestNum) { bestNum = n; best = item.url; }
}
if (best) return best;
}
return null;
}
function findPrevFromTOC(tocList, currentUrl) {
const cur = normalizeUrl(currentUrl);
let idx = tocList.findIndex(item => normalizeUrl(item.url) === cur);
if (idx > 0) return tocList[idx - 1].url;
const st = parseUrlStructure(currentUrl);
if (st) {
const curNum = parseInt(st.stem, 10);
let best = null, bestNum = -1;
for (const item of tocList) {
const s2 = parseUrlStructure(item.url);
if (!s2) continue;
const n = parseInt(s2.stem, 10);
if (n < curNum && n > bestNum) { bestNum = n; best = item.url; }
}
if (best) return best;
}
return null;
}
function guessNextUrls(currentUrl) {
const st = parseUrlStructure(currentUrl);
if (!st) return [];
const candidates = [];
candidates.push(`${st.origin}${st.dir}${st.stem}_${st.page + 1}${st.ext}`);
const nxt = (parseInt(st.stem, 10) + 1).toString();
candidates.push(`${st.origin}${st.dir}${nxt.padStart(st.stem.length, '0')}${st.ext}`);
candidates.push(`${st.origin}${st.dir}${nxt}${st.ext}`);
return Array.from(new Set(candidates));
}
// ==================== 综合找下一章 ====================
async function resolveNextUrl(doc, currentUrl, currentContent) {
const cur = normalizeUrl(currentUrl);
const fromDom = findNextUrl(doc, currentUrl);
if (fromDom) { log('[下一章] 页面按钮命中'); return { url: fromDom }; }
if (pageLooksEnded(doc, currentContent)) { log('[下一章] 页面提示已完结'); return { ended: true }; }
if (store.tocList && store.tocList.length) {
const next = findNextFromTOC(store.tocList, currentUrl);
if (next) { log('[下一章] 缓存目录命中'); return { url: next }; }
log('[下一章] 目录中已到末尾'); return { ended: true };
}
if (!store.tocList || store.tocList.length === 0) {
let tocUrl = store.tocUrl;
if (!tocUrl) {
tocUrl = findTOCUrl(doc, currentUrl);
if (tocUrl) store.tocUrl = tocUrl;
}
if (tocUrl) {
try {
const list = await fetchTOCList(tocUrl);
if (list && list.length >= CONFIG.tocMinCount) {
store.tocList = list;
updateMenu();
const next = findNextFromTOC(store.tocList, currentUrl);
if (next) return { url: next };
return { ended: true };
}
} catch (e) {}
}
}
const guessed = guessNextUrls(currentUrl);
for (const u of guessed) {
if (normalizeUrl(u) === cur) continue;
try {
const html = await gmFetch(u);
const d = toDoc(html, u);
const c = extractContent(d);
if (c && c.length >= 50 && c.slice(0, 100) !== (currentContent || '').slice(0, 100)) {
return { url: u };
}
} catch (e) {}
await sleep(150);
}
return null;
}
async function resolvePrevUrl(doc, currentUrl) {
const fromDom = findPrevUrl(doc, currentUrl);
if (fromDom) return fromDom;
if (store.tocList && store.tocList.length) {
const prev = findPrevFromTOC(store.tocList, currentUrl);
if (prev) return prev;
}
if (!store.tocList || store.tocList.length === 0) {
let tocUrl = store.tocUrl;
if (!tocUrl) {
tocUrl = findTOCUrl(doc, currentUrl);
if (tocUrl) store.tocUrl = tocUrl;
}
if (tocUrl) {
try {
const list = await fetchTOCList(tocUrl);
if (list && list.length >= CONFIG.tocMinCount) {
store.tocList = list;
updateMenu();
const prev = findPrevFromTOC(store.tocList, currentUrl);
if (prev) return prev;
}
} catch (e) {}
}
}
return null;
}
// ==================== 状态 ====================
const store = {
bookName: '', bookId: '',
tocUrl: '', tocList: [],
chapters: [],
fetching: false, finished: false, stopped: false,
};
const settings = {
fontSize: getSetting('fontSize', 18) || 18,
lineHeight: getSetting('lineHeight', 1.9) || 1.9,
theme: getSetting('theme', 'light') || 'light',
mode: getSetting('mode', 'page') || 'page',
};
// ==================== Session ====================
function loadSession() {
try {
const raw = GM_getValue(SESSION_KEY);
if (!raw) return null;
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!data || !data.url) return null;
return data;
} catch (e) { return null; }
}
function saveSessionNow() {
if (currentIndex < 0) return;
const ch = store.chapters[currentIndex];
if (!ch) return;
const locator = captureLocator();
const data = {
url: ch.url || '',
bookId: store.bookId || '',
bookName: store.bookName || '',
title: ch.title || '',
locator: locator,
time: Date.now(),
};
if (!data.url) return;
try { GM_setValue(SESSION_KEY, JSON.stringify(data)); }
catch (e) {}
}
function clearSession() {
try { GM_setValue(SESSION_KEY, ''); } catch (e) {}
}
let saveTimer = null;
function scheduleSave() {
if (!readerOpen) return;
if (saveTimer) return;
saveTimer = setTimeout(() => { saveTimer = null; saveSessionNow(); }, 500);
}
// ==================================================================
// ======================= 原网站模式 ==============================
// ==================================================================
const NATIVE_STYLE_ID = 'ndr-native-style';
const nativeMode = {
active: false,
barHost: null,
tocHost: null,
goPrev: null,
goNext: null,
};
// ---- 持久化标记(用 GM 存储,跨页跳转保留) ----
function saveNativeFlag() {
try {
GM_setValue(NATIVE_KEY, JSON.stringify({ t: Date.now() }));
} catch (e) {}
}
function refreshNativeFlag() {
saveNativeFlag();
}
function isNativeSaved() {
try {
const raw = GM_getValue(NATIVE_KEY);
if (!raw) return false;
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!data || !data.t) return false;
if (Date.now() - data.t > NATIVE_TIMEOUT) {
GM_setValue(NATIVE_KEY, '');
return false;
}
return true;
} catch (e) { return false; }
}
function clearNativeFlag() {
try { GM_setValue(NATIVE_KEY, ''); } catch (e) {}
}
// ---- 注入样式:字号、行距、主题 ----
function applyNativeStyle() {
let style = document.getElementById(NATIVE_STYLE_ID);
if (!style) {
style = document.createElement('style');
style.id = NATIVE_STYLE_ID;
document.head.appendChild(style);
}
const fs = settings.fontSize;
const lh = settings.lineHeight;
const theme = settings.theme;
const EXCLUDE = ':not(#ndr-native-bar-host):not(#ndr-native-toc-host)';
let css = `
html, body { font-size: ${fs}px !important; }
body p${EXCLUDE}, body article${EXCLUDE}, body section${EXCLUDE},
body li${EXCLUDE}, body div${EXCLUDE}, body span${EXCLUDE},
body a${EXCLUDE}, body td${EXCLUDE}, body th${EXCLUDE},
body strong${EXCLUDE}, body em${EXCLUDE}, body b${EXCLUDE},
body i${EXCLUDE}, body h1${EXCLUDE}, body h2${EXCLUDE},
body h3${EXCLUDE}, body h4${EXCLUDE}, body h5${EXCLUDE}, body h6${EXCLUDE} {
font-size: ${fs}px !important;
line-height: ${lh} !important;
}
`;
if (theme === 'sepia') {
css += `
html${EXCLUDE}, body${EXCLUDE} {
background-color: #f4ecd8 !important;
color: #5b4636 !important;
}
body *${EXCLUDE} {
background-color: transparent !important;
color: #5b4636 !important;
border-color: #e0d5bd !important;
}
body a${EXCLUDE} { color: #8a6d3b !important; }
`;
} else if (theme === 'dark') {
css += `
html${EXCLUDE}, body${EXCLUDE} {
background-color: #16171a !important;
color: #c9ccd1 !important;
}
body *${EXCLUDE} {
background-color: transparent !important;
color: #c9ccd1 !important;
border-color: #2a2c31 !important;
}
body a${EXCLUDE} { color: #7aa7f0 !important; }
body img${EXCLUDE} { opacity: 0.85 !important; }
body input${EXCLUDE}, body textarea${EXCLUDE} {
background-color: #1e2024 !important;
color: #c9ccd1 !important;
}
`;
}
style.textContent = css;
}
function removeNativeStyle() {
const style = document.getElementById(NATIVE_STYLE_ID);
if (style) try { style.remove(); } catch (e) {}
}
// ---- 激活 / 退出 ----
function activateNativeMode() {
if (readerOpen) closeReader();
nativeMode.active = true;
saveNativeFlag();
if (!store.bookName) {
const s = loadSession();
store.bookName = (s && s.bookName) || extractBookName(document) || '';
store.bookId = (s && s.bookId) || extractBookId(location.href) || '';
}
buildNativeBar();
setupNativeTapZones();
applyNativeStyle();
if (!store.tocList.length) {
const tocUrl = findTOCUrl(document, location.href);
if (tocUrl) {
store.tocUrl = tocUrl;
fetchTOCList(tocUrl).then(list => {
if (list && list.length >= CONFIG.tocMinCount) {
store.tocList = list;
log('[原生模式] 目录已加载:' + list.length + ' 章');
}
}).catch(() => {});
}
}
lastMenuKey = '';
updateMenu();
log('[原生模式] 已激活(左右翻页,翻到底/顶才跳章,中间呼出菜单)');
}
function deactivateNativeMode() {
nativeMode.active = false;
clearNativeFlag();
removeNativeStyle();
if (nativeMode.barHost) {
try { nativeMode.barHost.remove(); } catch (e) {}
nativeMode.barHost = null;
}
if (nativeMode.tocHost) {
try { nativeMode.tocHost.remove(); } catch (e) {}
nativeMode.tocHost = null;
}
hideNativeBar();
lastMenuKey = '';
updateMenu();
log('[原生模式] 已退出');
}
// ==================================================================
// ============ 原网站模式:工具栏显隐控制 + 左右翻页 ================
// ==================================================================
let nativeBarVisible = false;
let nativeBarHideTimer = null;
function showNativeBar(autoHide) {
if (!nativeMode.barHost) return;
nativeBarVisible = true;
const sh = nativeMode.barHost.shadowRoot;
if (sh) {
const bar = sh.querySelector('.bar');
if (bar) bar.classList.add('visible');
}
if (autoHide) resetNativeBarTimer();
}
function hideNativeBar() {
nativeBarVisible = false;
if (nativeBarHideTimer) {
clearTimeout(nativeBarHideTimer);
nativeBarHideTimer = null;
}
if (!nativeMode.barHost) return;
const sh = nativeMode.barHost.shadowRoot;
if (sh) {
const bar = sh.querySelector('.bar');
if (bar) bar.classList.remove('visible');
}
}
function resetNativeBarTimer() {
if (nativeBarHideTimer) clearTimeout(nativeBarHideTimer);
nativeBarHideTimer = setTimeout(() => hideNativeBar(), NATIVE_BAR_AUTO_HIDE);
}
function toggleNativeBar() {
if (nativeBarVisible) hideNativeBar();
else showNativeBar(true);
}
// ---- 查找原网站真正的滚动容器 ----
function getNativeScrollContainer() {
const scrollingEl = document.scrollingElement || document.documentElement;
if (scrollingEl && scrollingEl.scrollHeight > scrollingEl.clientHeight + 20) {
return scrollingEl;
}
if (document.body && document.body.scrollHeight > document.body.clientHeight + 20) {
return document.body;
}
const all = document.querySelectorAll('div, main, article, section');
let best = null, bestScore = 0;
for (const el of all) {
const cs = getComputedStyle(el);
const oy = cs.overflowY;
if (oy !== 'auto' && oy !== 'scroll') continue;
const sh = el.scrollHeight;
const ch = el.clientHeight;
if (sh <= ch + 50) continue;
const w = el.clientWidth;
const score = (sh - ch) * w;
if (score > bestScore) { bestScore = score; best = el; }
}
return best || scrollingEl;
}
// ---- 原网站模式翻页(先滚动一屏,到顶/底才跳章节) ----
function nativeScrollBy(direction) {
const el = getNativeScrollContainer();
const viewH = el.clientHeight || window.innerHeight || document.documentElement.clientHeight;
const curTop = el.scrollTop;
const maxTop = Math.max(0, el.scrollHeight - viewH);
const step = Math.round(viewH * 0.88);
const EDGE = 8;
if (direction > 0) {
if (curTop >= maxTop - EDGE) {
if (typeof nativeMode.goNext === 'function') nativeMode.goNext();
return;
}
const target = Math.min(maxTop, curTop + step);
try {
el.scrollTo({ top: target, behavior: 'smooth' });
} catch (e) {
el.scrollTop = target;
}
} else {
if (curTop <= EDGE) {
if (typeof nativeMode.goPrev === 'function') nativeMode.goPrev();
return;
}
const target = Math.max(0, curTop - step);
try {
el.scrollTo({ top: target, behavior: 'smooth' });
} catch (e) {
el.scrollTop = target;
}
}
}
// ---- 原生模式点击手势 ----
const nativeTap = {
startX: 0, startY: 0, startTime: 0,
mouseX: 0, mouseY: 0, mouseTime: 0,
touchHandled: false, lastTouchEnd: 0,
installed: false,
};
function setupNativeTapZones() {
if (nativeTap.installed) return;
nativeTap.installed = true;
window.addEventListener('touchstart', e => {
if (!nativeMode.active) return;
if (e.touches.length !== 1) return;
const t = e.touches[0];
nativeTap.startX = t.clientX;
nativeTap.startY = t.clientY;
nativeTap.startTime = Date.now();
nativeTap.touchHandled = false;
}, true);
window.addEventListener('touchend', e => {
if (!nativeMode.active) return;
if (e.changedTouches.length !== 1) return;
const t = e.changedTouches[0];
const dx = Math.abs(t.clientX - nativeTap.startX);
const dy = Math.abs(t.clientY - nativeTap.startY);
if (dx > 10 || dy > 10) return;
if (Date.now() - nativeTap.startTime > 500) return;
nativeTap.touchHandled = true;
nativeTap.lastTouchEnd = Date.now();
handleNativeTap(t.clientX, t.clientY, e.target);
}, true);
window.addEventListener('mousedown', e => {
if (!nativeMode.active) return;
nativeTap.mouseX = e.clientX;
nativeTap.mouseY = e.clientY;
nativeTap.mouseTime = Date.now();
}, true);
window.addEventListener('click', e => {
if (!nativeMode.active) return;
if (nativeTap.touchHandled) { nativeTap.touchHandled = false; return; }
if (Date.now() - nativeTap.lastTouchEnd < 500) return;
const dx = Math.abs(e.clientX - nativeTap.mouseX);
const dy = Math.abs(e.clientY - nativeTap.mouseY);
if (dx > 10 || dy > 10) return;
if (Date.now() - nativeTap.mouseTime > 500) return;
handleNativeTap(e.clientX, e.clientY, e.target);
}, true);
}
function handleNativeTap(x, y, target) {
if (target && (target.id === 'ndr-native-bar-host' || target.id === 'ndr-native-toc-host')) return;
if (nativeMode.barHost) {
const r = nativeMode.barHost.getBoundingClientRect();
if (y >= r.top && y <= r.bottom) return;
}
if (nativeMode.tocHost) return;
let el = target;
if (el && el.closest) {
if (el.closest('button, a, input, textarea, select, [contenteditable="true"]')) return;
}
try {
const sel = window.getSelection();
if (sel && String(sel).trim().length > 0) return;
} catch (e) {}
const w = window.innerWidth || document.documentElement.clientWidth || 1;
const rel = x / w;
if (rel < CONFIG.tapLeftRatio) {
nativeScrollBy(-1);
} else if (rel > CONFIG.tapRightRatio) {
nativeScrollBy(1);
} else {
toggleNativeBar();
}
}
// ---- 构建工具栏 ----
function buildNativeBar() {
if (nativeMode.barHost) return;
const host = document.createElement('div');
host.id = 'ndr-native-bar-host';
host.style.cssText = 'position:fixed;top:0;left:0;right:0;height:46px;z-index:2147483647;pointer-events:none;';
document.documentElement.appendChild(host);
const sh = host.attachShadow({ mode: 'open' });
sh.innerHTML = `
`;
nativeMode.barHost = host;
const $ = id => sh.getElementById(id);
const titleText = (function () {
if (store.tocList && store.tocList.length) {
const cur = normalizeUrl(location.href);
for (let i = 0; i < store.tocList.length; i++) {
if (normalizeUrl(store.tocList[i].url) === cur) return store.tocList[i].title;
}
}
const h1 = document.querySelector('#chapter-title, .chapter-title, h1, h2, .title, #title');
if (h1) {
const t = (h1.textContent || '').trim().replace(/\s+/g, ' ');
if (t && t.length <= 60) return t;
}
return document.title || '未命名';
})();
$('title').textContent = titleText.length > 24 ? titleText.slice(0, 24) + '…' : titleText;
$('title').title = titleText;
function goPrev() {
let prev = findPrevUrl(document, location.href);
if (!prev && store.tocList && store.tocList.length) {
const cur = normalizeUrl(location.href);
const idx = store.tocList.findIndex(it => normalizeUrl(it.url) === cur);
if (idx > 0) prev = store.tocList[idx - 1].url;
}
if (prev) {
refreshNativeFlag();
location.href = prev;
} else {
alert('没有找到上一章');
}
}
function goNext() {
let next = findNextUrl(document, location.href);
if (!next && store.tocList && store.tocList.length) {
const cur = normalizeUrl(location.href);
const idx = store.tocList.findIndex(it => normalizeUrl(it.url) === cur);
if (idx >= 0 && idx + 1 < store.tocList.length) next = store.tocList[idx + 1].url;
}
if (next) {
refreshNativeFlag();
location.href = next;
} else {
alert('没有找到下一章');
}
}
nativeMode.goPrev = goPrev;
nativeMode.goNext = goNext;
function openNativeTOC() {
if (nativeMode.tocHost) {
try { nativeMode.tocHost.remove(); } catch (e) {}
nativeMode.tocHost = null;
return;
}
const tHost = document.createElement('div');
tHost.id = 'ndr-native-toc-host';
tHost.style.cssText = 'position:fixed;inset:0;z-index:2147483647;pointer-events:none;';
document.documentElement.appendChild(tHost);
const tSh = tHost.attachShadow({ mode: 'open' });
const items = (store.tocList && store.tocList.length) ? store.tocList : [];
const cur = normalizeUrl(location.href);
const sorted = items
.map((it, idx) => ({ it, idx, num: parseChapterNumber(it.title) }))
.sort((a, b) => {
const an = a.num, bn = b.num;
if (an === null && bn === null) return a.idx - b.idx;
if (an === null) return 1;
if (bn === null) return -1;
return an - bn;
})
.map(x => x.it);
let listHtml = '';
if (!sorted.length) {
listHtml = '暂无目录。
';
} else {
sorted.forEach(it => {
const active = normalizeUrl(it.url) === cur;
listHtml += `${escapeHtml(it.title)}`;
});
}
tSh.innerHTML = `
目录
${sorted.length} 章
${listHtml}
`;
nativeMode.tocHost = tHost;
const closeFn = () => {
if (nativeMode.tocHost) {
try { nativeMode.tocHost.remove(); } catch (e) {}
nativeMode.tocHost = null;
}
};
tSh.getElementById('backdrop').addEventListener('click', closeFn);
tSh.getElementById('close').addEventListener('click', closeFn);
tSh.querySelectorAll('.item').forEach(a => {
a.addEventListener('click', ev => {
ev.preventDefault();
const href = a.getAttribute('href');
if (href) {
refreshNativeFlag();
location.href = href;
}
});
});
const act = tSh.querySelector('.item.active');
if (act) act.scrollIntoView({ block: 'center' });
}
function changeNativeFont(delta) {
settings.fontSize = Math.max(12, Math.min(32, settings.fontSize + delta));
setSetting('fontSize', settings.fontSize);
applyNativeStyle();
}
function cycleNativeTheme() {
const order = ['light', 'sepia', 'dark'];
const i = order.indexOf(settings.theme);
settings.theme = order[(i + 1) % order.length];
setSetting('theme', settings.theme);
applyNativeStyle();
}
$('btn-toc').addEventListener('click', openNativeTOC);
$('btn-prev').addEventListener('click', goPrev);
$('btn-next').addEventListener('click', goNext);
$('btn-font-minus').addEventListener('click', () => changeNativeFont(-1));
$('btn-font-plus').addEventListener('click', () => changeNativeFont(1));
$('btn-theme').addEventListener('click', cycleNativeTheme);
$('btn-close').addEventListener('click', () => {
if (confirm('退出原网站模式?')) deactivateNativeMode();
});
sh.querySelector('.bar').addEventListener('click', () => {
if (nativeBarVisible) resetNativeBarTimer();
});
sh.querySelector('.bar').addEventListener('touchstart', () => {
if (nativeBarVisible) resetNativeBarTimer();
}, { passive: true });
}
function maybeRestoreNativeMode() {
if (!isNativeSaved()) return;
const s = loadSession();
if (s) {
store.bookName = s.bookName || '';
store.bookId = s.bookId || '';
}
activateNativeMode();
}
// ==================================================================
// ======================= 脚本阅读器 ==============================
// ==================================================================
let host, shadow, readerEl;
let pagedWrap, pagedInner, scrollWrap, scrollInner;
let tocEl, tocListEl, tocCountEl, bookTitleEl, viewportEl;
let readerOpen = false;
let currentIndex = -1;
let loadingIndex = -1;
let prepending = false;
let pendingLocator = null;
let pageIndex = 0, pageCount = 1, pageW = 0;
const PAGE_GAP = 48;
function forceScrollTop() {
if (!scrollWrap) return;
try {
scrollWrap.scrollTop = 0;
if (scrollInner) scrollInner.scrollTop = 0;
if (scrollInner && scrollInner.firstElementChild) {
scrollInner.firstElementChild.scrollIntoView({ block: 'start', behavior: 'auto' });
}
} catch (e) {}
}
const CSS = `
:host { all: initial; }
* { box-sizing: border-box; }
.reader {
--bg: #f7f5f0; --fg: #2b2b2b; --dim: #8a8a8a;
--border: #e4e0d7; --panel: #fff; --hover: rgba(128,128,128,.12);
position: fixed; inset: 0; width: 100vw; height: 100vh;
z-index: 2147483647; background: var(--bg); color: var(--fg);
display: none; flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 18px; line-height: 1.9;
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
}
.reader.open { display: flex; }
.reader.theme-dark { --bg:#16171a; --fg:#c9ccd1; --dim:#6b7078; --border:#2a2c31; --panel:#1e2024; --hover:rgba(255,255,255,.08); }
.reader.theme-sepia { --bg:#f4ecd8; --fg:#5b4636; --dim:#9a8b78; --border:#e0d5bd; --panel:#faf3e3; --hover:rgba(120,90,60,.1); }
.topbar {
flex: 0 0 auto; display: flex; align-items: center; gap: 2px;
padding: 6px 6px; border-bottom: 1px solid var(--border);
background: var(--bg); font-size: 14px; z-index: 20;
}
.topbar .btn { padding: 6px 7px; flex: 0 0 auto; }
.book-title {
flex: 1 1 auto; font-weight: 600; font-size: 13px; text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
padding: 0 4px; color: var(--fg); min-width: 0;
}
.btn {
background: transparent; border: none; color: var(--fg);
padding: 6px 10px; border-radius: 6px; cursor: pointer;
font-size: 14px; font-family: inherit; line-height: 1;
display: inline-flex; align-items: center; gap: 4px; transition: background .15s;
}
.btn:hover:not(:disabled) { background: var(--hover); }
.btn:disabled { opacity: .3; cursor: default; }
.viewport {
flex: 1 1 auto; position: relative; overflow: hidden;
cursor: pointer; -webkit-tap-highlight-color: transparent;
user-select: text; -webkit-user-select: text;
}
.paged-wrap { height: 100%; padding: 18px 28px 18px; overflow: hidden; position: relative; }
.paged-inner {
position: relative; height: 100%; column-fill: auto;
transition: transform .22s ease; will-change: transform; color: var(--fg);
}
.scroll-wrap { height: 100%; overflow-y: auto; overflow-x: hidden; scroll-behavior: auto; -webkit-overflow-scrolling: touch; }
.scroll-inner { position: relative; max-width: 780px; margin: 0 auto; padding: 18px 24px 80px; }
.scroll-wrap::-webkit-scrollbar { width: 8px; }
.scroll-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.ch-title {
font-size: 1.1em; font-weight: 600; margin: 0 0 0.4em; padding-bottom: 0.15em;
border-bottom: 1px solid var(--border); color: var(--fg); line-height: 1.3;
}
.ch-title + p { margin-top: 0; }
p { margin: 0 0 1em; text-indent: 2em; word-break: break-word; overflow-wrap: anywhere; }
.empty { color: var(--dim); text-align: center; padding: 60px 20px; font-size: 15px; line-height: 2; }
.spinner {
display: inline-block; width: 18px; height: 18px;
border: 2px solid var(--border); border-top-color: var(--dim);
border-radius: 50%; animation: spin .8s linear infinite;
vertical-align: middle; margin-right: 8px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.toc {
position: absolute; top: 0; bottom: 0; left: 0; width: 320px; max-width: 85%;
background: var(--panel); border-right: 1px solid var(--border);
transform: translateX(-100%); transition: transform .22s ease;
display: flex; flex-direction: column; z-index: 15;
}
.toc.open { transform: translateX(0); }
.toc-head {
display: flex; align-items: center; gap: 8px;
padding: 12px 14px; border-bottom: 1px solid var(--border);
font-weight: 600; font-size: 14px; color: var(--fg);
}
.toc-count { flex: 1 1 auto; font-size: 12px; color: var(--dim); font-weight: 400; }
.toc-list { flex: 1 1 auto; overflow-y: auto; padding: 6px 0; }
.toc-list::-webkit-scrollbar { width: 6px; }
.toc-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.toc-item {
padding: 9px 16px; cursor: pointer; font-size: 13.5px; color: var(--fg);
line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.toc-item:hover { background: var(--hover); }
.toc-item.active { color: #4a90e2; font-weight: 600; background: var(--hover); }
.toc-item.loading { color: var(--dim); font-style: italic; cursor: default; }
.toc-backdrop {
position: absolute; inset: 0; background: rgba(0,0,0,.3);
opacity: 0; pointer-events: none; transition: opacity .22s; z-index: 14;
}
.toc-backdrop.open { opacity: 1; pointer-events: auto; }
`;
function buildReader() {
host = document.createElement('div');
host.id = 'ndr-host';
host.style.cssText = 'position:fixed;inset:0;z-index:2147483647;display:none;pointer-events:none;';
(document.body || document.documentElement).appendChild(host);
shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
`;
const $ = id => shadow.getElementById(id);
readerEl = $('reader');
viewportEl = $('viewport');
pagedWrap = $('paged-wrap');
pagedInner = $('paged-inner');
scrollWrap = $('scroll-wrap');
scrollInner = $('scroll-inner');
tocEl = $('toc');
tocListEl = $('toc-list');
tocCountEl = $('toc-count');
bookTitleEl = $('book-title');
const tocBackdrop = $('toc-backdrop');
tocBackdrop.addEventListener('click', closeTOC);
$('btn-close').addEventListener('click', e => { e.stopPropagation(); closeReader(); });
$('btn-toc').addEventListener('click', e => { e.stopPropagation(); toggleTOC(); });
$('btn-toc-close').addEventListener('click', e => { e.stopPropagation(); closeTOC(); });
$('btn-prev').addEventListener('click', e => { e.stopPropagation(); gotoChapter(currentIndex - 1, false); });
$('btn-next').addEventListener('click', e => { e.stopPropagation(); gotoChapter(currentIndex + 1, false); });
$('btn-font-plus').addEventListener('click', e => { e.stopPropagation(); changeFont(1); });
$('btn-font-minus').addEventListener('click', e => { e.stopPropagation(); changeFont(-1); });
$('btn-theme').addEventListener('click', e => { e.stopPropagation(); cycleTheme(); });
$('btn-mode').addEventListener('click', e => { e.stopPropagation(); toggleMode(); });
setupTapZones();
document.addEventListener('keydown', onKeydown, true);
scrollWrap.addEventListener('scroll', () => {
if (settings.mode === 'scroll') scheduleSave();
}, { passive: true });
let rzTimer = null;
window.addEventListener('resize', () => {
if (!readerOpen) return;
clearTimeout(rzTimer);
rzTimer = setTimeout(() => { if (settings.mode === 'page') relayout(); }, 150);
});
applyTheme();
applyFontSize();
applyMode();
}
// ==================== 点击 ====================
let touchStartX = 0, touchStartY = 0, touchStartTime = 0;
let touchHandled = false, lastTouchEndTime = 0;
let mouseStartX = 0, mouseStartY = 0, mouseStartTime = 0;
function setupTapZones() {
window.addEventListener('touchstart', e => {
if (!readerOpen) return;
if (e.touches.length !== 1) return;
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchStartTime = Date.now();
touchHandled = false;
}, true);
window.addEventListener('touchend', e => {
if (!readerOpen) return;
if (e.changedTouches.length !== 1) return;
const t = e.changedTouches[0];
const dx = Math.abs(t.clientX - touchStartX);
const dy = Math.abs(t.clientY - touchStartY);
if (dx > 10 || dy > 10) return;
if (Date.now() - touchStartTime > 500) return;
touchHandled = true;
lastTouchEndTime = Date.now();
handleTapAt(t.clientX, t.clientY);
}, true);
window.addEventListener('click', e => {
if (!readerOpen) return;
if (touchHandled) { touchHandled = false; return; }
if (Date.now() - lastTouchEndTime < 500) return;
if (mouseStartTime > 0) {
const dx = Math.abs(e.clientX - mouseStartX);
const dy = Math.abs(e.clientY - mouseStartY);
if (dx > 10 || dy > 10) return;
if (Date.now() - mouseStartTime > 500) return;
}
handleTapAt(e.clientX, e.clientY);
}, true);
window.addEventListener('mousedown', e => {
if (!readerOpen) return;
mouseStartX = e.clientX;
mouseStartY = e.clientY;
mouseStartTime = Date.now();
}, true);
}
function handleTapAt(x, y) {
if (tocEl.classList.contains('open')) return;
const r = viewportEl.getBoundingClientRect();
if (x < r.left || x > r.right) return;
if (y < r.top || y > r.bottom) return;
let elAtPoint = null;
try { if (shadow.elementFromPoint) elAtPoint = shadow.elementFromPoint(x, y); } catch (err) {}
if (elAtPoint && elAtPoint.closest) {
if (elAtPoint.closest('button, a, input, textarea, select')) return;
}
try {
const sel = shadow.getSelection ? shadow.getSelection() : window.getSelection();
if (sel && String(sel).trim().length > 0) return;
} catch (err) {}
const rel = (x - r.left) / r.width;
if (rel < CONFIG.tapLeftRatio) tapPrev();
else if (rel > CONFIG.tapRightRatio) tapNext();
}
function tapNext() {
if (settings.mode === 'page') pageNext();
else gotoChapter(currentIndex + 1, false);
}
function tapPrev() {
if (settings.mode === 'page') pagePrev();
else gotoChapter(currentIndex - 1, true);
}
// ==================== 主题/字号/模式 ====================
function applyTheme() {
readerEl.classList.remove('theme-dark', 'theme-sepia');
if (settings.theme === 'dark') readerEl.classList.add('theme-dark');
else if (settings.theme === 'sepia') readerEl.classList.add('theme-sepia');
}
function applyFontSize() {
readerEl.style.fontSize = settings.fontSize + 'px';
readerEl.style.lineHeight = settings.lineHeight;
}
function applyMode() {
if (settings.mode === 'page') {
pagedWrap.style.display = '';
scrollWrap.style.display = 'none';
} else {
pagedWrap.style.display = 'none';
scrollWrap.style.display = '';
}
}
function changeFont(d) {
settings.fontSize = Math.max(12, Math.min(32, settings.fontSize + d));
setSetting('fontSize', settings.fontSize);
applyFontSize();
if (currentIndex >= 0 && store.chapters[currentIndex] && store.chapters[currentIndex].content) {
const savedLocator = captureLocator();
renderChapter(currentIndex, false);
if (savedLocator && savedLocator.length) setTimeout(() => applyLocator(savedLocator), 60);
}
}
function cycleTheme() {
const order = ['light', 'sepia', 'dark'];
const i = order.indexOf(settings.theme);
settings.theme = order[(i + 1) % order.length];
setSetting('theme', settings.theme);
applyTheme();
}
function toggleMode() {
settings.mode = settings.mode === 'page' ? 'scroll' : 'page';
setSetting('mode', settings.mode);
applyMode();
if (currentIndex >= 0 && store.chapters[currentIndex] && store.chapters[currentIndex].content) {
const savedLocator = captureLocator();
renderChapter(currentIndex);
if (savedLocator && savedLocator.length) setTimeout(() => applyLocator(savedLocator), 80);
}
}
// ==================== 定位 ====================
function splitSentences(text) {
text = String(text || '').trim();
if (!text) return [];
const out = [];
let buf = '';
for (let i = 0; i < text.length; i++) {
buf += text[i];
if ('。!?!?'.indexOf(text[i]) !== -1) {
const s = buf.trim();
if (s) out.push(s);
buf = '';
}
}
if (buf.trim()) out.push(buf.trim());
return out.filter(s => s.length >= 4);
}
function captureLocator() {
if (currentIndex < 0) return [];
const ch = store.chapters[currentIndex];
if (!ch || !ch.content) return [];
let target = null;
if (settings.mode === 'page') {
const ps = pagedInner.querySelectorAll('p');
if (!ps.length) return [];
const wrapRect = pagedWrap.getBoundingClientRect();
const cs = getComputedStyle(pagedWrap);
const viewLeft = wrapRect.left + (parseFloat(cs.paddingLeft) || 0);
const viewRight = viewLeft + pageW;
let best = null, bestLeft = Infinity;
for (const p of ps) {
const r = p.getBoundingClientRect();
if (r.right > viewLeft - 2 && r.left < viewRight) {
const d = Math.abs(r.left - viewLeft);
if (d < bestLeft) { bestLeft = d; best = p; }
}
}
if (!best) return [];
const bl = best.getBoundingClientRect().left;
let bt = Infinity;
for (const p of ps) {
const r = p.getBoundingClientRect();
if (Math.abs(r.left - bl) < 20 && r.top < bt) { bt = r.top; target = p; }
}
} else {
const ps = scrollInner.querySelectorAll('p');
if (!ps.length) return [];
const top = scrollWrap.scrollTop;
for (const p of ps) if (p.offsetTop + 60 >= top) { target = p; break; }
}
if (!target) return [];
return splitSentences((target.textContent || '').trim()).slice(0, 3);
}
function applyLocator(locator) {
if (!locator || !locator.length) return false;
const key = locator[0];
if (!key) return false;
const container = settings.mode === 'page' ? pagedInner : scrollInner;
const ps = container.querySelectorAll('p');
let target = null;
for (const p of ps) if ((p.textContent || '').indexOf(key) !== -1) { target = p; break; }
if (!target && locator[1]) for (const p of ps) if ((p.textContent || '').indexOf(locator[1]) !== -1) { target = p; break; }
if (!target) return false;
if (settings.mode === 'page') {
const idx = Math.floor(target.offsetLeft / (pageW + PAGE_GAP));
pageIndex = Math.max(0, Math.min(pageCount - 1, idx));
applyPageTransform();
} else {
scrollWrap.scrollTop = Math.max(0, target.offsetTop - 20);
}
return true;
}
// ==================== 打开/关闭 ====================
let origOverflow = null;
function openReader() {
if (readerOpen) return;
readerOpen = true;
host.style.display = 'block';
host.style.pointerEvents = 'auto';
readerEl.classList.add('open');
origOverflow = document.documentElement.style.overflow;
document.documentElement.style.overflow = 'hidden';
requestAnimationFrame(() => {
if (store.chapters.length === 0) renderWelcome();
else if (currentIndex >= 0 && store.chapters[currentIndex] && store.chapters[currentIndex].content) {
renderChapter(currentIndex, settings.mode === 'page');
}
forceScrollTop();
setTimeout(forceScrollTop, 30);
setTimeout(forceScrollTop, 120);
updateTOC();
updateMenu();
});
}
function closeReader() {
if (!readerOpen) return;
saveSessionNow();
readerOpen = false;
readerEl.classList.remove('open');
host.style.pointerEvents = 'none';
host.style.display = 'none';
document.documentElement.style.overflow = origOverflow || '';
closeTOC();
updateMenu();
}
// ==================== 目录侧栏 ====================
function toggleTOC() {
if (tocEl.classList.contains('open')) closeTOC();
else openTOC();
}
function openTOC() {
tocEl.classList.add('open');
shadow.getElementById('toc-backdrop').classList.add('open');
const active = tocListEl.querySelector('.toc-item.active');
if (active) active.scrollIntoView({ block: 'center' });
}
function closeTOC() {
tocEl.classList.remove('open');
shadow.getElementById('toc-backdrop').classList.remove('open');
}
function updateTOC() {
if (!tocListEl) return;
const frag = document.createDocumentFragment();
if (store.tocList && store.tocList.length) {
const sorted = store.tocList
.map((item, idx) => ({ item, idx, num: parseChapterNumber(item.title) }))
.sort((a, b) => {
const an = a.num, bn = b.num;
if (an === null && bn === null) return a.idx - b.idx;
if (an === null) return 1;
if (bn === null) return -1;
if (an !== bn) return an - bn;
return a.idx - b.idx;
})
.map(x => x.item);
sorted.forEach(item => {
const el = document.createElement('div');
const isActive = currentIndex >= 0 && store.chapters[currentIndex] &&
normalizeUrl(store.chapters[currentIndex].url) === normalizeUrl(item.url);
el.className = 'toc-item' + (isActive ? ' active' : '');
el.textContent = item.title;
el.addEventListener('click', () => {
const idx = store.chapters.findIndex(c => c && normalizeUrl(c.url) === normalizeUrl(item.url));
if (idx >= 0) gotoChapter(idx, false);
else loadByUrl(item.url);
closeTOC();
});
frag.appendChild(el);
});
} else {
store.chapters.forEach((ch, i) => {
if (!ch) return;
const item = document.createElement('div');
item.className = 'toc-item' + (i === currentIndex ? ' active' : '');
item.textContent = ch.title || `第${i + 1}条`;
item.addEventListener('click', () => { gotoChapter(i, false); closeTOC(); });
frag.appendChild(item);
});
if (loadingIndex >= 0) {
const item = document.createElement('div');
item.className = 'toc-item loading';
item.textContent = '加载中…';
frag.appendChild(item);
}
}
tocListEl.innerHTML = '';
tocListEl.appendChild(frag);
const total = store.tocList && store.tocList.length
? store.tocList.length
: store.chapters.filter(c => c && c.content).length;
tocCountEl.textContent = `${total} 章${store.finished ? ' · 完' : ''}`;
}
// ==================== 渲染 ====================
function contentToHtml(text, title) {
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
let startIdx = 0;
if (lines.length && title && lines[0].indexOf(title) === 0) startIdx = 1;
const body = lines.slice(startIdx);
return body.map(l => `${escapeHtml(l)}
`).join('');
}
function renderWelcome() {
const html = `
📖 还没有内容
请点击油猴图标 →「📖 从本章开始阅读」
`;
pagedInner.innerHTML = html;
scrollInner.innerHTML = html;
currentIndex = -1;
updateTitleBar();
updateTOC();
}
function renderLoading(msg) {
const html = `${escapeHtml(msg)}
`;
if (settings.mode === 'page') pagedInner.innerHTML = html;
else scrollInner.innerHTML = html;
updateTitleBar();
}
function renderChapter(i, toLastPage) {
if (i < 0 || i >= store.chapters.length) return;
const ch = store.chapters[i];
if (!ch || !ch.content) return;
currentIndex = i;
const titleHtml = ch.title ? `${escapeHtml(ch.title)}
` : '';
const html = `${titleHtml}${contentToHtml(ch.content, ch.title)}`;
const locatorToApply = pendingLocator;
pendingLocator = null;
if (settings.mode === 'page') {
pagedInner.innerHTML = html;
pagedWrap.style.display = '';
scrollWrap.style.display = 'none';
pageIndex = 0;
requestAnimationFrame(() => {
relayout();
if (locatorToApply) applyLocator(locatorToApply);
else if (toLastPage && pageCount > 1) {
pageIndex = pageCount - 1;
applyPageTransform();
}
scheduleSave();
});
} else {
scrollInner.innerHTML = html;
scrollWrap.style.display = '';
pagedWrap.style.display = 'none';
forceScrollTop();
requestAnimationFrame(() => {
forceScrollTop();
if (locatorToApply) applyLocator(locatorToApply);
else {
setTimeout(forceScrollTop, 30);
setTimeout(forceScrollTop, 120);
setTimeout(forceScrollTop, 300);
}
scheduleSave();
});
}
updateTitleBar();
updateTOC();
}
// ==================== 章节切换 ====================
async function gotoChapter(i, toLastPage) {
if (i >= store.chapters.length) {
const last = store.chapters[store.chapters.length - 1];
if (!last) return;
if (store.finished) return;
await appendChapter(last.url, last.doc, toLastPage);
return;
}
if (i < 0) {
const first = store.chapters[0];
if (!first) return;
await prependChapterFromDOM(first, toLastPage);
return;
}
saveSessionNow();
pendingLocator = null;
if (store.chapters[i] && store.chapters[i].content) {
renderChapter(i, toLastPage);
} else {
await ensureChapter(i, { toLastPage: toLastPage });
}
}
async function appendChapter(prevUrl, prevDoc, toLastPage) {
if (loadingIndex >= 0) return;
loadingIndex = store.chapters.length;
renderLoading('正在查找下一章…');
updateTOC();
try {
const prevContent = store.chapters[store.chapters.length - 1].content;
const result = await resolveNextUrl(prevDoc, prevUrl, prevContent);
if (result && result.url) {
const html = await gmFetch(result.url);
const doc = toDoc(html, result.url);
const content = extractContent(doc);
const title = extractTitle(doc, store.chapters.length, content);
const bookName = extractBookName(doc);
const entry = { title, content, url: result.url, doc };
store.chapters.push(entry);
if (!store.bookName && bookName) store.bookName = bookName;
if (!store.bookId) store.bookId = extractBookId(result.url);
loadingIndex = -1;
renderChapter(store.chapters.length - 1, toLastPage);
updateMenu();
} else if (result && result.ended) {
loadingIndex = -1;
store.finished = true;
log('[下一章] 判定已完结');
renderChapter(currentIndex, false);
updateTitleBar();
updateTOC();
updateMenu();
if (typeof GM_notification === 'function') {
try {
GM_notification({
title: '《' + (store.bookName || '本书') + '》已读完',
text: '已经到最后一章了',
timeout: 4000,
});
} catch (e) {}
}
} else {
loadingIndex = -1;
log('找不到下一章');
renderChapter(currentIndex, false);
alert('找不到下一章。可能是网站结构特殊,或网络异常。\n可以尝试:\n1. 打开目录,手动点下一章\n2. 检查网络');
}
} catch (e) {
loadingIndex = -1;
log('加载下一章失败:' + e.message);
renderChapter(currentIndex, false);
}
}
async function prependChapterFromDOM(firstCh, toLastPage) {
if (prepending) return;
prepending = true;
saveSessionNow();
pendingLocator = null;
renderLoading('正在加载上一章…');
try {
let prevUrl = await resolvePrevUrl(firstCh.doc, firstCh.url);
if (!prevUrl) {
prepending = false;
log('找不到上一章');
renderChapter(currentIndex, false);
return;
}
const html = await gmFetch(prevUrl);
const doc = toDoc(html, prevUrl);
const content = extractContent(doc);
const title = extractTitle(doc, 0, content);
const bookName = extractBookName(doc);
const entry = { title, content, url: prevUrl, doc };
store.chapters.unshift(entry);
if (currentIndex >= 0) currentIndex++;
if (!store.bookName && bookName) store.bookName = bookName;
if (!store.bookId) store.bookId = extractBookId(prevUrl);
prepending = false;
renderChapter(0, toLastPage);
updateMenu();
} catch (e) {
prepending = false;
log('加载上一章失败:' + e.message);
renderChapter(currentIndex, false);
}
}
async function loadByUrl(url) {
url = normalizeUrl(url);
const idx = store.chapters.findIndex(c => c && normalizeUrl(c.url) === url);
if (idx >= 0) { gotoChapter(idx, false); return; }
loadingIndex = store.chapters.length;
renderLoading('正在加载…');
updateTOC();
try {
const html = await gmFetch(url);
const doc = toDoc(html, url);
const content = extractContent(doc);
const title = extractTitle(doc, store.chapters.length, content);
const bookName = extractBookName(doc);
const entry = { title, content, url, doc };
store.chapters.push(entry);
if (!store.bookName && bookName) store.bookName = bookName;
if (!store.bookId) store.bookId = extractBookId(url);
loadingIndex = -1;
renderChapter(store.chapters.length - 1, false);
updateMenu();
} catch (e) {
loadingIndex = -1;
log('加载失败:' + e.message);
renderLoading('加载失败:' + e.message);
}
}
// ==================== 分页 ====================
function relayout() {
if (settings.mode !== 'page') return;
if (!pagedInner.innerHTML) return;
const cs = getComputedStyle(pagedWrap);
const padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
const innerW = Math.max(100, pagedWrap.clientWidth - padX);
const innerH = Math.max(100, pagedWrap.clientHeight - padY);
pagedInner.style.columnWidth = innerW + 'px';
pagedInner.style.columnGap = PAGE_GAP + 'px';
pagedInner.style.height = innerH + 'px';
void pagedInner.offsetWidth;
pageW = innerW;
const totalW = pagedInner.scrollWidth;
pageCount = Math.max(1, Math.round((totalW + PAGE_GAP) / (innerW + PAGE_GAP)));
if (pageIndex >= pageCount) pageIndex = pageCount - 1;
if (pageIndex < 0) pageIndex = 0;
applyPageTransform();
}
function applyPageTransform() {
const x = -pageIndex * (pageW + PAGE_GAP);
pagedInner.style.transform = `translateX(${x}px)`;
updateTitleBar();
scheduleSave();
}
function pageNext() {
if (settings.mode !== 'page') return;
if (pageIndex < pageCount - 1) {
pageIndex++;
applyPageTransform();
} else {
gotoChapter(currentIndex + 1, false);
}
}
function pagePrev() {
if (settings.mode !== 'page') return;
if (pageIndex > 0) {
pageIndex--;
applyPageTransform();
} else {
gotoChapter(currentIndex - 1, true);
}
}
// ==================== 顶栏标题 ====================
function updateTitleBar() {
if (!bookTitleEl) return;
const ch = store.chapters[currentIndex];
const isLast = store.finished && currentIndex >= store.chapters.length - 1;
let s;
if (ch && ch.content) {
s = ch.title || '(未识别标题)';
if (settings.mode === 'page' && pageCount > 1) s += ` · ${pageIndex + 1}/${pageCount}`;
if (isLast) s += ' · 完';
else if (store.fetching) s += ' · 缓存中';
} else if (store.fetching) {
s = '加载中…';
} else {
s = store.bookName || '未命名';
}
bookTitleEl.textContent = s;
bookTitleEl.title = (store.bookName ? `《${store.bookName}》 ` : '') + s;
const prev = shadow.getElementById('btn-prev');
const next = shadow.getElementById('btn-next');
if (prev) prev.disabled = currentIndex <= 0 && store.chapters.length <= 1;
if (next) next.disabled = isLast;
}
// ==================== 键盘 ====================
function onKeydown(e) {
if (!readerOpen) return;
const tag = (e.target && e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea') return;
switch (e.key) {
case 'Escape':
if (tocEl.classList.contains('open')) closeTOC();
else closeReader();
e.preventDefault();
break;
case 'ArrowRight':
if (settings.mode === 'page') pageNext();
else gotoChapter(currentIndex + 1, false);
e.preventDefault();
break;
case 'ArrowLeft':
if (settings.mode === 'page') pagePrev();
else gotoChapter(currentIndex - 1, true);
e.preventDefault();
break;
case 'ArrowDown':
case 'PageDown':
case ' ':
if (settings.mode === 'scroll') {
scrollWrap.scrollBy({ top: scrollWrap.clientHeight * 0.9, behavior: 'smooth' });
e.preventDefault();
} else if (e.key === ' ') {
pageNext();
e.preventDefault();
}
break;
case 'ArrowUp':
case 'PageUp':
if (settings.mode === 'scroll') {
scrollWrap.scrollBy({ top: -scrollWrap.clientHeight * 0.9, behavior: 'smooth' });
e.preventDefault();
}
break;
case 'Home':
gotoChapter(0, false);
e.preventDefault();
break;
case 'End':
if (store.chapters.length) gotoChapter(store.chapters.length - 1, false);
e.preventDefault();
break;
}
}
// ==================== 抓取入口 ====================
async function ensureChapter(i, opts) {
opts = opts || {};
if (i < 0) return;
if (i === loadingIndex) return;
const cur = store.chapters[i];
if (cur && cur.content) { renderChapter(i, opts.toLastPage); return; }
let url = cur ? cur.url : null;
if (!url && opts.url) url = opts.url;
if (!url) { renderLoading(`第 ${i + 1} 条无法定位`); return; }
loadingIndex = i;
renderLoading('正在加载…');
updateTOC();
try {
const html = await gmFetch(url);
const doc = toDoc(html, url);
const content = extractContent(doc);
const title = extractTitle(doc, i, content);
const bookName = extractBookName(doc);
const entry = { title, content, url, doc };
if (store.chapters[i]) Object.assign(store.chapters[i], entry);
else store.chapters[i] = entry;
if (!store.bookName && bookName) store.bookName = bookName;
if (!store.bookId) store.bookId = extractBookId(url);
loadingIndex = -1;
renderChapter(i, opts.toLastPage);
updateMenu();
} catch (e) {
loadingIndex = -1;
log('加载失败:' + e.message);
renderLoading('加载失败:' + e.message);
}
}
// ---- 从本章开始阅读(先退出原生模式) ----
function openFromCurrent() {
if (nativeMode.active) deactivateNativeMode();
const url = normalizeUrl(location.href);
startReading(url, {});
}
async function startReading(url, opts) {
opts = opts || {};
store.bookName = opts.bookName || '';
store.bookId = extractBookId(url);
store.tocUrl = '';
store.tocList = [];
store.chapters = [];
store.fetching = false;
store.finished = false;
store.stopped = false;
currentIndex = -1;
loadingIndex = -1;
prepending = false;
pendingLocator = opts.locator || null;
openReader();
renderLoading('正在加载…');
updateTOC();
updateMenu();
await ensureChapter(0, { url: url });
}
// ---- 继续阅读(先退出原生模式) ----
function resumeReading() {
const s = loadSession();
if (!s || !s.url) { alert('没有可恢复的阅读进度。'); return; }
if (nativeMode.active) deactivateNativeMode();
startReading(s.url, {
locator: s.locator || null,
bookName: s.bookName || '',
});
}
async function startFullFetch(startUrl, userBookName) {
if (store.fetching) return;
if (nativeMode.active) deactivateNativeMode();
store.bookName = userBookName || '';
store.bookId = extractBookId(startUrl);
store.tocUrl = '';
store.tocList = [];
store.chapters = [];
store.fetching = true;
store.finished = false;
store.stopped = false;
currentIndex = -1;
loadingIndex = -1;
prepending = false;
pendingLocator = null;
openReader();
renderLoading('正在抓取…');
updateTOC();
updateMenu();
let url = normalizeUrl(startUrl);
const visited = new Set();
let prevDoc = null;
try {
while (url && store.chapters.length < CONFIG.maxChapters && !store.stopped) {
const key = normalizeUrl(url);
if (visited.has(key)) { log('检测到循环链接,停止'); break; }
visited.add(key);
let entry = null;
try {
const html = await gmFetch(url);
const doc = toDoc(html, url);
const content = extractContent(doc);
const title = extractTitle(doc, store.chapters.length, content);
const bookName = extractBookName(doc);
entry = { title, content, url, doc };
if (!store.bookName && bookName) store.bookName = bookName;
} catch (e) {
log('请求失败:' + e.message + ',2 秒后重试');
await sleep(2000);
try {
const html = await gmFetch(url);
const doc = toDoc(html, url);
const content = extractContent(doc);
const title = extractTitle(doc, store.chapters.length, content);
const bookName = extractBookName(doc);
entry = { title, content, url, doc };
if (!store.bookName && bookName) store.bookName = bookName;
} catch (e2) { log('重试仍失败:' + e2.message); }
}
if (entry) {
store.chapters.push(entry);
if (currentIndex === -1) renderChapter(0);
updateTOC();
updateTitleBar();
updateMenu();
prevDoc = entry.doc;
}
if (store.stopped) break;
const curContent = entry ? entry.content : '';
const result = prevDoc ? await resolveNextUrl(prevDoc, url, curContent) : null;
if (result && result.url) {
url = result.url;
} else if (result && result.ended) {
log('已完结,停止');
store.finished = true;
break;
} else {
log('找不到下一章,停止');
break;
}
await sleep(CONFIG.requestDelay + Math.random() * 400);
}
} catch (e) {
log('抓取出错:' + e.message);
}
store.fetching = false;
if (!store.finished) store.finished = true;
updateTOC();
updateTitleBar();
updateMenu();
scheduleSave();
if (typeof GM_notification === 'function') {
try {
GM_notification({
title: '小说抓取完成',
text: `《${store.bookName}》共 ${store.chapters.length} 章`,
timeout: 6000,
});
} catch (e) {}
}
}
// ==================== 导出 ====================
function exportTxt() {
const chapters = store.chapters.filter(c => c && c.content);
if (chapters.length === 0) { alert('还没有任何已加载的章节。'); return; }
let text = `${store.bookName || '小说'}\n\n`;
for (const ch of chapters) {
text += `\n\n${ch.title || ''}\n\n${ch.content}\n`;
}
const blob = new Blob(['\ufeff' + text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${(store.bookName || '小说').replace(/[\\/:*?"<>|]/g, '_').slice(0, 80)}.txt`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
// ==================== 菜单 ====================
const menuIds = {};
let lastMenuKey = '';
function updateMenu() {
const session = loadSession();
const hasLoaded = store.chapters.some(c => c && c.content);
const key = [
store.fetching, store.chapters.length, store.finished, store.stopped,
readerOpen, hasLoaded, store.tocList.length, nativeMode.active,
session ? session.time : 0, session ? session.url : ''
].join('|');
if (key === lastMenuKey) return;
lastMenuKey = key;
if (typeof GM_unregisterMenuCommand === 'function') {
Object.values(menuIds).forEach(id => {
try { GM_unregisterMenuCommand(id); } catch (e) {}
});
}
Object.keys(menuIds).forEach(k => delete menuIds[k]);
const reg = (label, fn) => {
try { return GM_registerMenuCommand(label, fn); } catch (e) { return null; }
};
// ============ 1. 阅读与进度组 ============
if (nativeMode.active) {
menuIds.exitNative = reg('🚪 退出原网站模式', deactivateNativeMode);
} else {
menuIds.openNative = reg('📄 用原网站模式阅读本章', activateNativeMode);
}
menuIds.open = reg('📖 从本章开始阅读', openFromCurrent);
if (session && session.url) {
const name = session.bookName || '小说';
const ch = session.title || '';
const label = ch
? `📖 继续阅读《${name}》· ${ch}`
: `📖 继续阅读《${name}》`;
menuIds.resume = reg(label, resumeReading);
}
menuIds.status = reg('📊 状态', showStatus);
if (readerOpen) {
menuIds.close = reg('📕 关闭阅读器', closeReader);
}
// ============ 2. 下载与抓取组 ============
if (store.fetching) {
menuIds.prog = reg(`⏳ 全本抓取中… 已 ${store.chapters.length} 章`, () => {});
menuIds.stop = reg('⏹ 停止抓取', () => { store.stopped = true; });
} else {
menuIds.fetchCur = reg('📥 以当前页为第一章抓取…', () => {
const url = location.href;
if (!url) return;
const book = prompt('书名(可留空自动识别):', '');
startFullFetch(url, (book || '').trim());
});
menuIds.fetchFirst = reg('📥 从第一章全本抓取…', () => {
const url = prompt('输入【第一章】网址(可留空使用当前页):', location.href);
if (!url || !url.trim()) return;
const book = prompt('书名(可留空自动识别):', '');
startFullFetch(url.trim(), (book || '').trim());
});
}
if (hasLoaded) {
menuIds.export = reg('💾 导出已加载章节 TXT', exportTxt);
}
}
// ---- 保留底层手动加载目录的方法,只是不在菜单显示 ----
async function manualLoadTOC() {
let baseUrl = location.href;
let baseDoc = document;
if (readerOpen && currentIndex >= 0 && store.chapters[currentIndex]) {
baseUrl = store.chapters[currentIndex].url;
baseDoc = store.chapters[currentIndex].doc || document;
}
let tocUrl = store.tocUrl || findTOCUrl(baseDoc, baseUrl);
if (!tocUrl) {
tocUrl = prompt('自动找不到目录页,请手动输入目录页 URL:', '');
if (!tocUrl) return;
}
try {
const list = await fetchTOCList(tocUrl);
if (list && list.length >= CONFIG.tocMinCount) {
store.tocUrl = tocUrl;
store.tocList = list;
if (readerOpen) updateTOC();
updateMenu();
alert(`目录加载成功,共 ${list.length} 章`);
} else {
alert('目录解析失败,或者章节数太少');
}
} catch (e) {
alert('抓目录失败:' + e.message);
}
}
function showStatus() {
const session = loadSession();
const lines = [];
lines.push(`书名:${store.bookName || '(未识别)'}`);
lines.push(`书ID:${store.bookId || '(未识别)'}`);
lines.push(`已加载:${store.chapters.filter(c => c && c.content).length} 章`);
lines.push(`目录缓存:${store.tocList.length} 条${store.tocUrl ? '(' + store.tocUrl + ')' : ''}`);
lines.push(`完结标记:${store.finished ? '是' : '否'}`);
lines.push(`当前模式:${nativeMode.active ? '原网站模式' : (readerOpen ? '脚本阅读器' : '普通页面')}`);
if (currentIndex >= 0) lines.push(`当前:第 ${currentIndex + 1} 条`);
lines.push(`状态:${store.fetching ? '全本抓取中' : (store.finished ? '已完结' : '边读边下')}`);
if (session) {
const when = new Date(session.time || 0).toLocaleString();
lines.push('');
lines.push(`存储的进度:`);
lines.push(` 《${session.bookName || '未命名'}》${session.title || ''}`);
lines.push(` 定位句:${(session.locator || []).join(' / ').slice(0, 80)}`);
lines.push(` 时间:${when}`);
}
alert(lines.join('\n'));
}
// ==================== 启动 ====================
buildReader();
updateMenu();
maybeRestoreNativeMode();
console.log('[小说追章阅读器 v7.5.7] 已加载。');
if (nativeMode.active) console.log('[原生模式] 当前处于原网站模式(左右翻页,翻到顶/底才跳章,中间呼出菜单)');
})();