// ==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('[原生模式] 当前处于原网站模式(左右翻页,翻到顶/底才跳章,中间呼出菜单)'); })();