// ==UserScript== // @name alicesw小说章节下载器 // @namespace https://www.alicesw.com/ // @version 1.9 // @description 在 alicesw.com 章节目录页批量下载TXT/合并整本TXT/合并整本EPUB,在章节详情页朗读小说或导出MP3(需本地Edge TTS服务)。v1.9: 增加长章节拆分控制、失败清单与“仅失败重跑” // @author zwy // @match https://www.alicesw.com/other/chapters/id/*.html // @match https://alicesw.com/other/chapters/id/*.html // @match https://www.alicesw.org/other/chapters/id/*.html // @match https://alicesw.org/other/chapters/id/*.html // @match https://www.alicesw.com/book/*/* // @match https://alicesw.com/book/*/* // @match https://www.alicesw.org/book/*/* // @match https://alicesw.org/book/*/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @connect www.alicesw.com // @connect alicesw.com // @connect www.alicesw.org // @connect alicesw.org // @connect localhost // @connect 127.0.0.1 // @run-at document-end // @updateURL https://raw.githubusercontent.com/zwy/userscripts/main/alicesw-novel-downloader/alicesw-novel-downloader.user.js // @downloadURL https://raw.githubusercontent.com/zwy/userscripts/main/alicesw-novel-downloader/alicesw-novel-downloader.user.js // ==/UserScript== (function () { 'use strict'; const TTS_SERVER = 'http://127.0.0.1:9898'; // ════════════════════════════════════════════════════ // 判断当前页面类型 // ════════════════════════════════════════════════════ const isChapterList = /\/other\/chapters\/id\//i.test(location.pathname); const isChapterPage = /\/book\/[^/]+\//i.test(location.pathname) && !isChapterList; // ════════════════════════════════════════════════════ // 公共工具 // ════════════════════════════════════════════════════ const CONFIG = { delay: 1500, retryMax: 3, retryDelay: 3000 }; function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function safeFileName(str) { return str.replace(/[\\/:*?"<>|]/g, '_').substring(0, 80); } function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1500); } function downloadTxt(content, filename) { downloadBlob(new Blob(['\uFEFF' + content], { type: 'text/plain;charset=utf-8' }), filename); } function getBookTitle() { const crumbs = document.querySelectorAll('.bread-crumbs li a'); for (const a of crumbs) { if ((a.getAttribute('href') || '').startsWith('/novel/')) return a.textContent.trim(); } const m = document.title.match(/章节列表-(.+?)-/); return m ? m[1] : '未知书名'; } function extractChapters() { const BASE = location.origin; return Array.from(document.querySelectorAll('.mulu_list li a')).map((a, i) => ({ index: i + 1, name: a.textContent.trim().replace(/\s+/g, ' '), url: a.href.startsWith('http') ? a.href : BASE + a.getAttribute('href') })); } // CORE_START function extractChapterSeq(name) { const text = String(name || '').trim(); const m1 = text.match(/第\s*(\d+)\s*章/i); if (m1) return parseInt(m1[1], 10); const m2 = text.match(/^(\d{1,5})(?:\D|$)/); if (m2) return parseInt(m2[1], 10); return null; } function normalizeChapterLabel(chapter, orderIndex) { const extracted = extractChapterSeq(chapter && chapter.name); const seq = extracted > 0 ? extracted : orderIndex; return { ...chapter, seq, seqPadded: String(seq).padStart(4, '0') }; } function textLength(value) { return String(value || '').replace(/\s+/g, '').length; } const DEFAULT_SPLIT_CONFIG = Object.freeze({ splitThreshold: 3000, targetSize: 2000, mergeThreshold: 1000 }); function splitChapterByThreshold(title, paragraphs, options = {}) { const { splitThreshold = DEFAULT_SPLIT_CONFIG.splitThreshold, targetSize = DEFAULT_SPLIT_CONFIG.targetSize, mergeThreshold = DEFAULT_SPLIT_CONFIG.mergeThreshold } = options; const source = Array.isArray(paragraphs) ? paragraphs.filter(p => String(p || '').length > 0) : []; const totalLength = source.reduce((sum, paragraph) => sum + textLength(paragraph), 0); if (totalLength <= splitThreshold) { return [{ title, paragraphs: source.slice() }]; } const chunks = []; let current = []; let currentLength = 0; const flushCurrent = () => { if (!current.length) return; chunks.push(current); current = []; currentLength = 0; }; for (const paragraph of source) { current.push(paragraph); currentLength += textLength(paragraph); if (currentLength >= targetSize) { flushCurrent(); } } if (current.length) { const lastChunk = chunks[chunks.length - 1]; const lastChunkLength = lastChunk ? lastChunk.reduce((sum, paragraph) => sum + textLength(paragraph), 0) : 0; if (chunks.length && currentLength < mergeThreshold && lastChunkLength + currentLength <= splitThreshold) { chunks[chunks.length - 1].push(...current); } else { chunks.push(current); } } return chunks.map((chunk, index) => ({ title: `${title}【${index + 1}】`, paragraphs: chunk })); } function buildFailureParagraphs(chapter, error) { const reason = error && error.message ? error.message : '未知错误'; return [ '【本章获取失败】', `原因:${reason}`, '建议:使用“仅失败重跑”补齐后重新导出' ]; } async function runDownloadPipeline(targets, options = {}) { const retryRounds = Number.isInteger(options.retryRounds) ? options.retryRounds : 1; const fetcher = typeof options.fetcher === 'function' ? options.fetcher : async () => []; const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; const buildFailure = typeof options.buildFailureParagraphs === 'function' ? options.buildFailureParagraphs : buildFailureParagraphs; const shouldStop = typeof options.shouldStop === 'function' ? options.shouldStop : () => false; const pause = typeof options.pause === 'function' ? options.pause : async () => {}; const states = Array.from(targets || [], target => ({ target, paragraphs: null, error: null, success: false })); let completedAttempts = 0; let scheduledAttempts = states.length; const emitProgress = (payload) => { if (!onProgress) return; try { onProgress(payload); } catch (error) { console.error('[runDownloadPipeline:onProgress]', error); } }; const runSubset = async (indices, roundIndex) => { for (let attemptInRound = 0; attemptInRound < indices.length; attemptInRound++) { const index = indices[attemptInRound]; if (shouldStop()) return; const state = states[index]; let success = false; let error = null; try { state.paragraphs = await fetcher(state.target); state.error = null; state.success = true; success = true; } catch (err) { state.error = err; state.success = false; error = err; } completedAttempts += 1; emitProgress({ target: state.target, success, error, round: roundIndex + 1, attemptInRound: attemptInRound + 1, completedAttempts, scheduledAttempts, retryRound: roundIndex > 0 }); if (!shouldStop()) { await pause(state.target); } } }; await runSubset(states.map((_, index) => index), 0); for (let round = 0; round < retryRounds; round++) { const failedIndices = states .map((state, index) => (!state.success ? index : null)) .filter(index => index !== null); if (!failedIndices.length || shouldStop()) break; scheduledAttempts += failedIndices.length; await runSubset(failedIndices, round + 1); } const failedChapters = []; const resolvedChapters = states.map(state => { if (state.success) { return { ...state.target, paragraphs: Array.isArray(state.paragraphs) ? state.paragraphs : [], failed: false }; } const failedChapter = { ...state.target, error: state.error, paragraphs: buildFailure(state.target, state.error), failed: true }; failedChapters.push(failedChapter); return failedChapter; }); return { resolvedChapters, failedChapters }; } globalThis.__ALICESW_CORE__ = { extractChapterSeq, normalizeChapterLabel, splitChapterByThreshold, buildFailureParagraphs, runDownloadPipeline, DEFAULT_SPLIT_CONFIG }; // CORE_END function toMergedChapterTitle(chapter) { return `${chapter.seqPadded}_${chapter.name}`; } function expandMergedChapters(chapters, options = {}) { const splitEnabled = options.splitEnabled !== false; const splitConfig = options.splitConfig || DEFAULT_SPLIT_CONFIG; return chapters.flatMap((chapter, index) => { const normalized = normalizeChapterLabel(chapter, index + 1); const title = toMergedChapterTitle(normalized); const parts = normalized.failed || !splitEnabled ? [{ title, paragraphs: normalized.paragraphs }] : splitChapterByThreshold(title, normalized.paragraphs, splitConfig); return parts.map(part => ({ name: part.title, paragraphs: part.paragraphs })); }); } // ════════════════════════════════════════════════════ // 噪声词集合(动态加载时的占位文字) // ════════════════════════════════════════════════════ const NOISE = new Set([ '加载中...', '章节加载中...', '使用手机扫码阅读', '正在加载', '内容加载中', 'Loading...', '请稍候', '请稍等', '加载中', '' ]); // ════════════════════════════════════════════════════ // extractParagraphsFromEl: 从已渲染的 DOM 元素中提取正文段落 // 与 TTS 的 getFullText() 使用完全相同的逻辑 // ════════════════════════════════════════════════════ function extractParagraphsFromEl(el) { if (!el) return null; const clone = el.cloneNode(true); clone.querySelectorAll('script,style,ins,iframe,img,noscript,button').forEach(e => e.remove()); const ps = []; // 策略1:优先

标签 clone.querySelectorAll('p').forEach(p => { const t = p.textContent.trim(); if (t && t.length > 1 && !NOISE.has(t)) ps.push(t); }); // 策略2:

无内容时,从

直接文本节点提取 if (!ps.length) { clone.querySelectorAll('div').forEach(div => { const directText = Array.from(div.childNodes) .filter(n => n.nodeType === Node.TEXT_NODE) .map(n => n.textContent.trim()) .join(''); if (directText && directText.length > 5 && !NOISE.has(directText)) { ps.push(directText); } }); } // 策略3:终极 fallback,遍历所有文本节点 if (!ps.length) { const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT); let node; const seen = new Set(); while ((node = walker.nextNode())) { const t = node.textContent.trim(); if (t && t.length > 5 && !NOISE.has(t) && !seen.has(t)) { seen.add(t); ps.push(t); } } } return ps.length ? ps : null; } // ════════════════════════════════════════════════════ // fetchChapterParagraphs: 用 iframe 加载章节页面, // 等待 JS 渲染完成后读取 DOM,彻底解决正文为空问题 // ════════════════════════════════════════════════════ const CONTENT_SELECTORS = [ '.j_readContent', '.read-content', '.readContent', '#chapterContent', '.chapter-content', '[class*="readContent"]', '[class*="read-content"]', '[class*="chapter-content"]', '.content' ]; function fetchChapterParagraphs(url, retry = 0) { return new Promise((resolve, reject) => { // 创建不可见 iframe const iframe = document.createElement('iframe'); iframe.style.cssText = 'position:fixed;top:-9999px;left:-9999px;width:1024px;height:768px;opacity:0;pointer-events:none;z-index:-1;'; iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin'); document.body.appendChild(iframe); let settled = false; let poll = null; let hardTimeout = null; function clearTimers() { if (poll !== null) { clearInterval(poll); poll = null; } if (hardTimeout !== null) { clearTimeout(hardTimeout); hardTimeout = null; } } function cleanup() { clearTimers(); if (iframe.parentNode) document.body.removeChild(iframe); } function fail(err) { if (settled) return; settled = true; cleanup(); if (retry < CONFIG.retryMax) { setTimeout(() => fetchChapterParagraphs(url, retry + 1).then(resolve).catch(reject), CONFIG.retryDelay); } else { reject(err); } } function succeed(ps) { if (settled) return; settled = true; cleanup(); resolve(ps); } // 超时保护:15 秒 hardTimeout = setTimeout(() => fail(new Error('加载超时')), 15000); iframe.onload = () => { // 轮询等待正文 JS 渲染完成(最多等 8 秒,每 200ms 检查一次) let checkCount = 0; const MAX_CHECKS = 40; // 8000ms / 200ms poll = setInterval(() => { checkCount++; try { const doc = iframe.contentDocument || iframe.contentWindow.document; if (!doc || doc.readyState === 'loading') return; // 找正文容器 let el = null; for (const sel of CONTENT_SELECTORS) { const found = doc.querySelector(sel); if (found) { el = found; break; } } if (!el) { if (checkCount >= MAX_CHECKS) { fail(new Error('正文容器未找到')); } return; } // 检查内容是否已渲染(不是占位文字,且有实质内容) const rawText = el.textContent.trim(); const isPlaceholder = NOISE.has(rawText) || rawText === '章节加载中...' || rawText.length < 30; if (!isPlaceholder) { const ps = extractParagraphsFromEl(el); if (ps && ps.length > 0) { succeed(ps); } else { fail(new Error('正文提取失败')); } return; } // 还在加载,继续等待 if (checkCount >= MAX_CHECKS) { fail(new Error('正文加载超时')); } } catch (e) { // 跨域异常(理论上不会发生,因为是同域) fail(new Error('读取 iframe 内容失败: ' + e.message)); } }, 200); }; iframe.onerror = () => { fail(new Error('iframe 加载失败')); }; iframe.src = url; }); } // ════════════════════════════════════════════════════ // EPUB 生成工具(纯原生 ZIP 构建,无需任何外部库) // ════════════════════════════════════════════════════ function escapeXml(str) { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // CRC32 查表法(ZIP 规范要求) const CRC32_TABLE = (() => { const t = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let j = 0; j < 8; j++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[i] = c; } return t; })(); function crc32(buf) { let crc = 0xFFFFFFFF; for (let i = 0; i < buf.length; i++) crc = CRC32_TABLE[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8); return (crc ^ 0xFFFFFFFF) >>> 0; } // 构建标准 ZIP Blob(STORE 模式,无压缩) function buildZipBlob(files) { const enc = new TextEncoder(); const parts = [], cdEntries = []; let offset = 0; for (const { name, data } of files) { const nb = enc.encode(name); const d = data instanceof Uint8Array ? data : enc.encode(data); const c = crc32(d); const sz = d.length; const lh = new Uint8Array(30 + nb.length); const lv = new DataView(lh.buffer); lv.setUint32(0, 0x04034B50, true); lv.setUint16(4, 20, true); lv.setUint16(6, 0, true); lv.setUint16(8, 0, true); lv.setUint16(10, 0, true); lv.setUint16(12, 0, true); lv.setUint32(14, c, true); lv.setUint32(18, sz, true); lv.setUint32(22, sz, true); lv.setUint16(26, nb.length, true); lv.setUint16(28, 0, true); lh.set(nb, 30); const cd = new Uint8Array(46 + nb.length); const cv = new DataView(cd.buffer); cv.setUint32(0, 0x02014B50, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true); cv.setUint16(8, 0, true); cv.setUint16(10, 0, true); cv.setUint16(12, 0, true); cv.setUint16(14, 0, true); cv.setUint32(16, c, true); cv.setUint32(20, sz, true); cv.setUint32(24, sz, true); cv.setUint16(28, nb.length, true); cv.setUint16(30, 0, true); cv.setUint16(32, 0, true); cv.setUint16(34, 0, true); cv.setUint16(36, 0, true); cv.setUint32(38, 0, true); cv.setUint32(42, offset, true); cd.set(nb, 46); parts.push(lh, d); cdEntries.push(cd); offset += lh.length + d.length; } const cdSz = cdEntries.reduce((s, e) => s + e.length, 0); const eocd = new Uint8Array(22); const ev = new DataView(eocd.buffer); ev.setUint32(0, 0x06054B50, true); ev.setUint16(4, 0, true); ev.setUint16(6, 0, true); ev.setUint16(8, files.length, true); ev.setUint16(10, files.length, true); ev.setUint32(12, cdSz, true); ev.setUint32(16, offset, true); ev.setUint16(20, 0, true); return new Blob([...parts, ...cdEntries, eocd], { type: 'application/epub+zip' }); } function buildEpub(bookTitle, author, chapters) { const uid = 'urn:uuid:' + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); }); const now = new Date().toISOString().replace(/\.\d+Z$/, 'Z'); const files = []; files.push({ name: 'mimetype', data: 'application/epub+zip' }); files.push({ name: 'META-INF/container.xml', data: ` ` }); files.push({ name: 'OEBPS/title_page.xhtml', data: ` ${escapeXml(bookTitle)}

${escapeXml(bookTitle)}

${escapeXml(author)}

来源:alicesw.com

` }); files.push({ name: 'OEBPS/styles.css', data: `body { font-family: "Hiragino Sans GB", "Microsoft YaHei", sans-serif; line-height: 1.8; margin: 1em 1.5em; } h1 { font-size: 1.5em; text-align: center; margin: 2em 0 0.5em; } h2 { font-size: 1.2em; margin: 2em 0 1em; border-bottom: 1px solid #ccc; padding-bottom: 0.3em; } p { text-indent: 2em; margin: 0.4em 0; } .title-page { text-align: center; margin-top: 4em; } .title-page h1 { font-size: 2em; margin-bottom: 0.5em; } .title-page .author { font-size: 1.1em; color: #555; } .title-page .source { font-size: 0.9em; color: #999; margin-top: 1em; }` }); const chapterIds = []; chapters.forEach((ch, i) => { const id = `chapter_${String(i + 1).padStart(4, '0')}`; chapterIds.push(id); const paragraphsHtml = ch.paragraphs .map(p => `

${escapeXml(p)}

`) .join('\n'); files.push({ name: `OEBPS/${id}.xhtml`, data: ` ${escapeXml(ch.name)}

${escapeXml(ch.name)}

${paragraphsHtml} ` }); }); const manifestItems = [ ``, ``, ...chapterIds.map(id => ``) ].join('\n '); const spineItems = [ ``, ...chapterIds.map(id => ``) ].join('\n '); files.push({ name: 'OEBPS/content.opf', data: ` ${escapeXml(bookTitle)} ${escapeXml(author)} zh-CN ${uid} ${now} alicesw.com ${manifestItems} ${spineItems} ` }); const navPoints = [ ` ${escapeXml(bookTitle)} `, ...chapters.map((ch, i) => ` ${escapeXml(ch.name)} `) ].join('\n '); files.push({ name: 'OEBPS/toc.ncx', data: ` ${escapeXml(bookTitle)} ${navPoints} ` }); return buildZipBlob(files); } // ════════════════════════════════════════════════════ // TTS 工具 // ════════════════════════════════════════════════════ function checkTtsServer() { return new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', url: `${TTS_SERVER}/health`, timeout: 2000, onload(r) { resolve(r.status === 200); }, onerror() { resolve(false); }, ontimeout() { resolve(false); } }); }); } function fetchVoices() { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: `${TTS_SERVER}/voices`, onload(r) { try { resolve(JSON.parse(r.responseText)); } catch { resolve([]); } }, onerror() { resolve([]); } }); }); } function synthesize(text, voice, rate) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${TTS_SERVER}/tts`, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ text, voice, rate }), responseType: 'arraybuffer', onload(r) { if (r.status !== 200) return reject(new Error(`TTS服务返回 ${r.status}`)); resolve(r.response); }, onerror() { reject(new Error('无法连接TTS服务')); } }); }); } // ════════════════════════════════════════════════════ // ① 章节目录页逻辑(批量下载 TXT / 合并整本 TXT / 合并整本 EPUB) // ════════════════════════════════════════════════════ function initChapterListUI() { const fab = document.createElement('button'); fab.textContent = '📥 下载小说'; Object.assign(fab.style, { position:'fixed',bottom:'24px',left:'24px',zIndex:99999, padding:'10px 16px',background:'#059669',color:'#fff', border:'none',borderRadius:'8px',cursor:'pointer', fontSize:'14px',fontWeight:'bold', boxShadow:'0 4px 12px rgba(0,0,0,0.3)',transition:'background 0.2s' }); fab.onmouseenter = () => fab.style.background = '#047857'; fab.onmouseleave = () => fab.style.background = '#059669'; const panel = document.createElement('div'); Object.assign(panel.style, { display:'none',position:'fixed',bottom:'80px',left:'24px', zIndex:99998,width:'370px',background:'#fff',color:'#333', borderRadius:'12px',boxShadow:'0 8px 32px rgba(0,0,0,0.25)', padding:'20px',fontFamily:'system-ui,sans-serif',fontSize:'14px', maxHeight:'85vh',overflowY:'auto' }); panel.innerHTML = `
📥 小说章节下载器 v1.9

下载范围:

请求间隔: ms(建议≥1500)

选择下载模式:

`; document.body.appendChild(fab); document.body.appendChild(panel); const bookTitle = getBookTitle(); let chapters = [], isRunning = false, shouldStop = false, successCount = 0, failCount = 0; let lastFailedTargets = []; let lastMergedState = null; fab.addEventListener('click', () => { const open = panel.style.display === 'block'; panel.style.display = open ? 'none' : 'block'; if (!open && !chapters.length) { chapters = extractChapters(); document.getElementById('dlBookInfo').innerHTML = `书名:${bookTitle}
总章节数:${chapters.length} 章`; document.getElementById('dlTo').value = chapters.length; document.getElementById('dlFrom').value = 1; } }); document.getElementById('dlClose').addEventListener('click', () => { panel.style.display = 'none'; }); panel.querySelectorAll('input[name="dlRange"]').forEach(r => r.addEventListener('change', () => { document.getElementById('dlRangeInputs').style.display = r.value === 'range' ? 'flex' : 'none'; })); document.getElementById('dlSplitToggle').addEventListener('change', (e) => { document.getElementById('dlSplitInputs').style.opacity = e.target.checked ? '1' : '0.45'; }); document.getElementById('dlSplitToggle').dispatchEvent(new Event('change')); function log(msg, color = '#555') { const el = document.getElementById('dlLog'); const d = document.createElement('div'); d.style.color = color; d.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`; el.appendChild(d); el.scrollTop = el.scrollHeight; } function updateProgress(done, total, color = '#059669') { const pct = total > 0 ? Math.round(done / total * 100) : 0; document.getElementById('dlProgressBar').style.cssText += `;width:${pct}%;background:${color}`; document.getElementById('dlProgressPct').style.color = color; document.getElementById('dlProgressPct').textContent = pct + '%'; document.getElementById('dlProgressText').textContent = `已完成 ${done}/${total}(✅${successCount} ❌${failCount})`; } function getSplitSettings() { return { splitEnabled: document.getElementById('dlSplitToggle').checked, splitConfig: { splitThreshold: Math.max(1, parseInt(document.getElementById('dlSplitThreshold').value, 10) || 3000), targetSize: Math.max(1, parseInt(document.getElementById('dlSplitTarget').value, 10) || 2000), mergeThreshold: Math.max(1, parseInt(document.getElementById('dlSplitMerge').value, 10) || 1000) } }; } function escapeHtml(value) { return String(value || '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); } function renderFailedSummary(failedChapters) { const wrap = document.getElementById('dlFailedWrap'); const list = document.getElementById('dlFailedList'); if (!failedChapters || !failedChapters.length) { wrap.style.display = 'none'; list.innerHTML = ''; lastFailedTargets = []; return; } wrap.style.display = 'block'; list.innerHTML = failedChapters.map((ch, i) => { const reason = ch.error?.message ? ` — ${escapeHtml(ch.error.message)}` : ''; return `
${i + 1}. ${escapeHtml(ch.name)}${reason}
`; }).join(''); lastFailedTargets = failedChapters.map(ch => ({ ...ch })); } function chapterKey(chapter) { return chapter?.url || `${chapter?.index || ''}::${chapter?.name || ''}`; } function mergeChapterResults(baseChapters, updatedChapters) { const updateMap = new Map((updatedChapters || []).map(ch => [chapterKey(ch), ch])); return (baseChapters || []).map(ch => updateMap.get(chapterKey(ch)) || ch); } function getMergedChaptersForExport(resolvedChapters, splitSettings) { return expandMergedChapters(resolvedChapters, splitSettings); } function getMergedExportContext(mode) { if (mode === 'epub') { return { color: '#0369a1', label: '【合并整本EPUB】', exportName: `${safeFileName(bookTitle)}_完整版.epub`, exportChapters: (chaptersToExport) => { const blob = buildEpub(bookTitle, 'alicesw.com', chaptersToExport); downloadBlob(blob, `${safeFileName(bookTitle)}_完整版.epub`); } }; } return { color: '#7c3aed', label: '【合并整本TXT】', exportName: `${safeFileName(bookTitle)}_完整版.txt`, exportChapters: (chaptersToExport) => { const cover = `${bookTitle}\n\n作者:(alicesw.com)\n章节数:${chaptersToExport.length} 章\n\n${'━'.repeat(50)}\n`; const chunks = chaptersToExport.map(ch => `\n${ch.name}\n\n${ch.paragraphs.join('\n\n')}\n`); downloadTxt(cover + chunks.join('\n'), `${safeFileName(bookTitle)}_完整版.txt`); } }; } async function runMergedExport(mode, targets, retryOnly = false) { if (isRunning) return; const targetList = Array.isArray(targets) ? targets : getTargets(); if (!targetList.length) { alert('没有找到章节'); return; } const splitSettings = retryOnly && lastMergedState?.splitSettings ? lastMergedState.splitSettings : getSplitSettings(); const ctx = getMergedExportContext(mode); const delay = enterRunning(ctx.color); const actionLabel = retryOnly ? `${ctx.label}(仅失败重跑)` : ctx.label; log(`${actionLabel}《${bookTitle}》共 ${targetList.length} 章`, ctx.color); const pipeline = await runDownloadPipeline(targetList, { retryRounds: 1, fetcher: ch => fetchChapterParagraphs(ch.url), pause: () => sleep(delay), shouldStop: () => shouldStop, onProgress: ({ target: ch, success, error, completedAttempts, scheduledAttempts }) => { updateProgress(completedAttempts, scheduledAttempts, ctx.color); log(`${success ? '✅' : '❌'} ${ch.name}${success ? '' : ` — ${error?.message || '未知错误'}`}`, success ? '#059669' : '#ef4444'); } }); const resolvedChapters = retryOnly && lastMergedState ? mergeChapterResults(lastMergedState.resolvedChapters, pipeline.resolvedChapters) : pipeline.resolvedChapters; const mergedChapters = getMergedChaptersForExport(resolvedChapters, splitSettings); successCount = resolvedChapters.length - pipeline.failedChapters.length; failCount = pipeline.failedChapters.length; lastMergedState = { mode, resolvedChapters, splitSettings }; renderFailedSummary(pipeline.failedChapters); if (failCount > 0) { log(`⚠️ 仍有 ${failCount} 章失败,可点“仅失败重跑”继续补齐`, '#b45309'); } if (!shouldStop && mergedChapters.length) { if (mode === 'epub') { log(`📦 正在打包 EPUB,请稍候...`, '#0369a1'); try { ctx.exportChapters(mergedChapters); log(`📚 整本EPUB已生成:${ctx.exportName}`, '#0369a1'); log(`💡 可直接导入 Kindle、Apple Books、Moon+ Reader 等阅读器`, '#9ca3af'); } catch (e) { log(`❌ EPUB生成失败:${e.message}`, '#ef4444'); console.error('[alicesw-epub]', e); } } else { ctx.exportChapters(mergedChapters); log(`📖 整本TXT已生成:${ctx.exportName}`, '#7c3aed'); log(`💡 传到手机→番茄小说→书架→+→导入本地书籍`, '#9ca3af'); } } exitRunning(); log(`─── 完成!✅${successCount} ❌${failCount} ───`, '#1d4ed8'); } function getTargets() { if (!chapters.length) chapters = extractChapters(); const v = panel.querySelector('input[name="dlRange"]:checked').value; if (v === 'all') return chapters; const from = parseInt(document.getElementById('dlFrom').value) || 1; const to = parseInt(document.getElementById('dlTo').value) || chapters.length; return chapters.slice(from - 1, to); } function setButtonsVisible(visible) { document.getElementById('dlStart').style.display = visible ? 'block' : 'none'; document.getElementById('dlMerge').style.display = visible ? 'block' : 'none'; document.getElementById('dlEpub').style.display = visible ? 'block' : 'none'; document.getElementById('dlStop').style.display = visible ? 'none' : 'block'; document.getElementById('dlRetryFailed').style.display = visible && lastFailedTargets.length ? 'inline-block' : 'none'; } function enterRunning(color) { isRunning = true; shouldStop = false; successCount = 0; failCount = 0; CONFIG.delay = Math.max(500, parseInt(document.getElementById('dlDelay').value) || 1500); setButtonsVisible(false); document.getElementById('dlStop').disabled = false; document.getElementById('dlStop').textContent = '⏹\n停止'; document.getElementById('dlProgressWrap').style.display = 'block'; document.getElementById('dlLog').innerHTML = ''; document.getElementById('dlProgressBar').style.background = color; return CONFIG.delay; } function exitRunning() { isRunning = false; shouldStop = false; setButtonsVisible(true); } // 分章下载 document.getElementById('dlStart').addEventListener('click', async () => { if (isRunning) return; const targets = getTargets(); if (!targets.length) { alert('没有找到章节'); return; } const delay = enterRunning('#059669'); log(`【分章下载】《${bookTitle}》共 ${targets.length} 章,间隔 ${delay}ms`, '#059669'); for (let i = 0; i < targets.length; i++) { if (shouldStop) { log('⏹ 已停止', '#ef4444'); break; } const ch = targets[i]; log(`↓ [${i+1}/${targets.length}] ${ch.name}`); updateProgress(i, targets.length, '#059669'); try { const ps = await fetchChapterParagraphs(ch.url); const txt = `${ch.name}\n${'═'.repeat(50)}\n\n${ps.join('\n\n')}\n\n${'─'.repeat(50)}\n`; downloadTxt(txt, `${safeFileName(bookTitle)}_${String(ch.index).padStart(4,'0')}_${safeFileName(ch.name)}.txt`); successCount++; log(`✅ ${ch.name}`, '#059669'); } catch(e) { failCount++; log(`❌ ${ch.name} — ${e.message}`, '#ef4444'); } updateProgress(i+1, targets.length, '#059669'); if (i < targets.length-1 && !shouldStop) await sleep(delay); } exitRunning(); log(`─── 完成!✅${successCount} ❌${failCount} ───`, '#1d4ed8'); }); // 合并整本 TXT / EPUB document.getElementById('dlMerge').addEventListener('click', async () => { await runMergedExport('txt'); }); document.getElementById('dlEpub').addEventListener('click', async () => { await runMergedExport('epub'); }); document.getElementById('dlStop').addEventListener('click', () => { shouldStop = true; document.getElementById('dlStop').textContent = '停止中'; document.getElementById('dlStop').disabled = true; }); document.getElementById('dlRetryFailed').addEventListener('click', async () => { if (isRunning || !lastFailedTargets.length || !lastMergedState) return; await runMergedExport(lastMergedState.mode || 'txt', lastFailedTargets, true); }); } // ════════════════════════════════════════════════════ // ② 章节详情页逻辑(朗读 / 导出MP3) // ════════════════════════════════════════════════════ function initChapterPageUI() { checkTtsServer().then(async (online) => { const fab = document.createElement('button'); fab.textContent = '🔊 朗读小说'; Object.assign(fab.style, { position:'fixed',bottom:'24px',right:'24px',zIndex:99999, padding:'10px 16px',background: online ? '#2563eb' : '#9ca3af',color:'#fff', border:'none',borderRadius:'8px',cursor:'pointer', fontSize:'14px',fontWeight:'bold', boxShadow:'0 4px 12px rgba(0,0,0,0.3)',transition:'background 0.2s' }); const panel = document.createElement('div'); Object.assign(panel.style, { display:'none',position:'fixed',bottom:'80px',right:'24px', zIndex:99998,width:'320px',background:'#fff',color:'#333', borderRadius:'12px',boxShadow:'0 8px 32px rgba(0,0,0,0.25)', padding:'18px',fontFamily:'system-ui,sans-serif',fontSize:'14px' }); const chapterTitle = document.querySelector('h1.read-title, .read-top h1, h1')?.textContent.trim() || document.title; const contentEl = document.querySelector('.j_readContent, .read-content'); let voiceList = []; if (online) voiceList = await fetchVoices(); const voiceOptions = voiceList.length ? voiceList.map(v => ``).join('') : ` `; panel.innerHTML = `
🔊 朗读小说
${ !online ? `
⚠️ 未检测到本地TTS服务,请先启动 start.bat
查看安装说明
` : '
✅ TTS服务在线
' }
正常
`; document.body.appendChild(fab); document.body.appendChild(panel); let audio = null; fab.addEventListener('click', () => { panel.style.display = panel.style.display === 'block' ? 'none' : 'block'; }); document.getElementById('ttsClose').addEventListener('click', () => { panel.style.display = 'none'; if (audio) { audio.pause(); audio = null; } }); document.getElementById('ttsRate').addEventListener('input', function() { const v = parseInt(this.value); const lbl = v === 0 ? '正常' : (v > 0 ? `+${v}%` : `${v}%`); document.getElementById('ttsRateLabel').textContent = lbl; }); function getRate() { const v = parseInt(document.getElementById('ttsRate').value); return v >= 0 ? `+${v}%` : `${v}%`; } function getVoice() { return document.getElementById('ttsVoice').value; } function setStatus(msg, color = '#6b7280') { const el = document.getElementById('ttsStatus'); el.style.color = color; el.textContent = msg; } function getFullText() { if (!contentEl) return ''; const ps = extractParagraphsFromEl(contentEl); return ps ? ps.join('\n\n') : ''; } document.getElementById('ttsPlay').addEventListener('click', async () => { const text = getFullText(); if (!text) { setStatus('未找到正文内容', '#ef4444'); return; } setStatus('正在合成语音,请稍候...', '#2563eb'); document.getElementById('ttsPlay').style.display = 'none'; try { const buf = await synthesize(chapterTitle + '\n\n' + text, getVoice(), getRate()); const blob = new Blob([buf], { type: 'audio/mpeg' }); const url = URL.createObjectURL(blob); if (audio) audio.pause(); audio = new Audio(url); audio.play(); document.getElementById('ttsPause').style.display = 'block'; document.getElementById('ttsStop2').style.display = 'block'; setStatus('▶ 朗读中...', '#059669'); audio.onended = () => { setStatus('✅ 朗读完毕'); document.getElementById('ttsPlay').style.display = 'block'; document.getElementById('ttsPause').style.display = 'none'; document.getElementById('ttsStop2').style.display = 'none'; URL.revokeObjectURL(url); }; } catch(e) { setStatus('❌ ' + e.message, '#ef4444'); document.getElementById('ttsPlay').style.display = 'block'; } }); document.getElementById('ttsPause').addEventListener('click', function() { if (!audio) return; if (audio.paused) { audio.play(); this.textContent = '⏸ 暂停'; setStatus('▶ 朗读中...', '#059669'); } else { audio.pause(); this.textContent = '▶ 继续'; setStatus('⏸ 已暂停'); } }); document.getElementById('ttsStop2').addEventListener('click', () => { if (audio) { audio.pause(); audio = null; } document.getElementById('ttsPlay').style.display = 'block'; document.getElementById('ttsPause').style.display = 'none'; document.getElementById('ttsStop2').style.display = 'none'; setStatus('⏹ 已停止'); }); document.getElementById('ttsExport').addEventListener('click', async () => { const text = getFullText(); if (!text) { setStatus('未找到正文内容', '#ef4444'); return; } document.getElementById('ttsExport').disabled = true; document.getElementById('ttsExport').textContent = '⏳ 合成中...'; setStatus('正在合成MP3,请稍候...', '#d97706'); try { const buf = await synthesize(chapterTitle + '\n\n' + text, getVoice(), getRate()); const blob = new Blob([buf], { type: 'audio/mpeg' }); downloadBlob(blob, `${safeFileName(chapterTitle)}.mp3`); setStatus('✅ MP3已下载', '#059669'); } catch(e) { setStatus('❌ ' + e.message, '#ef4444'); } finally { document.getElementById('ttsExport').disabled = false; document.getElementById('ttsExport').textContent = '🎵 导出 MP3'; } }); }); } // ════════════════════════════════════════════════════ // 入口 // ════════════════════════════════════════════════════ if (isChapterList) initChapterListUI(); if (isChapterPage) initChapterPageUI(); })();