// ==UserScript==
// @name 哔哩轻小说打包下载器
// @namespace https://github.com/202310011168/bili-novel-downloader
// @version 4.1.2
// @updateURL https://raw.githubusercontent.com/202310011168/bili-novel-downloader/master/bili-novel-downloader.user.js
// @downloadURL https://raw.githubusercontent.com/202310011168/bili-novel-downloader/master/bili-novel-downloader.user.js
// @description 将哔哩轻小说(linovelib.com/bilinovel.com)打包为EPUB电子书。支持分卷选择下载、插图、封面识别、反爬调度、段落还原。苹果风格UI。
// @author bili_novel_packer
// @match *://m.bilinovel.com/novel/*
// @match *://bilinovel.com/novel/*
// @match *://www.bilinovel.com/novel/*
// @match *://linovelib.com/novel/*
// @match *://www.linovelib.com/novel/*
// @match *://m.linovelib.com/novel/*
// @require https://unpkg.com/jszip@3.2.0/dist/jszip.min.js
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect m.bilinovel.com
// @connect bilinovel.com
// @connect www.bilinovel.com
// @connect linovelib.com
// @connect www.linovelib.com
// @connect m.linovelib.com
// @connect *
// @run-at document-idle
// ==/UserScript==
const BNP = (function () {
'use strict';
const DOMAIN = 'https://m.bilinovel.com';
const UA = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36';
class RequestScheduler {
constructor(n, perMs) {
this.gap = n > 0 && perMs > 0 ? Math.ceil(perMs / n) : 0;
this._queue = [];
this._running = false;
this._paused = false;
}
async run(task) {
return new Promise((resolve, reject) => {
this._queue.push({ task, resolve, reject });
this._process();
});
}
pause() { this._paused = true; }
resume() { this._paused = false; }
async _process() {
if (this._running) return;
this._running = true;
while (this._queue.length > 0) {
while (this._paused) { await sleep(100); }
const { task, resolve, reject } = this._queue.shift();
try { resolve(await task()); } catch (e) { reject(e); }
if (this.gap > 0) await sleep(this.gap);
}
this._running = false;
}
}
const _pageScheduler = new RequestScheduler(15, 60000);
const _imageScheduler = new RequestScheduler(10, 1000);
const logs = [];
const LOG_KEY = 'bnp_log_store';
const MAX_LOG = 3000;
const MAX_DOM_LOG = 300; // DOM中最多保留300条,其余的只有内存+localStorage
let _logEl = null, _badgeEl = null, _logDirty = false, _logFlushing = false;
const _logQueue = [];
function _ensureLogRefs() {
if (!_logEl) _logEl = document.getElementById('bnp-log');
if (!_badgeEl) _badgeEl = document.getElementById('bnp-log-cnt');
}
function _isLogVisible() {
_ensureLogRefs();
return _logEl && _logEl.style.display !== 'none' && getComputedStyle(_logEl).display !== 'none';
}
function _flushLogQueue() {
_logFlushing = false;
if (!_logQueue.length) return;
_ensureLogRefs();
if (!_logEl) return;
// 日志面板隐藏时暂不追加DOM,等打开时再渲染
if (!_isLogVisible()) return;
const frag = document.createDocumentFragment();
let batch;
while ((batch = _logQueue.splice(0, 50)).length) {
for (const e of batch) {
const div = document.createElement('div');
div.className = 'bnp-l';
if (e.l === 'ERROR') div.classList.add('e');
else if (e.l === 'WARN') div.classList.add('w');
else if (e.l === 'SESSION') div.classList.add('s');
div.innerHTML = e.l === 'SESSION'
? `${escH(e.m)}`
: `${e.t}${e.l}${escH(e.m)}`;
frag.appendChild(div);
}
}
_logEl.appendChild(frag);
// 裁剪DOM节点,防止过多
while (_logEl.children.length > MAX_DOM_LOG) {
_logEl.children[0].remove();
}
_logEl.scrollTop = _logEl.scrollHeight;
}
function _flushLogQueueImmediate() {
if (_logQueue.length) {
_logFlushing = true;
_flushLogQueue();
}
}
function _scheduleLogFlush() {
if (_logFlushing) return;
_logFlushing = true;
requestAnimationFrame(_flushLogQueue);
}
function addLog(level, msg) {
const now = new Date();
const entry = { t: now.toLocaleTimeString(), ts: now.getTime(), l: level, m: msg };
logs.push(entry);
_logQueue.push(entry);
_scheduleLogFlush();
_ensureLogRefs();
if (_badgeEl) _badgeEl.textContent = logs.length;
if (!_logDirty) {
_logDirty = true;
setTimeout(() => {
try { localStorage.setItem(LOG_KEY, JSON.stringify(logs.slice(-MAX_LOG))); } catch {}
_logDirty = false;
}, 2000);
}
// 控制台输出: ERROR/WARN始终显示, INFO节流显示
if (level !== 'SESSION' && (level !== 'INFO' || logs.length <= 30)) {
console[level === 'ERROR' ? 'error' : level === 'WARN' ? 'warn' : 'log'](`[${entry.t}] [${level}] ${msg}`);
}
}
function loadLogs() {
if (window._bnpLogLoaded) return;
window._bnpLogLoaded = true;
_ensureLogRefs();
if (!_logEl) return;
try {
const raw = localStorage.getItem(LOG_KEY);
if (!raw) return;
const stored = JSON.parse(raw);
for (const e of stored.slice(-MAX_LOG)) {
logs.push(e);
_logQueue.push(e);
}
_scheduleLogFlush();
if (_badgeEl) _badgeEl.textContent = logs.length;
} catch (e) { /* 忽略 */ }
}
function clearLog() {
logs.length = 0;
_logQueue.length = 0;
try { localStorage.removeItem(LOG_KEY); } catch (e) { /* ignore */ }
if (document.getElementById('bnp-log')) document.getElementById('bnp-log').innerHTML = '';
const badge = document.getElementById('bnp-log-cnt');
if (badge) badge.textContent = '0';
}
function copyLog() {
const text = logs
.map(e => e.l === 'SESSION' ? e.m : `[${e.t}] [${e.l}] ${e.m}`)
.join('\n');
const btn = document.getElementById('bnp-log-cpy');
if (!btn) return;
navigator.clipboard.writeText(text).then(() => {
btn.textContent = '✅';
setTimeout(() => { btn.textContent = '📋'; }, 2000);
}).catch(() => {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select(); document.execCommand('copy');
document.body.removeChild(ta);
btn.textContent = '✅';
setTimeout(() => { btn.textContent = '📋'; }, 2000);
});
}
function gmFetch(url, type = 'text', timeout = 30000) {
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => { if (!settled) { settled = true; reject(new Error(`请求超时(${timeout/1000}s): ${url.substring(0, 80)}`)); } }, timeout);
try {
GM_xmlhttpRequest({
method: 'GET', url,
headers: { 'User-Agent': UA, 'Accept': '*/*', 'Accept-Language': 'zh-CN,zh;q=0.9', 'Cookie': 'night=0' },
responseType: type,
onload: r => { if (settled) return; settled = true; clearTimeout(timer); r.status >= 200 && r.status < 300 ? resolve(type === 'arraybuffer' ? new Uint8Array(r.response) : r.responseText) : reject(new Error(`HTTP ${r.status}`)); },
onerror: e => { if (settled) return; settled = true; clearTimeout(timer); reject(new Error(`网络错误: ${e.error || ''}`)); },
ontimeout: () => { if (settled) return; settled = true; clearTimeout(timer); reject(new Error('GM超时')); },
});
} catch (e) { if (!settled) { settled = true; clearTimeout(timer); reject(e); } }
});
}
async function fetchPage(url) {
return _pageScheduler.run(async () => {
for (let i = 0; i < 3; i++) {
try {
const html = await gmFetch(url, 'text', 30000);
if (html.includes('Cloudflare to restrict access') || html.includes('503 Service')) {
addLog('WARN', '触发反爬,暂停调度10秒...');
_pageScheduler.pause();
await sleep(10000);
_pageScheduler.resume();
continue;
}
return html;
} catch (e) { if (i === 2) throw e; await sleep(2000); }
}
});
}
function gmFetchImg(url) {
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => { if (!settled) { settled = true; reject(new Error('图片超时')); } }, 20000);
GM_xmlhttpRequest({
method: 'GET', url,
headers: { 'User-Agent': UA, 'Accept': '*/*', 'Referer': DOMAIN + '/', 'Cookie': 'night=0' },
responseType: 'arraybuffer',
onload: r => { if (settled) return; settled = true; clearTimeout(timer); r.status >= 200 && r.status < 300 ? resolve(new Uint8Array(r.response)) : reject(new Error(`HTTP ${r.status}`)); },
onerror: e => { if (settled) return; settled = true; clearTimeout(timer); reject(new Error('网络错误')); },
ontimeout: () => { if (settled) return; settled = true; clearTimeout(timer); reject(new Error('超时')); },
});
});
}
async function fetchImage(src) {
if (src.startsWith('data:image')) return b64ToU8(src.split(',')[1]);
if (!src.startsWith('http')) src = `${DOMAIN}/${src}`;
src = src.replace('https://https://', 'https://').replace(/𝘣/g, 'b');
return gmFetchImg(src);
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
function formatTime(sec) {
if (sec < 60) return `${Math.round(sec)}秒`;
if (sec < 3600) return `${Math.floor(sec / 60)}分${Math.round(sec % 60)}秒`;
return `${Math.floor(sec / 3600)}时${Math.floor((sec % 3600) / 60)}分`;
}
async function fetchImageWithRetry(src, retries = 2) {
let lastErr;
for (let i = 0; i <= retries; i++) {
try { return await fetchImage(src); }
catch (e) { lastErr = e; if (i < retries) await sleep(1000 * (i + 1)); }
}
throw lastErr;
}
function b64ToU8(b64) { const bin = atob(b64); const u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); return u; }
function uuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 3 | 8)).toString(16); }); }
function _crossFadeText(el, text) {
if (el.textContent === text) return;
el.classList.add('switch');
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.textContent = text;
el.classList.remove('switch');
});
});
}
function esc(s) { return s ? s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') : ''; }
function escH(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
function sanitize(n) { return n.replace(/[:*?"\\\/<>|\0 ]/g, ' ').replace(/^\.+|\.+$/g, '').replace(/\s+/g, ' ').trim(); }
function detectExt(u) { if (u[0] === 0xFF && u[1] === 0xD8) return '.jpg'; if (u[0] === 0x89 && u[1] === 0x50) return '.png'; if (u[0] === 0x47 && u[1] === 0x49) return '.gif'; if (u[0] === 0x52 && u[1] === 0x49) return '.webp'; return '.jpg'; }
function getImageDimensions(data) {
try {
if (data[0] === 0xFF && data[1] === 0xD8) {
let i = 2;
while (i < data.length - 1) {
if (data[i] !== 0xFF) { i++; continue; }
const marker = data[i + 1];
if (marker === 0xC0 || marker === 0xC1 || marker === 0xC2) {
const h = (data[i + 5] << 8) | data[i + 6];
const w = (data[i + 7] << 8) | data[i + 8];
return { width: w, height: h };
}
const len = (data[i + 2] << 8) | data[i + 3];
i += 2 + len;
}
}
if (data[0] === 0x89 && data[1] === 0x50) {
const w = (data[16] << 24) | (data[17] << 16) | (data[18] << 8) | data[19];
const h = (data[20] << 24) | (data[21] << 16) | (data[22] << 8) | data[23];
return { width: w, height: h };
}
if (data[0] === 0x47 && data[1] === 0x49) {
const w = data[6] | (data[7] << 8);
const h = data[8] | (data[9] << 8);
return { width: w, height: h };
}
if (data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46) {
const w = (data[26] | (data[27] << 8)) + 1;
const h = (data[28] | (data[29] << 8)) + 1;
return { width: w, height: h };
}
} catch {}
return null;
}
function mediaType(ext) { return ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'; }
let secretMap = null;
async function getSecretMap() {
if (secretMap) return secretMap;
secretMap = {};
try {
const js = await gmFetch(`${DOMAIN}/themes/zhmb/js/readtools.js`);
// 新版readtools.js已不包含字符替换映射,此功能不再需要
// 但仍保留请求用于兼容,结果不会被使用
} catch (e) {
addLog('WARN', `readtools.js获取失败: ${e.message}`);
}
return secretMap;
}
const FALLBACK = { fixedLength: 20, seedMultiplier: 135, seedOffset: 234, a: 9302, c: 49397, mod: 233280 };
const tplCache = {};
async function getShuffleParams(doc) {
const scripts = doc.querySelectorAll('script[src*="chapterlog.js?v"]');
if (!scripts.length) return null;
const idMatch = doc.documentElement.outerHTML.match(/chapterid:'(\d+)'/);
if (!idMatch) return null;
const chId = parseInt(idMatch[1]);
const src = new URL(scripts[0].src, DOMAIN).toString();
let tpl = tplCache[src];
if (tpl === undefined) {
try { tpl = parseChapterLog(await gmFetch(src)) || null; } catch (e) { addLog('WARN', `chapterlog.js请求失败: ${e.message}`); tpl = null; }
if (!tpl) addLog('WARN', '⚠ chapterlog.js无法解析(网站已混淆),跳过段落还原。若章节段落顺序错乱请反馈');
tplCache[src] = tpl;
}
if (!tpl) return null;
return { fixedLength: tpl.fixedLength, seed: chId * tpl.seedMultiplier + tpl.seedOffset, a: tpl.a, c: tpl.c, mod: tpl.mod };
}
function parseChapterLog(js) {
// 新版chapterlog.js已高度混淆,先用自定义base64解码尝试提取字符串
// 混淆格式: var _0xXXXX=['str1','str2',...]; 其中str是自定义base64编码
// 自定义字母表: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=
try {
if (js.includes("['\\x61\\x70\\x70\\x6c\\x79']")) {
// 旧版readtools.js中的secretMap格式(兼容保留)
}
// 尝试查找常量数字字面量(即使混淆也能匹配)
const nums = js.match(/(?:0x[0-9a-f]+|\d{3,6})/g) || [];
const candidates = nums.map(n => n.startsWith('0x') ? parseInt(n, 16) : parseInt(n)).filter(n => n > 1000 && n < 500000);
// 典型的LCG参数: mod通常在10万左右, a和c在几千到几万
let foundMod = null, foundA = null, foundC = null;
for (const n of candidates) {
if (n > 20000 && n < 500000) foundMod = n;
}
// 若实在找不到则返回null (跳过还原)
} catch {}
// 尝试标准模式匹配(旧版未混淆或部分混淆)
let m = js.match(/if\s*\(\s*[_$a-zA-Z0-9]+\s*>\s*(.+?)\)/);
let sm = js.match(/=\s*(.+?Number\s*\(\s*chapterId\s*\).+?)\s*;/);
let lm = js.match(/=\s*(\(\s*[_$a-zA-Z0-9]+\s*\*.+?\)\s*%\s*.+?)\s*;/);
if (m && sm && lm) {
const fl = evInt(stripP(m[1])), sp = parseSeed(sm[1]), lp = parseLcg(lm[1]);
if (fl !== null && sp && lp) return { fixedLength: fl, seedMultiplier: sp[0], seedOffset: sp[1], a: lp[0], c: lp[1], mod: lp[2] };
}
const os = /var\s+[_$a-zA-Z0-9]+\s*=\s*[^;]*?Number\s*\(\s*[_$a-zA-Z0-9]+\s*\)\s*,\s*([^,)]+?)\s*\)\s*,\s*([^,)]+?)\s*\)\s*,/g;
const ol = /([_$a-zA-Z0-9]+)\s*=\s*[^;]*?\(\s*\1\s*,\s*([^,)]+?)\s*\)\s*,\s*([^,)]+?)\s*\)\s*,\s*([^;)]+?)\s*\)\s*;/g;
let sp = null, lp = null, t;
while ((t = os.exec(js))) { const mul = evInt(t[1]), off = evInt(t[2]); if (mul > 0 && off >= 0) { sp = [mul, off]; break; } }
while ((t = ol.exec(js))) { const a = evInt(t[2]), c = evInt(t[3]), mod = evInt(t[4]); if (a > 0 && c >= 0 && mod > a && mod > c) { lp = [a, c, mod]; break; } }
if (sp && lp) return { fixedLength: 20, seedMultiplier: sp[0], seedOffset: sp[1], a: lp[0], c: lp[1], mod: lp[2] };
return null;
}
function parseSeed(expr) { const o = evVar(expr, { chapterId: 0 }), v = evVar(expr, { chapterId: 1 }); return o !== null && v !== null ? [v - o, o] : null; }
function parseLcg(expr) {
const parts = splitTop(expr, '%'); if (parts.length !== 2) return null;
const mod = evInt(parts[1]); if (mod === null) return null;
const left = stripP(parts[0]); const vn = (left.match(/[_$a-zA-Z][_$a-zA-Z0-9]*/) || [])[0]; if (!vn) return null;
const c = evVar(left, { [vn]: 0 }), v = evVar(left, { [vn]: 1 }); return c !== null && v !== null ? [v - c, c, mod] : null;
}
function evVar(expr, vars) { let n = expr; for (const [k, v] of Object.entries(vars)) n = n.replace(new RegExp(`Number\\s*\\(\\s*${k}\\s*\\)`, 'g'), v).replace(new RegExp(`\\b${k}\\b`, 'g'), v); return evInt(n); }
function splitTop(expr, op) { const r = []; let s = 0, d = 0; for (let i = 0; i < expr.length; i++) { if (expr[i] === '(') { d++; continue; } if (expr[i] === ')') { d--; continue; } if (d === 0 && expr.startsWith(op, i)) { r.push(expr.substring(s, i).trim()); s = i + op.length; i += op.length - 1; } } r.push(expr.substring(s).trim()); return r; }
function stripP(e) { let v = e.trim(); while (v.startsWith('(') && v.endsWith(')')) { let d = 0, w = true; for (let i = 0; i < v.length; i++) { if (v[i] === '(') d++; if (v[i] === ')') { d--; if (d === 0 && i !== v.length - 1) { w = false; break; } } } if (!w) return v; v = v.substring(1, v.length - 1).trim(); } return v; }
function evInt(expr) { if (!expr) return null; try { const tk = tokenize(expr.trim()); let p = 0; function pe() { let v = pt(); while (tk[p] === '+' || tk[p] === '-') { const o = tk[p++]; const r = pt(); v = o === '+' ? v + r : v - r; } return v; } function pt() { let v = pu(); while (tk[p] === '*' || tk[p] === '/' || tk[p] === '%') { const o = tk[p++]; const r = pu(); v = o === '*' ? v * r : o === '/' ? Math.trunc(v / r) : v % r; } return v; } function pu() { if (tk[p] === '-') { p++; return -pu(); } if (tk[p] === '+') { p++; return pu(); } if (tk[p] === '~') { p++; return ~pu(); } return pp(); } function pp() { if (tk[p] === '(') { p++; const v = pe(); if (tk[p] === ')') p++; return v; } const t = tk[p++]; return t.startsWith('0x') ? parseInt(t, 16) : parseInt(t); } const r = pe(); return isNaN(r) ? null : r; } catch { return null; } }
function tokenize(e) { const t = []; let i = 0; while (i < e.length) { if (e[i] === ' ' || e[i] === '\t') { i++; continue; } if (e[i] === '0' && (e[i + 1] === 'x' || e[i + 1] === 'X')) { let j = i + 2; while (j < e.length && /[0-9a-fA-F]/.test(e[j])) j++; t.push(e.substring(i, j)); i = j; continue; } if (/[0-9]/.test(e[i])) { let j = i; while (j < e.length && /[0-9]/.test(e[j])) j++; t.push(e.substring(i, j)); i = j; continue; } if (e.startsWith('>>>', i)) { t.push('>>>'); i += 3; continue; } if (e.startsWith('>>', i)) { t.push('>>'); i += 2; continue; } if (e.startsWith('<<', i)) { t.push('<<'); i += 2; continue; } t.push(e[i]); i++; } return t; }
function unshuffle(content, params) {
const ps = Array.from(content.querySelectorAll('p')).filter(p => p.textContent.trim());
if (!ps.length) return;
const fixed = [], shuffled = [];
for (let i = 0; i < ps.length; i++) (i < params.fixedLength ? fixed : shuffled).push(i);
if (ps.length > params.fixedLength) { let seed = params.seed; for (let i = shuffled.length - 1; i > 0; i--) { seed = (seed * params.a + params.c) % params.mod; const j = Math.floor(seed / params.mod * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } }
const idx = [...fixed, ...shuffled]; const mapped = new Array(ps.length);
for (let i = 0; i < ps.length; i++) mapped[idx[i]] = ps[i];
let ri = 0;
for (const ch of Array.from(content.children)) { if (ch.tagName === 'P' && ch.textContent.trim()) content.replaceChild(mapped[ri++].cloneNode(true), ch); }
}
// 清除网站添加的内容加载失败/截断提示
function _cleanContentError(el) {
// 递归遍历所有文本节点
function walkTextNodes(node) {
const toRemove = [];
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i];
if (child.nodeType === 3) { // TEXT_NODE
if (child.textContent.includes('內容加載失敗') || child.textContent.includes('内容加载失败')) {
toRemove.push(child);
}
} else if (child.nodeType === 1) { // ELEMENT_NODE
walkTextNodes(child);
}
}
for (const n of toRemove) node.removeChild(n);
}
walkTextNodes(el);
// 移除内容为空或仅含失败提示的p标签
for (const p of el.querySelectorAll('p')) {
const txt = p.textContent.trim();
if (txt.includes('內容加載失敗') || txt.includes('内容加载失败')) {
const hasRealContent = Array.from(p.childNodes).some(n => n.nodeType === 3 && !n.textContent.includes('內容加載失敗') && !n.textContent.includes('内容加载失败') && n.textContent.trim().length > 0);
if (!hasRealContent) p.remove();
}
}
}
function getId(url) { const m = url.match(/(?:linovelib|bilinovel)\.com\/(?:novel|download)\/(\d+)/); if (!m) throw new Error('不支持的URL'); return m[1]; }
async function getNovel(url) {
const id = getId(url); addLog('INFO', `获取小说: ${id}`);
const html = await fetchPage(`${DOMAIN}/novel/${id}.html`);
const doc = new DOMParser().parseFromString(html, 'text/html');
const novel = { id, url, title: doc.querySelector('.book-title')?.textContent || '', alias: doc.querySelector('.backupname .bkname-body.gray')?.textContent?.trim() || '', coverUrl: doc.querySelector('.book-layout img')?.getAttribute('src') || '', tags: [...doc.querySelectorAll('.book-cell .book-meta span em')].map(e => e.textContent), publisher: doc.querySelector('.tag-small.orange')?.textContent || '', author: doc.querySelector('.book-rand-a span')?.textContent || '', description: doc.querySelector('#bookSummary content')?.textContent || '', status: '' };
const se = doc.querySelector('.book-cell .book-meta+.book-meta');
if (se) { const n = se.childNodes; for (let i = n.length - 1; i >= 0; i--) if (n[i].textContent.trim()) { novel.status = n[i].textContent.trim(); break; } }
addLog('INFO', `小说: ${novel.title}`);
return novel;
}
async function getCatalog(id) {
addLog('INFO', '获取目录...');
const html = await fetchPage(`${DOMAIN}/novel/${id}/catalog`);
const doc = new DOMParser().parseFromString(html, 'text/html');
const allLis = doc.querySelectorAll('.volume-chapters>li');
addLog('INFO', `目录li元素: ${allLis.length}个`);
if (allLis.length === 0) {
addLog('WARN', `目录为空, html前500=${html.substring(0, 500)}`);
}
const volumes = []; let cur = null;
for (const li of allLis) {
if (li.classList.contains('chapter-bar')) { if (cur) volumes.push(cur); cur = { name: li.textContent.trim(), chapters: [], cover: null }; }
else if (li.classList.contains('volume-cover')) { if (cur) { const img = li.querySelector('a img'); cur.cover = img?.getAttribute('src') || null; } }
else if (li.classList.contains('jsChapter')) { const a = li.querySelector('a'); if (!a || !cur) continue; let href = a.getAttribute('href'); if (!href || href.includes('javascript')) { href = null; } else { href = DOMAIN + href; } cur.chapters.push({ name: a.textContent, url: href }); }
}
if (cur) volumes.push(cur);
addLog('INFO', `目录: ${volumes.length}卷, ${volumes.reduce((s, v) => s + v.chapters.length, 0)}章`);
for (const v of volumes) {
for (let i = 0; i < Math.min(2, v.chapters.length); i++) {
addLog('INFO', ` 章节URL样本: ${v.chapters[i].name} => ${v.chapters[i].url || 'null'}`);
}
}
return volumes;
}
async function getChapter(url) {
if (!url) return { title: '', content: '' };
let title = '', html = '', next = url;
do {
const page = await getPage(next);
if (page.title && !page.title.includes('〇')) title = page.title;
html += page.content;
next = page.nextUrl;
} while (next);
return { title, content: html };
}
async function getPage(url) {
addLog('INFO', `getPage: ${url}`);
const raw = await fetchPage(url);
addLog('INFO', `getPage响应: ${raw.length}字符`);
const doc = new DOMParser().parseFromString(raw, 'text/html');
const title = !url.includes('_') ? (doc.querySelector('#atitle')?.textContent || null) : null;
const el = doc.querySelector('#acontent') || doc.querySelector('.bcontent');
if (!el) { addLog('ERROR', `内容为空, URL=${url}, html前200=${raw.substring(0, 200)}`); throw new Error('内容为空'); }
for (const sel of ['div', 'ins', 'figure', 'fig', 'br', 'script', '.tp', '.bd']) el.querySelectorAll(sel).forEach(e => e.remove());
el.querySelectorAll('[class]').forEach(e => { if (/^[a-z]\d{4}$/.test(e.className)) e.remove(); });
const sp = await getShuffleParams(doc);
if (sp) unshuffle(el, sp);
// 清除网站添加的内容加载失败提示
_cleanContentError(el);
const um = raw.match(/url_previous:'(.*?)',url_next:'(.*?)'/);
const fl = doc.querySelectorAll('#footlink a');
let nextUrl = null, prevChapterUrl = null, nextChapterUrl = null;
if (fl.length && um?.[1]) {
const prevText = fl[0]?.textContent || '';
if (prevText.includes('上一页') || prevText.includes('上一頁')) {
} else if (um[1]) {
prevChapterUrl = DOMAIN + um[1];
}
}
if (fl.length && um?.[2]) {
const nextText = fl[fl.length - 1].textContent || '';
if (nextText.includes('下一页') || nextText.includes('下一頁')) {
nextUrl = DOMAIN + um[2];
} else if (um[2]) {
nextChapterUrl = DOMAIN + um[2];
}
}
el.querySelectorAll('img').forEach(img => {
let src = img.dataset?.src || img.src;
if (!src) return;
if (src.includes('<')) { img.remove(); return; }
if (src.startsWith('//')) src = 'https:' + src;
img.src = src;
});
return { title, content: el.innerHTML, nextUrl, prevChapterUrl, nextChapterUrl };
}
function _getAllChapters(volumes) {
const all = [];
for (const v of volumes) {
for (const ch of v.chapters) {
all.push(ch);
}
}
return all;
}
async function resolveChapterUrl(chapter, volumes) {
if (chapter.url) return chapter.url;
const all = _getAllChapters(volumes);
const idx = all.indexOf(chapter);
if (idx === -1) return null;
// 1. 尝试从下一章的"上一章"链接获取
if (idx < all.length - 1) {
const next = all[idx + 1];
if (next.url) {
try {
const page = await getPage(next.url);
if (page.prevChapterUrl) return page.prevChapterUrl;
} catch (e) { addLog('WARN', `推导URL失败(下一章): ${e.message}`); }
}
}
// 2. 尝试从上一章翻页直到"下一章"链接
if (idx > 0) {
const prev = all[idx - 1];
if (prev.url) {
try {
let url = prev.url;
for (let i = 0; i < 20; i++) {
const page = await getPage(url);
if (!page.nextUrl) {
if (page.nextChapterUrl) return page.nextChapterUrl;
break;
}
url = page.nextUrl;
}
} catch (e) { addLog('WARN', `推导URL失败(上一章): ${e.message}`); }
}
}
return null;
}
class CoverDetector {
constructor() { this._images = {}; }
add(name, data) {
const dims = getImageDimensions(data);
if (dims) this._images[name] = dims;
}
detectCover() {
const entries = Object.entries(this._images);
if (!entries.length) return null;
for (const [name, dims] of entries) {
if (dims.height > dims.width) return name;
}
return entries[0][0];
}
}
function buildEpub(opts) {
const { title, author, desc, cover, chapters, images, publisher, subjects } = opts;
const zip = new JSZip();
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
zip.file('META-INF/container.xml', '\n
(无链接)
' }); continue; } try { // 带120秒超时的章节获取 const { title, content } = await Promise.race([ getChapter(ch.url), new Promise((_, rej) => setTimeout(() => rej(new Error('章节获取超时(120s)')), 120000)) ]); const container = document.createElement('div'); container.innerHTML = content; const imgs = container.querySelectorAll('img'); let imgOk = 0, imgFail = 0; for (const img of imgs) { let src = img.src; if (!src) continue; try { const data = await _imageScheduler.run(() => fetchImageWithRetry(src)); if (data?.length > 0) { const ext = detectExt(data); const fn = `images/${String(++imgIdx).padStart(6, '0')}${ext}`; allImages[fn] = data; img.src = fn; imgOk++; } } catch (e) { imgFail++; addLog('WARN', `图片失败(${src.substring(0,60)}): ${e.message}`); } } if (imgOk > 0) addLog('INFO', ` 章节${done}: ${imgOk}张图片已下载${imgFail > 0 ? `, ${imgFail}张失败` : ''}`); volChapters.push({ title: title || ch.name, content: container.innerHTML }); } catch (e) { addLog('ERROR', `章节失败: ${ch.name} - ${e.message}`); volChapters.push({ title: ch.name, content: `获取失败
` }); } } fill.style.width = '90%'; ptext.textContent = `📦 打包: ${volName}`; peta.textContent = '正在生成 EPUB...'; document.getElementById('bnp-mini-text').textContent = `打包中`; document.getElementById('bnp-mini-sub').textContent = volName; if (ringFg) ringFg.style.strokeDashoffset = '7.5'; const coverUrl = (vol.cover && !vol.cover.includes('no.svg')) ? vol.cover : null; let cover = new Uint8Array(0); if (coverUrl) { try { cover = await fetchImage(coverUrl); addLog('INFO', `封面下载: ${(cover.length/1024).toFixed(0)}KB`); } catch(e) { addLog('WARN', `封面下载失败: ${e.message}`); } } else { addLog('INFO', '卷封面是占位符,用 CoverDetector 自动选封面...'); const detector = new CoverDetector(); for (const [fn, data] of Object.entries(allImages)) { if (data.length >= 1000) detector.add(fn, data); } const coverName = detector.detectCover(); if (coverName) { cover = allImages[coverName]; const dims = getImageDimensions(cover); addLog('INFO', `封面自动选择: ${coverName}${dims ? ` (${dims.width}x${dims.height})` : ''}`); } } addLog('INFO', `打包: ${volChapters.length}章, 图片${Object.keys(allImages).length}张, 封面${cover.length > 0 ? '有' : '无'}`); const zip = buildEpub({ title: `${novel.title} ${volName}`, author: novel.author, desc: novel.description, publisher: novel.publisher, subjects: novel.tags, cover: cover?.length > 0 ? cover : null, chapters: volChapters, images: allImages }); addLog('INFO', `JSZip文件数: ${Object.keys(zip.files).length},打包中...`); try { const data = await zip.generateAsync({ type: 'uint8array', streamFiles: false, compression: 'DEFLATE', }, (meta) => { if (meta.percent && meta.percent % 25 === 0) { addLog('INFO', `JSZip进度: ${meta.percent.toFixed(0)}%`); } }); addLog('INFO', `打包完成: ${(data.length/1024/1024).toFixed(1)}MB`); const epubName = volName.includes(novel.title) ? sanitize(volName) : sanitize(`${novel.title} ${volName}`); downloadFile(new Blob([data], { type: 'application/epub+zip' }), epubName + '.epub'); addLog('INFO', '下载已触发'); } catch(e) { addLog('ERROR', `generate失败: ${e.message} ${e.stack}`); } } fill.classList.add("done"); fill.style.width = "100%"; document.getElementById("bnp-mini").classList.add("done"); _crossFadeText(ptext, "✅ 全部完成!"); ptext.classList.add("done"); document.getElementById("bnp-mini-text").innerHTML = "✅ 完成"; peta.textContent = ""; if (ringFg) { ringFg.style.strokeDashoffset = "0"; ringFg.style.stroke = "#34C759"; } addLog('INFO', '全部完成'); // delay for completion animation setTimeout(() => { prog.style.display = 'none'; document.getElementById('bnp-mini').style.display = 'none'; document.getElementById('bnp-btn').style.display = 'flex'; showPanel(); }, 2500); } catch (e) { addLog('ERROR', `失败: ${e.message}`); ptext.textContent = `❌ 失败: ${e.message}`; peta.textContent = ""; document.getElementById("bnp-mini-text").textContent = "❌ 失败"; document.getElementById("bnp-mini-sub").textContent = e.message; ptext.style.animation = "bnp-shake 0.4s cubic-bezier(.4,0,.2,1)"; if (ringFg) { ringFg.style.stroke = "#FF3B30"; ringFg.style.strokeDashoffset = "0"; } document.getElementById('bnp-mini').style.display = 'none'; document.getElementById('bnp-btn').style.display = 'flex'; showPanel(); } finally { btn.disabled = false; isDownloading = false; } } const API = { getNovel, getCatalog, getChapter, getSecretMap, buildEpub, fetchPage, fetchImage, DOMAIN, resolveChapterUrl, CoverDetector, RequestScheduler }; if (typeof document !== 'undefined' && typeof window !== 'undefined') { if (document.readyState === 'complete') injectUI(); else window.addEventListener('load', injectUI); } return API; })(); if (typeof module !== 'undefined' && module.exports) { module.exports = BNP; }