// ==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();
})();