feat: 集成漫画源并支持在线阅读

This commit is contained in:
lofyer
2026-08-08 19:32:47 +08:00
parent c2292da442
commit 87dcc307e6
30 changed files with 3681 additions and 58 deletions
+33
View File
@@ -89,3 +89,36 @@ test('目录 fsync 失败不影响写入结果', () => {
}
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { ok: true });
});
test('writeBytes 原子覆盖二进制文件且不残留临时文件', () => {
const dir = fresh('bytes');
const dest = path.join(dir, 'book.epub');
fs.writeFileSync(dest, Buffer.from([1, 2, 3]));
atomic.writeBytes(dest, Buffer.from([4, 5, 0, 255]));
assert.deepStrictEqual([...fs.readFileSync(dest)], [4, 5, 0, 255]);
assert.strictEqual(fs.existsSync(`${dest}.tmp`), false);
assert.strictEqual(fs.existsSync(`${dest}.bak`), false);
});
test('writeBytes 替换失败时恢复旧二进制内容', () => {
const dir = fresh('bytes-rollback');
const dest = path.join(dir, 'book.epub');
fs.writeFileSync(dest, Buffer.from([1, 2, 3]));
const realRename = fs.renameSync;
let failed = false;
fs.renameSync = function failing(from, to) {
if (!failed && String(from) === `${dest}.tmp` && String(to) === dest) {
failed = true;
throw new Error('模拟二进制替换失败');
}
return realRename.apply(this, arguments);
};
try {
assert.throws(() => atomic.writeBytes(dest, Buffer.from([9, 9])), /模拟二进制替换失败/);
} finally {
fs.renameSync = realRename;
}
assert.deepStrictEqual([...fs.readFileSync(dest)], [1, 2, 3]);
assert.strictEqual(fs.existsSync(`${dest}.tmp`), false);
assert.strictEqual(fs.existsSync(`${dest}.bak`), false);
});
+97 -2
View File
@@ -1,4 +1,5 @@
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const { app, BrowserWindow } = require('electron');
@@ -9,6 +10,7 @@ app.setPath('appData', TMP);
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true';
const results = [];
let imageServer = null;
function check(name, pass, detail) {
results.push([pass ? 'OK' : 'FAIL', name, detail || '']);
}
@@ -36,6 +38,31 @@ function printSummary() {
}
app.whenReady().then(async () => {
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
imageServer = http.createServer((_request, response) => {
response.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': png.length });
response.end(png);
});
await new Promise((resolve) => imageServer.listen(0, '127.0.0.1', resolve));
const imageUrl = `http://127.0.0.1:${imageServer.address().port}/page.png`;
const mangaDex = require(path.join(ROOT, 'src', 'sources', 'mangadex'));
mangaDex.chapters = async () => ({
items: [
{ chapterId: 'online-c1', label: '在线第 1 话', pages: 1 },
{ chapterId: 'online-c2', label: '在线第 2 话', pages: 1 }
],
page: 1,
maxPage: 1
});
mangaDex.chapterImageUrls = async () => ({
urls: [imageUrl],
mustReport: false,
quality: 'dataSaver'
});
const library = require(path.join(ROOT, 'src', 'library', 'store'));
let scanStartedAt = 0;
library.scan = () => {
@@ -63,23 +90,91 @@ app.whenReady().then(async () => {
checked: input.checked,
name: input.parentElement.querySelector('span').textContent
}));
return rows.length >= 16 ? rows : null;
return rows.length >= 18 ? rows : null;
})()`));
const expectedSources = ['openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en'];
const expectedSources = [
'openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en',
'mangadex', 'copymanga'
];
check('新增开放书源出现在设置页且新安装默认启用',
expectedSources.every((id) => sourceRows.some((row) => row.id === id && row.checked)),
sourceRows.filter((row) => expectedSources.includes(row.id)).map((row) => `${row.id}:${row.name}`).join(', '));
const sourceGroups = await waitUntil(() => win.webContents.executeJavaScript(`(() => {
const labels = Array.from(document.querySelectorAll('#sourceSelect optgroup')).map((group) => group.label);
return labels.includes('漫画') ? labels : null;
})()`));
check('检索书源下拉框按内容类型分组',
['学术论文', '电子书', '开放教材与文库', '档案与特藏', '漫画']
.every((label) => sourceGroups.includes(label)),
sourceGroups.join(', '));
const mangaLanguage = await win.webContents.executeJavaScript(
`document.getElementById('mangadexLanguageSelect').value`
);
check('MangaDex 新安装默认只显示简体中文章节', mangaLanguage === 'zh', mangaLanguage);
await waitUntil(() => scanStartedAt > 0, 7000);
check('启动维护在首屏完成后延迟执行', scanStartedAt - startedAt >= 1400,
`${scanStartedAt - startedAt}ms`);
const beforeOnline = await win.webContents.executeJavaScript(
`window.api.library.list().then((result) => result.data.length)`
);
const openedOnline = await win.webContents.executeJavaScript(`window.api.sources.readOnline('mangadex', {
mangaId: 'online-manga',
title: '在线阅读夹具',
authors: ['测试作者'],
language: 'zh',
quality: 'dataSaver'
})`);
check('漫画详情可以打开不入库的在线阅读器', openedOnline && openedOnline.ok,
openedOnline && openedOnline.error);
const onlineWindow = await waitUntil(() => BrowserWindow.getAllWindows().find(
(item) => item !== win && /manga-online\.html/.test(item.webContents.getURL())
));
let firstChapter = false;
try {
firstChapter = await waitUntil(() => onlineWindow.webContents.executeJavaScript(
`document.querySelectorAll('.chapter-item').length === 2
&& document.querySelector('.manga-page img')
&& document.querySelector('.manga-page img').naturalWidth > 0`
));
} catch (error) { /* 由下面的诊断状态报告 */ }
const onlineState = await onlineWindow.webContents.executeJavaScript(`(() => ({
status: document.getElementById('readerStatus').textContent,
statusHidden: document.getElementById('readerStatus').classList.contains('hidden'),
chapters: document.querySelectorAll('.chapter-item').length,
pages: document.querySelectorAll('.manga-page').length,
pageState: document.querySelector('.manga-page')?.dataset.state || '',
pageText: document.querySelector('.manga-page')?.dataset.placeholder || ''
}))()`);
check('在线阅读器读取整部章节目录并通过主进程显示图片',
!!firstChapter, JSON.stringify(onlineState));
let secondChapter = false;
if (firstChapter) {
await onlineWindow.webContents.executeJavaScript(`document.getElementById('nextChapterBtn').click()`);
secondChapter = await waitUntil(() => onlineWindow.webContents.executeJavaScript(`(() => {
const image = document.querySelector('.manga-page img');
return document.getElementById('chapterTitle').textContent === '在线第 2 话'
&& image && image.naturalWidth > 0;
})()`));
}
check('在线阅读器可连续切换到下一章', secondChapter);
const afterOnline = await win.webContents.executeJavaScript(
`window.api.library.list().then((result) => result.data.length)`
);
check('在线阅读不会创建书库条目', afterOnline === beforeOnline,
`${beforeOnline}${afterOnline}`);
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.destroy();
}
await new Promise((resolve) => imageServer.close(resolve));
const failed = printSummary();
app.exit(failed ? 1 : 0);
}).catch((error) => {
if (imageServer) imageServer.close();
console.error('异常:', error);
check('启动验证未发生异常', false, error.message || String(error));
for (const window of BrowserWindow.getAllWindows()) {
+26
View File
@@ -43,6 +43,32 @@ test('所有 IPC handler 都通过 thunk 调用 wrap', () => {
assert.deepStrictEqual(bare, [], '存在绕过 wrap 的 handler: ' + bare);
});
test('章节制数据源 IPC 通过源能力下载并按请求隔离进度', () => {
const start = mainSrc.indexOf("ipcMain.handle('source:chapters'");
const end = mainSrc.indexOf('// 代理配置', start);
const segment = mainSrc.slice(start, end);
assert.ok(start >= 0 && end > start, '缺少章节制数据源 IPC');
assert.match(segment, /source\.chapters\(postId, page, options \|\| \{\}\)/);
assert.match(segment, /source\.downloadChapter\(library, payload \|\| \{\}, sendProgress\)/);
assert.match(segment, /requestId:\s*progressId/);
assert.match(segment, /event\.sender\.isDestroyed\(\)/);
assert.match(segment, /event\.sender\.send\('source:chapterProgress'/);
});
test('在线漫画使用独立窗口、sender 绑定会话和主进程图片代理', () => {
const start = mainSrc.indexOf("ipcMain.handle('source:readOnline'");
const end = mainSrc.indexOf('// 代理配置', start);
const segment = mainSrc.slice(start, end);
assert.ok(start >= 0 && end > start, '缺少在线漫画 IPC');
assert.match(segment, /event\.sender\.id !== mainWindow\.webContents\.id/);
assert.match(segment, /mangaOnlineWindow\.create\(__dirname, currentUiTheme\)/);
assert.match(segment, /mangaOnlineSessions\.create\(ownerId, source, input \|\| \{\}\)/);
assert.match(segment, /mangaOnlineWindow\.fromWebContents\(event\.sender\)/);
for (const channel of ['meta', 'chapters', 'chapterManifest', 'image', 'close']) {
assert.match(segment, new RegExp(`ipcMain\\.handle\\('mangaOnline:${channel}'`));
}
});
test('版本比较:预发布版本低于同号正式版', () => {
assert.strictEqual(compareVersion('1.1.0', '1.1.0-beta'), 1);
assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0'), -1);
+281
View File
@@ -0,0 +1,281 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const h = require('./helpers');
h.installFetchStub();
const store = require('../library/store');
const mangaDownload = require('../library/manga-download');
const mangaEpub = require('../library/manga-epub');
const roots = [];
function freshRoot(tag) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-manga-${tag}-`));
roots.push(root);
store.init(root);
return root;
}
function installImageHandler() {
h.setHandler((url) => {
const name = new URL(url).pathname.split('/').at(-1);
const byte = name.startsWith('one') ? 1 : 2;
const data = Buffer.from([byte, byte, byte]);
return {
ok: true,
status: 200,
headers: { get: () => null },
arrayBuffer: async () => data
};
});
}
function sourceFor(files) {
return {
id: 'mangadex',
chapterImageUrls: async () => ({
urls: files.map((name) => `https://uploads.mangadex.org/data/hash/${name}`),
mustReport: false
})
};
}
function payload(chapterId, chapter) {
return {
mangaId: 'manga-1',
title: '测试漫画',
authors: ['作者'],
originalLanguage: 'ja',
chapterId,
chapter,
label: `${chapter}`,
translatedLanguage: 'zh',
quality: 'dataSaver'
};
}
test.after(() => {
for (const root of roots) {
try { fs.rmSync(root, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
test('fetchChapterImages: 保持页序并上报准确进度', async () => {
installImageHandler();
const progress = [];
const result = await mangaDownload.fetchChapterImages(
sourceFor(['one.jpg', 'two.png']),
'chapter-1',
'dataSaver',
(done, total) => progress.push([done, total])
);
assert.deepStrictEqual(result.map((image) => image.ext), ['jpg', 'png']);
assert.deepStrictEqual([...result[0].data], [1, 1, 1]);
assert.deepStrictEqual([...result[1].data], [2, 2, 2]);
assert.deepStrictEqual(progress, [[1, 2], [2, 2]]);
});
test('fetchChapterImages: 失败页换节点重试,进度只按成功页单调增加', async () => {
let sourceCalls = 0;
h.setHandler((url) => {
if (url.includes('old-one.jpg')) {
return {
ok: false,
status: 500,
headers: { get: () => null },
body: { cancel: async () => {} }
};
}
const data = Buffer.from(url.includes('one.jpg') ? [1] : [2]);
return {
ok: true,
status: 200,
headers: { get: () => null },
arrayBuffer: async () => data
};
});
const source = {
chapterImageUrls: async () => {
sourceCalls++;
const prefix = sourceCalls === 1 ? 'old-' : 'fresh-';
return {
urls: [
`https://uploads.mangadex.org/data/hash/${prefix}one.jpg`,
`https://uploads.mangadex.org/data/hash/${prefix}two.jpg`
],
mustReport: false
};
}
};
const progress = [];
const result = await mangaDownload.fetchChapterImages(
source,
'chapter-1',
'dataSaver',
(done, total) => progress.push([done, total])
);
assert.strictEqual(sourceCalls, 2);
assert.deepStrictEqual(progress, [[1, 2], [2, 2]]);
assert.deepStrictEqual(result.map((image) => [...image.data]), [[1], [2]]);
});
test('fetchChapterImages: @Home 图片上报完成后才返回,且上报体包含传输指标', async () => {
let reportBody = null;
let reportFinished = false;
h.setHandler((url, options) => {
if (url === 'https://api.mangadex.network/report') {
reportBody = JSON.parse(options.body);
return {
ok: true,
status: 200,
headers: { get: () => null },
body: { cancel: async () => { reportFinished = true; } }
};
}
const data = Buffer.from([7, 8, 9]);
return {
ok: true,
status: 200,
headers: { get: (name) => name.toLowerCase() === 'x-cache' ? 'HIT' : null },
arrayBuffer: async () => data
};
});
const source = {
chapterImageUrls: async () => ({
urls: ['https://node.example.test/data/hash/one.jpg'],
mustReport: true
})
};
await mangaDownload.fetchChapterImages(source, 'chapter-1', 'dataSaver');
assert.strictEqual(reportFinished, true);
assert.strictEqual(reportBody.url, 'https://node.example.test/data/hash/one.jpg');
assert.strictEqual(reportBody.success, true);
assert.strictEqual(reportBody.bytes, 3);
assert.strictEqual(reportBody.cached, true);
assert.ok(Number.isFinite(reportBody.duration));
});
test('downloadChapter: 首章建库,后续章节追加到同一 EPUB', async () => {
freshRoot('append');
installImageHandler();
const source = sourceFor(['one.jpg']);
const first = await mangaDownload.downloadChapter(source, store, payload('chapter-1', '1'));
assert.strictEqual(first.created, true);
assert.strictEqual(store.list().length, 1);
const second = await mangaDownload.downloadChapter(source, store, payload('chapter-2', '2'));
assert.strictEqual(second.created, false);
assert.strictEqual(second.entry.id, first.entry.id);
assert.strictEqual(store.list().length, 1);
const epub = second.entry.files.find((file) => /\.epub$/i.test(file.path));
assert.ok(epub && epub.exists);
assert.deepStrictEqual(
mangaEpub.listChapters(fs.readFileSync(epub.path)).map((chapter) => chapter.chapterId),
['chapter-1', 'chapter-2']
);
});
test('downloadChapter: 同一漫画并发下载会串行合并,不产生重复书库条目', async () => {
freshRoot('concurrent');
h.setHandler(() => ({
ok: true,
status: 200,
headers: { get: () => null },
arrayBuffer: async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return Buffer.from([1, 2, 3]);
}
}));
const source = sourceFor(['one.jpg']);
const [first, second] = await Promise.all([
mangaDownload.downloadChapter(source, store, payload('chapter-1', '1')),
mangaDownload.downloadChapter(source, store, payload('chapter-2', '2'))
]);
assert.strictEqual(first.entry.id, second.entry.id);
assert.strictEqual(store.list().length, 1);
const epub = store.list()[0].files.find((file) => /\.epub$/i.test(file.path));
assert.deepStrictEqual(
mangaEpub.listChapters(fs.readFileSync(epub.path)).map((chapter) => chapter.chapterId),
['chapter-1', 'chapter-2']
);
});
test('downloadChapter: 已下载章节不会重复抓图', async () => {
freshRoot('dedupe');
installImageHandler();
const source = sourceFor(['one.jpg']);
await mangaDownload.downloadChapter(source, store, payload('chapter-1', '1'));
let calls = 0;
const countingSource = {
id: 'mangadex',
chapterImageUrls: async () => {
calls++;
return source.chapterImageUrls();
}
};
await assert.rejects(
mangaDownload.downloadChapter(countingSource, store, payload('chapter-1', '1')),
/该章节已下载/
);
assert.strictEqual(calls, 0);
});
test('downloadChapter: 已有条目只有非 EPUB 文件时新建 EPUB 而不误解析旧文件', async () => {
const root = freshRoot('non-epub');
installImageHandler();
const pdf = path.join(root, 'files', 'old.pdf');
fs.writeFileSync(pdf, '%PDF-test');
const entry = store.add({
title: '测试漫画',
sourceId: 'mangadex',
sourcePostId: 'manga-1',
files: [{ path: pdf, name: 'old.pdf', format: 'PDF' }]
});
const result = await mangaDownload.downloadChapter(
sourceFor(['one.jpg']),
store,
payload('chapter-1', '1')
);
assert.strictEqual(result.entry.id, entry.id);
assert.strictEqual(store.list().length, 1);
assert.strictEqual(result.entry.files.filter((file) => /\.epub$/i.test(file.path)).length, 1);
assert.strictEqual(result.entry.files.filter((file) => /\.pdf$/i.test(file.path)).length, 1);
});
test('downloadChapter: 已有普通 EPUB 时保留原文件并另建可合并的漫画 EPUB', async () => {
const root = freshRoot('plain-epub');
installImageHandler();
const plain = path.join(root, 'files', 'plain.epub');
fs.writeFileSync(plain, 'not a manga epub');
const entry = store.add({
title: '测试漫画',
sourceId: 'mangadex',
sourcePostId: 'manga-1',
files: [{ path: plain, name: 'plain.epub', format: 'EPUB' }]
});
const result = await mangaDownload.downloadChapter(
sourceFor(['one.jpg']),
store,
payload('chapter-1', '1')
);
assert.strictEqual(result.entry.id, entry.id);
const epubs = result.entry.files.filter((file) => /\.epub$/i.test(file.path));
assert.strictEqual(epubs.length, 2);
const generated = epubs.find((file) => file.path !== plain);
assert.strictEqual(mangaEpub.isMangaEpub(fs.readFileSync(generated.path), 'manga-1'), true);
});
test('extFromUrl: 忽略查询参数并为无扩展名地址回退到 jpg', () => {
assert.strictEqual(mangaDownload.extFromUrl('https://x.test/p.webp?token=1'), 'webp');
assert.strictEqual(mangaDownload.extFromUrl('https://x.test/image'), 'jpg');
});
+155
View File
@@ -0,0 +1,155 @@
const test = require('node:test');
const assert = require('node:assert');
const mangaEpub = require('../library/manga-epub');
const zip = require('../zip');
function img(byte, ext) {
return { ext: ext || 'jpg', data: Buffer.from([byte, byte, byte]) };
}
test('createMangaEpub: 产出的 zip 含标准 EPUB 骨架与章节旁路索引', () => {
const { bytes, chapterCount, pageCount } = mangaEpub.createMangaEpub({
title: '测试漫画',
mangaId: 'manga-1',
originalLanguage: 'ja',
chapterId: 'ch-1',
label: '第 1 话',
volume: '1',
chapter: '1',
translatedLanguage: 'zh',
group: '汉化组',
images: [img(1), img(2), img(3)]
});
assert.strictEqual(chapterCount, 1);
assert.strictEqual(pageCount, 3);
const entries = zip.readZip(bytes);
assert.ok(entries.has('mimetype'));
assert.strictEqual(entries.get('mimetype').toString('utf8'), 'application/epub+zip');
assert.ok(entries.has('META-INF/container.xml'));
assert.ok(entries.has('content.opf'));
assert.ok(entries.has('nav.xhtml'));
assert.ok(entries.has('images/ch-1/0001.jpg'));
assert.ok(entries.has('chapters/ch-1/p0001.xhtml'));
const meta = JSON.parse(entries.get('peoplelib/chapters.json').toString('utf8'));
assert.strictEqual(meta.mangaId, 'manga-1');
assert.strictEqual(meta.chapters.length, 1);
assert.strictEqual(meta.chapters[0].chapterId, 'ch-1');
assert.strictEqual(meta.chapters[0].pageCount, 3);
});
test('appendMangaChapter: 合并新章节,保留旧章节图片字节不变,按卷/话排序', () => {
const first = mangaEpub.createMangaEpub({
title: '测试漫画',
mangaId: 'manga-1',
originalLanguage: 'ja',
chapterId: 'ch-2',
label: '第 2 话',
volume: '1',
chapter: '2',
images: [img(10)]
});
const { bytes } = mangaEpub.appendMangaChapter(first.bytes, {
title: '测试漫画',
mangaId: 'manga-1',
originalLanguage: 'ja',
chapterId: 'ch-1',
label: '第 1 话',
volume: '1',
chapter: '1',
images: [img(20), img(21)]
});
const entries = zip.readZip(bytes);
assert.ok(entries.has('images/ch-1/0001.jpg'));
assert.ok(entries.has('images/ch-2/0001.jpg'));
// 旧章节图片字节原样保留,未被重新编码
assert.deepStrictEqual([...entries.get('images/ch-2/0001.jpg')], [10, 10, 10]);
const list = mangaEpub.listChapters(bytes);
assert.strictEqual(list.length, 2);
// 按 volume/chapter 排序:话 1 应排在话 2 前面,尽管是后追加的
assert.strictEqual(list[0].chapterId, 'ch-1');
assert.strictEqual(list[1].chapterId, 'ch-2');
});
test('appendMangaChapter: 追加同一章节 ID 时抛出可读错误', () => {
const first = mangaEpub.createMangaEpub({
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
chapterId: 'ch-1', label: '第 1 话', images: [img(1)]
});
assert.throws(
() => mangaEpub.appendMangaChapter(first.bytes, {
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
chapterId: 'ch-1', label: '重复章节', images: [img(2)]
}),
/该章节已存在/
);
});
test('appendMangaChapter: 追加到不同漫画的 EPUB 会被拒绝', () => {
const first = mangaEpub.createMangaEpub({
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
chapterId: 'ch-1', label: '第 1 话', images: [img(1)]
});
assert.throws(
() => mangaEpub.appendMangaChapter(first.bytes, {
title: '另一部漫画', mangaId: 'manga-2', originalLanguage: 'ja',
chapterId: 'ch-9', label: '第 9 话', images: [img(2)]
}),
/不是同一部漫画/
);
});
test('appendMangaChapter: 目标不是漫画 EPUB(缺少旁路索引)时给出可操作提示', () => {
const plain = zip.writeZip([{ name: 'mimetype', data: Buffer.from('application/epub+zip') }]);
assert.throws(
() => mangaEpub.appendMangaChapter(plain, {
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
chapterId: 'ch-1', label: '第 1 话', images: [img(1)]
}),
/请改为新建条目/
);
});
test('hasChapter: 命中已收录章节,未收录或非法数据都返回 false', () => {
const { bytes } = mangaEpub.createMangaEpub({
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
chapterId: 'ch-1', label: '第 1 话', images: [img(1)]
});
assert.strictEqual(mangaEpub.hasChapter(bytes, 'ch-1'), true);
assert.strictEqual(mangaEpub.hasChapter(bytes, 'ch-missing'), false);
assert.strictEqual(mangaEpub.hasChapter(Buffer.from('not a zip'), 'ch-1'), false);
});
test('compareChapters: 缺省卷号/话号排到最后', () => {
const withNum = { volume: '1', chapter: '1' };
const withoutNum = { volume: '', chapter: '' };
assert.ok(mangaEpub.compareChapters(withNum, withoutNum) < 0);
assert.ok(mangaEpub.compareChapters(withoutNum, withNum) > 0);
});
test('漫画 EPUB 用 sourceId 隔离不同站点的同名 ID', () => {
const created = mangaEpub.createMangaEpub({
title: '中文漫画',
sourceId: 'copymanga',
mangaId: 'same-id',
originalLanguage: 'zh',
chapterId: 'chapter-1',
label: '第 1 话',
images: [img(1)]
});
const parsed = mangaEpub.parseMangaEpub(created.bytes);
assert.strictEqual(parsed.sourceId, 'copymanga');
assert.strictEqual(mangaEpub.isMangaEpub(created.bytes, 'same-id', 'copymanga'), true);
assert.strictEqual(mangaEpub.isMangaEpub(created.bytes, 'same-id', 'mangadex'), false);
assert.throws(() => mangaEpub.appendMangaChapter(parsed, {
title: '另一来源',
sourceId: 'mangadex',
mangaId: 'same-id',
chapterId: 'chapter-2',
label: '第 2 话',
images: [img(2)]
}), /不是同一部漫画/);
});
+146
View File
@@ -0,0 +1,146 @@
const assert = require('node:assert');
const { test, afterEach } = require('node:test');
const mangaDownload = require('../library/manga-download');
const sessions = require('../manga-online/session');
const { responseBytes } = require('../sources/http');
const originalFetchOnlineImage = mangaDownload.fetchOnlineImage;
afterEach(() => {
sessions.reset();
mangaDownload.fetchOnlineImage = originalFetchOnlineImage;
});
function source(overrides) {
return {
id: 'fixture-manga',
chapters: async (_mangaId, page, options) => ({
page,
maxPage: 2,
items: [
{ chapterId: 'c1', label: '第 1 话', pages: 2, group: options.language },
{ chapterId: 'external', label: '站外章节', external: true },
{ chapterId: 'missing', label: '不可用章节', unavailable: true }
]
}),
chapterImageUrls: async () => ({
urls: ['https://image.test/1.png', 'https://image.test/2.png'],
headers: { Referer: 'https://reader.test/' },
mustReport: false
}),
...overrides
};
}
test('在线漫画会话与窗口 sender 绑定,并只授权目录里可用的章节', async () => {
const created = sessions.create(11, source(), {
mangaId: 'manga-1',
title: '在线漫画',
language: 'zh-hk',
quality: 'data'
});
assert.strictEqual(created.title, '在线漫画');
assert.strictEqual(created.quality, 'data');
await assert.rejects(
Promise.resolve().then(() => sessions.meta(12, created.sessionId)),
/会话无效/
);
const chapters = await sessions.chapters(11, created.sessionId, 1);
assert.deepStrictEqual(chapters.items.map((item) => item.chapterId), ['c1']);
assert.strictEqual(chapters.items[0].group, 'zh-hk');
await assert.rejects(
sessions.chapterManifest(11, created.sessionId, 'external'),
/不属于当前在线漫画会话/
);
const manifest = await sessions.chapterManifest(11, created.sessionId, 'c1');
assert.strictEqual(manifest.pages, 2);
});
test('在线图片由主进程代理,节点失败时刷新地址后重试并校验图片格式', async () => {
let sourceCalls = 0;
let imageCalls = 0;
const mangaSource = source({
chapterImageUrls: async () => {
sourceCalls++;
return {
urls: [
`https://image.test/${sourceCalls}/1.png`,
`https://image.test/${sourceCalls}/2.png`
],
headers: { Referer: 'https://reader.test/' },
mustReport: true
};
}
});
mangaDownload.fetchOnlineImage = async (url, headers, mustReport) => {
imageCalls++;
assert.strictEqual(headers.Referer, 'https://reader.test/');
assert.strictEqual(mustReport, true);
if (/\/1\//.test(url)) throw new Error('节点失败');
assert.match(url, /\/2\/[12]\.png$/);
return Buffer.from('89504e470d0a1a0a00000000', 'hex');
};
const created = sessions.create(21, mangaSource, { mangaId: 'manga-2' });
await sessions.chapters(21, created.sessionId, 1);
const manifest = await sessions.chapterManifest(21, created.sessionId, 'c1');
const images = await Promise.all([
sessions.image(21, created.sessionId, manifest.manifestId, 0),
sessions.image(21, created.sessionId, manifest.manifestId, 1)
]);
assert.ok(images.every((image) => image.mimeType === 'image/png'));
assert.strictEqual(imageCalls, 4);
assert.strictEqual(sourceCalls, 2);
});
test('关闭窗口会回收其全部在线漫画会话', () => {
const first = sessions.create(31, source(), { mangaId: 'one' });
const second = sessions.create(31, source(), { mangaId: 'two' });
sessions.closeOwner(31);
assert.throws(() => sessions.meta(31, first.sessionId), /会话无效/);
assert.throws(() => sessions.meta(31, second.sessionId), /会话无效/);
});
test('快速切章时较慢的旧清单不会覆盖当前章节', async () => {
let releaseFirst;
const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
const mangaSource = source({
chapters: async () => ({
items: [
{ chapterId: 'c1', label: '第 1 话' },
{ chapterId: 'c2', label: '第 2 话' }
],
page: 1,
maxPage: 1
}),
chapterImageUrls: async (chapterId) => {
if (chapterId === 'c1') await firstGate;
return { urls: [`https://image.test/${chapterId}.png`], mustReport: false };
}
});
const created = sessions.create(41, mangaSource, { mangaId: 'switching' });
await sessions.chapters(41, created.sessionId, 1);
const first = sessions.chapterManifest(41, created.sessionId, 'c1');
const second = await sessions.chapterManifest(41, created.sessionId, 'c2');
releaseFirst();
await assert.rejects(first, /章节加载已取消/);
assert.strictEqual(second.pages, 1);
});
test('有界响应读取会在分块图片超限时立即取消', async () => {
let pulls = 0;
let cancelled = false;
const body = new ReadableStream({
pull(controller) {
pulls++;
controller.enqueue(new Uint8Array(4));
if (pulls >= 10) controller.close();
},
cancel() { cancelled = true; }
});
const result = await responseBytes(new Response(body), 6);
assert.strictEqual(result, null);
assert.strictEqual(cancelled, true);
assert.ok(pulls < 10);
});
+262
View File
@@ -23,6 +23,19 @@ test('注册表:每个源都实现完整接口', () => {
);
});
test('注册表:两个可用漫画源都提供章节制下载能力与分类', () => {
const list = sources.listSources();
for (const id of ['mangadex', 'copymanga']) {
const entry = list.find((source) => source.id === id);
assert.strictEqual(entry.chapterBased, true);
assert.strictEqual(entry.category, 'manga');
const source = sources.getSource(id);
assert.strictEqual(typeof source.chapters, 'function');
assert.strictEqual(typeof source.downloadChapter, 'function');
}
assert.strictEqual(list.find((source) => source.id === 'copymanga').experimental, true);
});
test('注册表:开放教材与中英文维基文库已启用', () => {
const ids = sources.listSources().map((source) => source.id);
for (const id of ['openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en']) {
@@ -710,3 +723,252 @@ test('gutenberg: 解析格式与封面', async () => {
assert.strictEqual(r.items[0].cover, 'https://x/c.jpg');
assert.strictEqual(r.maxPage, 2);
});
// --- MangaDex ---
test('mangadex: 列表映射本地化标题、作者与封面,并传递安全分级', async () => {
h.resetCalls();
h.setHandler(h.routes([['api.mangadex.org/manga?', {
body: {
total: 41,
data: [{
id: 'manga-1',
attributes: {
title: { ja: '原题', en: 'English title' },
year: 2024
},
relationships: [
{ type: 'cover_art', attributes: { fileName: 'cover.jpg' } },
{ type: 'author', attributes: { name: '作者' } },
{ type: 'artist', attributes: { name: '画师' } }
]
}]
}
}]]));
const result = await sources.getSource('mangadex').list(2);
assert.strictEqual(result.page, 2);
assert.strictEqual(result.maxPage, 3);
assert.deepStrictEqual(result.items[0], {
postId: 'manga-1',
title: 'English title',
cover: 'https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg',
date: '2024',
url: 'https://mangadex.org/title/manga-1',
subtitle: '作者, 画师'
});
const request = new URL(h.getCalls().at(-1).url);
assert.strictEqual(request.searchParams.get('offset'), '20');
assert.deepStrictEqual(request.searchParams.getAll('contentRating[]'), ['safe', 'suggestive']);
assert.deepStrictEqual(request.searchParams.getAll('includes[]'), ['cover_art', 'author', 'artist']);
});
test('mangadex: 空搜索词直接返回空结果,不发起网络请求', async () => {
h.resetCalls();
const result = await sources.getSource('mangadex').search(' ', 3);
assert.deepStrictEqual(result, { items: [], maxPage: 1, page: 3 });
assert.strictEqual(h.getCalls().length, 0);
});
test('mangadex: 详情映射状态、分级、标签与原始语言', async () => {
h.setHandler(h.routes([['api.mangadex.org/manga/manga-1?', {
body: {
data: {
id: 'manga-1',
attributes: {
title: { zh: '中文标题' },
description: { zh: '简介' },
status: 'ongoing',
publicationDemographic: 'shounen',
originalLanguage: 'ja',
year: 2023,
tags: [{ attributes: { name: { en: 'Action' } } }]
},
relationships: [{ type: 'author', attributes: { name: '作者' } }]
}
}
}]]));
const result = await sources.getSource('mangadex').detail('manga-1');
assert.strictEqual(result.title, '中文标题');
assert.strictEqual(result.brief, '简介');
assert.deepStrictEqual(result.tags, ['状态:连载中', '分级:少年', '标签:Action']);
assert.strictEqual(result.originalLanguage, 'ja');
assert.deepStrictEqual(result.authors, ['作者']);
});
test('mangadex: 章节列表映射翻译组、站外与不可用状态', async () => {
h.resetCalls();
h.setHandler(h.routes([['/manga/manga-1/feed?', {
body: {
total: 101,
data: [{
id: 'chapter-1',
attributes: {
volume: '2',
chapter: '3.5',
title: '番外',
translatedLanguage: 'zh-hk',
pages: 18,
publishAt: '2026-01-02T00:00:00Z',
externalUrl: 'https://example.test/read',
isUnavailable: true
},
relationships: [{ type: 'scanlation_group', attributes: { name: '翻译组' } }]
}]
}
}]]));
const result = await sources.getSource('mangadex').chapters('manga-1', 1, { language: 'zh-hk' });
assert.strictEqual(result.maxPage, 2);
assert.deepStrictEqual(result.items[0], {
chapterId: 'chapter-1',
volume: '2',
chapter: '3.5',
title: '番外',
label: '第 2 卷 第 3.5 话 番外',
translatedLanguage: 'zh-hk',
pages: 18,
publishAt: '2026-01-02T00:00:00Z',
group: '翻译组',
external: true,
unavailable: true
});
const request = new URL(h.getCalls().at(-1).url);
assert.deepStrictEqual(request.searchParams.getAll('translatedLanguage[]'), ['zh-hk']);
});
test('mangadex: 原图与压缩图使用正确的 MangaDex@Home 路径', async () => {
const source = sources.getSource('mangadex');
const originalAtHome = source.atHome;
source.atHome = async () => ({
baseUrl: 'https://node.example.test',
chapter: {
hash: 'hash-1',
data: ['one.jpg'],
dataSaver: ['one-small.jpg']
}
});
try {
const original = await source.chapterImageUrls('chapter-1', 'data');
assert.deepStrictEqual(original.urls, ['https://node.example.test/data/hash-1/one.jpg']);
assert.strictEqual(original.mustReport, true);
const saver = await source.chapterImageUrls('chapter-1', 'dataSaver');
assert.deepStrictEqual(saver.urls, ['https://node.example.test/data-saver/hash-1/one-small.jpg']);
assert.strictEqual(saver.mustReport, true);
} finally {
source.atHome = originalAtHome;
}
});
test('mangadex: 官方图片域名无需上报,下载整部时提示先选章节', async () => {
const source = sources.getSource('mangadex');
const originalAtHome = source.atHome;
source.atHome = async () => ({
baseUrl: 'https://uploads.mangadex.org',
chapter: { hash: 'hash-1', data: ['one.png'], dataSaver: ['one.png'] }
});
try {
const result = await source.chapterImageUrls('chapter-1', 'data');
assert.strictEqual(result.mustReport, false);
} finally {
source.atHome = originalAtHome;
}
await assert.rejects(source.download('manga-1'), /选择要下载的具体章节/);
});
// --- 拷贝漫画 ---
test('copymanga: 列表与搜索使用移动端 API 的分页结构', async () => {
h.setHandler(h.routes([
['/api/v3/comics?', {
body: {
results: {
total: 43,
list: [{
name: '漫画甲',
path_word: 'comic-a',
cover: 'https://img/a.jpg',
author: [{ name: '作者甲' }],
datetime_updated: '2026-08-08'
}]
}
}
}],
['/api/v3/search/comic?', {
body: {
results: {
total: 1,
list: [{ name: '漫画乙', path_word: 'comic-b', author: [] }]
}
}
}]
]));
const source = sources.getSource('copymanga');
const listed = await source.list(2);
assert.strictEqual(listed.maxPage, 3);
assert.strictEqual(listed.items[0].subtitle, '作者甲');
const searched = await source.search('漫画', 1);
assert.strictEqual(searched.items[0].postId, 'comic-b');
});
test('copymanga: 详情、分组章节和章节图片按当前 v3 API 映射', async () => {
h.resetCalls();
h.setHandler(h.routes([
['/api/v3/comic2/comic-a?', {
body: {
results: {
comic: {
uuid: 'book-1',
name: '漫画甲',
path_word: 'comic-a',
cover: 'https://img/cover.jpg',
reclass: { value: 1, display: '漫画' },
region: { display: '日本' },
status: { display: '连载中' },
author: [{ name: '作者' }],
theme: [{ name: '冒险' }],
brief: '简介',
datetime_updated: '2026-08-08'
},
groups: { default: { path_word: 'default', count: 2, name: '默认' } }
}
}
}],
['/group/default/chapters?', {
body: {
results: {
list: [
{ uuid: 'c1', name: '第 1 话', ordered: 10, size: 12, datetime_created: '2026-01-01' },
{ uuid: 'c2', name: '第 2 话', ordered: 20, size: 13, datetime_created: '2026-01-02' }
]
}
}
}],
['/chapter/c1?', {
body: {
results: {
chapter: {
contents: [{ url: 'https://img/1.jpg' }, { url: 'https://img/2.webp' }]
}
}
}
}]
]));
const source = sources.getSource('copymanga');
const detail = await source.detail('comic-a');
assert.deepStrictEqual(detail.tags, ['状态:连载中', '地区:日本', '标签:冒险']);
const chapters = await source.chapters('comic-a', 1);
assert.deepStrictEqual(chapters.items.map((item) => item.chapterId), ['comic-a||c1', 'comic-a||c2']);
assert.strictEqual(chapters.items[0].chapter, '1');
await source.chapters('comic-a', 1);
assert.strictEqual(
h.getCalls().filter((call) => call.url.includes('/group/default/chapters?')).length,
1,
'重复翻页不应重新抓取完整章节目录'
);
const images = await source.chapterImageUrls('comic-a||c1');
assert.deepStrictEqual(images.urls, ['https://img/1.jpg', 'https://img/2.webp']);
});
+48
View File
@@ -240,6 +240,54 @@ test('Z-Library 进入详情不消耗下载额度,点击下载后才解析并
assert.match(onDemand, /每日免费下载额度有限,仅在点击后获取下载地址/);
});
test('漫画源按分类显示,MangaDex 可筛选中文章节并设置图片画质', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
assert.match(html, /id="mangadexQualitySelect"[\s\S]*value="dataSaver"[\s\S]*value="data"/);
assert.match(html, /id="mangadexLanguageSelect"[\s\S]*value="zh"[\s\S]*value="zh-hk"[\s\S]*value="all"/);
assert.match(app, /settings\.get\('mangadex\.imageQuality', 'dataSaver'\)/);
assert.match(app, /settings\.set\('mangadex\.imageQuality'/);
assert.match(app, /settings\.get\('mangadex\.chapterLanguage', 'zh'\)/);
assert.match(app, /settings\.set\('mangadex\.chapterLanguage'/);
assert.match(browse, /\['academic', '学术论文'\]/);
assert.match(browse, /\['manga', '漫画'\]/);
assert.match(browse, /<optgroup label="\$\{label\}">/);
assert.match(browse, /sourceIsChapterBased/);
assert.match(browse, /window\.api\.sources\.chapters\(sourceId, mangaId, page, options\)/);
assert.match(browse, /window\.api\.sources\.downloadChapter\(sourceId, payload/);
assert.match(browse, /settings\.get\('mangadex\.chapterLanguage', 'zh'\)/);
assert.match(browse, /settings\.get\('mangadex\.imageQuality', 'dataSaver'\)/);
assert.match(browse, /createDownloadProgress\(row\)/);
assert.match(preload, /chapters:\s*\(sourceId, postId, page, options\)[\s\S]*source:chapters/);
assert.match(preload, /downloadChapter:\s*\(sourceId, payload, onProgress\).*runChapterDownload/);
assert.match(preload, /invokeWithProgress\(\s*'source:chapterProgress',\s*'source:downloadChapter'/);
assert.match(preload, /removeListener\(progressChannel, listener\)/);
assert.match(css, /\.chapter-toolbar\s*\{/);
});
test('漫画详情提供不入库的整部在线阅读器,并通过独立 preload 懒加载图片', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'manga-online.html'), 'utf8');
const script = fs.readFileSync(path.join(__dirname, '..', 'ui', 'manga-online.js'), 'utf8');
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'manga-online-preload.js'), 'utf8');
assert.match(browse, /id="onlineReadBtn">在线阅读整部漫画/);
assert.match(browse, /window\.api\.sources\.readOnline\(state\.activeSourceId/);
assert.doesNotMatch(browse.slice(browse.indexOf('async function readOnline'), browse.indexOf('async function loadChapters')), /library\.add/);
assert.match(html, /Content-Security-Policy" content="default-src 'self'; img-src 'self' blob: data:/);
assert.match(html, /id="chapterList"/);
assert.match(html, /id="pageStack"/);
assert.match(script, /new IntersectionObserver/);
assert.match(script, /URL\.createObjectURL\(new Blob/);
assert.match(script, /ensureNextChapter/);
assert.match(preload, /mangaOnline:image/);
assert.doesNotMatch(preload, /library:|reader:bytes|shell:openPath/);
});
test('主窗口在设置旁提供持久化明暗主题切换', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
+69
View File
@@ -0,0 +1,69 @@
const test = require('node:test');
const assert = require('node:assert');
const zip = require('../zip');
test('writeZip/readZip: 基本往返,文本与二进制内容不失真', () => {
const entries = [
{ name: 'mimetype', data: Buffer.from('application/epub+zip') },
{ name: 'a/b.txt', data: Buffer.from('hello 你好', 'utf8') },
{ name: 'c.bin', data: Buffer.from([0, 1, 2, 255, 254, 128]) }
];
const buf = zip.writeZip(entries);
const parsed = zip.readZip(buf);
assert.deepStrictEqual([...parsed.keys()], ['mimetype', 'a/b.txt', 'c.bin']);
assert.strictEqual(parsed.get('a/b.txt').toString('utf8'), 'hello 你好');
assert.deepStrictEqual([...parsed.get('c.bin')], [0, 1, 2, 255, 254, 128]);
});
test('writeZip/readZip: 大文件(跨多个 chunk)内容比特级一致', () => {
const big = Buffer.alloc(500000);
for (let i = 0; i < big.length; i++) big[i] = i % 256;
const buf = zip.writeZip([{ name: 'big.bin', data: big }]);
const parsed = zip.readZip(buf);
assert.strictEqual(Buffer.compare(parsed.get('big.bin'), big), 0);
});
test('readZip: 能解析第三方(JSZip)产出的 DEFLATE 压缩包', async () => {
const JSZip = require('../ui/vendor/jszip.min.js');
const jz = new JSZip();
jz.file('x.txt', 'DEFLATE 测试内容');
const buf = await jz.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
const parsed = zip.readZip(buf);
assert.strictEqual(parsed.get('x.txt').toString('utf8'), 'DEFLATE 测试内容');
});
test('writeZip: 产出的包能被第三方(JSZip)正确打开', async () => {
const JSZip = require('../ui/vendor/jszip.min.js');
const buf = zip.writeZip([
{ name: 'mimetype', data: Buffer.from('application/epub+zip') },
{ name: 'nested/dir/file.txt', data: Buffer.from('nested content') }
]);
const jz = await JSZip.loadAsync(buf);
assert.ok(jz.file('mimetype'));
const text = await jz.file('nested/dir/file.txt').async('string');
assert.strictEqual(text, 'nested content');
});
test('readZip: 空 ZIPEOCD 但无条目)不抛错,返回空 Map', () => {
const buf = zip.writeZip([]);
const parsed = zip.readZip(buf);
assert.strictEqual(parsed.size, 0);
});
test('readZip: 非 ZIP 数据抛出可读错误而不是崩溃', () => {
assert.throws(() => zip.readZip(Buffer.from('not a zip file')), /不是有效的 ZIP 文件/);
});
test('readZip: 不把未知压缩方式误当作 STORE 内容', () => {
const buf = zip.writeZip([{ name: 'x.bin', data: Buffer.from([1, 2, 3]) }]);
const central = buf.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
buf.writeUInt16LE(99, central + 10);
assert.throws(() => zip.readZip(buf), /压缩方式不受支持/);
});
test('readZip: 条目内容损坏时通过 CRC 拒绝继续读取', () => {
const buf = zip.writeZip([{ name: 'x.bin', data: Buffer.from([1, 2, 3]) }]);
const dataStart = 30 + Buffer.byteLength('x.bin');
buf[dataStart] ^= 0xff;
assert.throws(() => zip.readZip(buf), /条目校验失败/);
});