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
+69 -2
View File
@@ -108,6 +108,8 @@ const annotations = require('./src/reader/annotations');
const noteAssets = require('./src/reader/note-assets');
const readerWindow = require('./src/reader/window');
const noteWindow = require('./src/reader/note-window');
const mangaOnlineWindow = require('./src/manga-online/window');
const mangaOnlineSessions = require('./src/manga-online/session');
const rangeSessions = require('./src/reader/range-sessions');
const aiConfig = require('./src/reader/ai-config');
const aiClient = require('./src/reader/ai-client');
@@ -276,7 +278,7 @@ noteWindow.setChangeListener(notifyNoteWindowsChanged);
function applyWindowIcons(theme) {
currentUiTheme = theme === 'light' ? 'light' : 'dark';
const icon = iconForTheme(currentUiTheme);
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all(), ...mangaOnlineWindow.all()];
for (const win of windows) {
if (!win || win.isDestroyed()) continue;
try { win.setIcon(icon); } catch (e) { /* 平台不支持动态图标时保留创建时图标 */ }
@@ -284,7 +286,7 @@ function applyWindowIcons(theme) {
}
function notifyUiThemeChanged() {
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all(), ...mangaOnlineWindow.all()];
for (const win of windows) {
if (win && !win.isDestroyed()) {
win.webContents.send('ui:themeChanged', currentUiTheme);
@@ -314,6 +316,7 @@ app.on('before-quit', () => {
for (const download of downloadSessions.values()) discardDownloadSession(download);
coverGenerator.close();
rangeSessions.closeAll().catch(() => {});
mangaOnlineSessions.reset();
});
// fn 同步抛出时也必须变成 { ok:false },否则 invoke 直接 reject
@@ -332,6 +335,70 @@ ipcMain.handle('source:search', (_e, sourceId, keyword, page) => wrap(() => sour
ipcMain.handle('source:detail', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).detail(postId)));
ipcMain.handle('source:download', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).download(postId)));
// 按章节下载的源不复用 download:file;后者只处理单个直链文件。
ipcMain.handle('source:chapters', (_e, sourceId, postId, page, options) => wrap(() => {
const source = sources.getSource(sourceId);
if (typeof source.chapters !== 'function') throw new Error('该数据源不支持章节列表');
return source.chapters(postId, page, options || {});
}));
ipcMain.handle('source:readOnline', (event, sourceId, input) => wrap(() => {
if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) {
throw new Error('只有主窗口可以打开在线漫画');
}
const source = sources.getSource(sourceId);
const win = mangaOnlineWindow.create(__dirname, currentUiTheme);
const ownerId = win.webContents.id;
win.webContents.once('destroyed', () => mangaOnlineSessions.closeOwner(ownerId));
try {
const value = mangaOnlineSessions.create(ownerId, source, input || {});
mangaOnlineWindow.load(__dirname, value.sessionId);
return value;
} catch (error) {
win.destroy();
throw error;
}
}));
ipcMain.handle('source:downloadChapter', async (event, sourceId, payload, requestId) => {
const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
const sendProgress = (done, total) => {
if (!progressId || event.sender.isDestroyed()) return;
event.sender.send('source:chapterProgress', { requestId: progressId, done, total });
};
return wrap(async () => {
const source = sources.getSource(sourceId);
if (typeof source.downloadChapter !== 'function') throw new Error('该数据源不支持章节下载');
// library.add()/attachFile() 内部已经在 commit() 里触发变更通知,这里不需要再发一次
const result = await source.downloadChapter(library, payload || {}, sendProgress);
return { entryId: result.entry ? result.entry.id : null, chapterLabel: result.chapterLabel, created: result.created };
});
});
function requireMangaOnlineSender(event) {
if (!mangaOnlineWindow.fromWebContents(event.sender)) throw new Error('只有在线漫画阅读器可以读取内容');
return event.sender.id;
}
ipcMain.handle('mangaOnline:meta', (event, sessionId) => wrap(() => (
mangaOnlineSessions.meta(requireMangaOnlineSender(event), sessionId)
)));
ipcMain.handle('mangaOnline:chapters', (event, sessionId, page) => wrap(() => (
mangaOnlineSessions.chapters(requireMangaOnlineSender(event), sessionId, page)
)));
ipcMain.handle('mangaOnline:chapterManifest', (event, sessionId, chapterId) => wrap(() => (
mangaOnlineSessions.chapterManifest(requireMangaOnlineSender(event), sessionId, chapterId)
)));
ipcMain.handle('mangaOnline:image', (event, sessionId, manifestId, index) => wrap(() => (
mangaOnlineSessions.image(
requireMangaOnlineSender(event),
sessionId,
manifestId,
index
)
)));
ipcMain.handle('mangaOnline:close', (event, sessionId) => wrap(() => (
mangaOnlineSessions.close(requireMangaOnlineSender(event), sessionId)
)));
// 代理配置:全局生效,影响所有数据源的 HTTP 请求与文件下载
ipcMain.handle('proxy:get', () => wrap(() => getProxy()));
ipcMain.handle('proxy:set', (_e, url) => {
+23
View File
@@ -0,0 +1,23 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('mangaApi', {
meta: (sessionId) => ipcRenderer.invoke('mangaOnline:meta', sessionId),
chapters: (sessionId, page) => ipcRenderer.invoke('mangaOnline:chapters', sessionId, page),
chapterManifest: (sessionId, chapterId) => (
ipcRenderer.invoke('mangaOnline:chapterManifest', sessionId, chapterId)
),
image: (sessionId, manifestId, index) => (
ipcRenderer.invoke('mangaOnline:image', sessionId, manifestId, index)
),
closeSession: (sessionId) => ipcRenderer.invoke('mangaOnline:close', sessionId),
getTheme: () => ipcRenderer.invoke('ui:getTheme'),
setTheme: (theme) => ipcRenderer.invoke('ui:setTheme', theme),
onThemeChanged: (callback) => {
const listener = (_event, theme) => callback(theme);
ipcRenderer.on('ui:themeChanged', listener);
return () => ipcRenderer.removeListener('ui:themeChanged', listener);
},
minimize: () => ipcRenderer.send('win:minimize'),
maximize: () => ipcRenderer.send('win:maximize'),
close: () => ipcRenderer.send('win:close')
});
+32 -5
View File
@@ -1,15 +1,25 @@
const { contextBridge, ipcRenderer } = require('electron');
let downloadSeq = 0;
function runDownload(requestId, url, suggestName, entryId, extraHeaders, meta, onProgress) {
function invokeWithProgress(progressChannel, invokeChannel, requestId, args, onProgress) {
const listener = (_event, data) => {
if (!data || data.requestId !== requestId || typeof onProgress !== 'function') return;
try { onProgress(data); } catch (e) { /* 渲染层进度回调异常不影响下载 */ }
};
if (typeof onProgress === 'function') ipcRenderer.on('download:progress', listener);
if (typeof onProgress === 'function') ipcRenderer.on(progressChannel, listener);
return ipcRenderer
.invoke('download:file', url, suggestName, entryId, extraHeaders, meta, requestId)
.finally(() => ipcRenderer.removeListener('download:progress', listener));
.invoke(invokeChannel, ...args, requestId)
.finally(() => ipcRenderer.removeListener(progressChannel, listener));
}
function runDownload(requestId, url, suggestName, entryId, extraHeaders, meta, onProgress) {
return invokeWithProgress(
'download:progress',
'download:file',
requestId,
[url, suggestName, entryId, extraHeaders, meta],
onProgress
);
}
function downloadFile(url, suggestName, entryId, extraHeaders, meta, onProgress) {
@@ -40,13 +50,30 @@ function captureReaderRect(rect) {
return ipcRenderer.invoke('reader:captureRect', area);
}
let chapterDownloadSeq = 0;
function runChapterDownload(sourceId, payload, onProgress) {
const requestId = `chapter_${Date.now().toString(36)}_${(++chapterDownloadSeq).toString(36)}`;
return invokeWithProgress(
'source:chapterProgress',
'source:downloadChapter',
requestId,
[sourceId, payload],
onProgress
);
}
contextBridge.exposeInMainWorld('api', {
sources: {
list: () => ipcRenderer.invoke('sources:list'),
browse: (sourceId, page) => ipcRenderer.invoke('source:list', sourceId, page),
search: (sourceId, keyword, page) => ipcRenderer.invoke('source:search', sourceId, keyword, page),
detail: (sourceId, postId) => ipcRenderer.invoke('source:detail', sourceId, postId),
download: (sourceId, postId) => ipcRenderer.invoke('source:download', sourceId, postId)
download: (sourceId, postId) => ipcRenderer.invoke('source:download', sourceId, postId),
chapters: (sourceId, postId, page, options) => (
ipcRenderer.invoke('source:chapters', sourceId, postId, page, options)
),
readOnline: (sourceId, input) => ipcRenderer.invoke('source:readOnline', sourceId, input),
downloadChapter: (sourceId, payload, onProgress) => runChapterDownload(sourceId, payload, onProgress)
},
library: {
list: () => ipcRenderer.invoke('library:list'),
+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), /条目校验失败/);
});
+18 -6
View File
@@ -32,18 +32,20 @@ function syncDirectory(dir) {
}
}
// 写入 dest,保留一份 .bak 以便下次读取时恢复。
// 失败时清理临时文件,并在目标已被改走时把备份换回去。
function writeJson(dest, value) {
function writeAtomicReplace(dest, data, encoding) {
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
writeSynced(temp, JSON.stringify(value, null, 2));
writeSynced(temp, data, encoding);
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) {
fs.renameSync(dest, backup);
try {
fs.linkSync(dest, backup);
} catch (e) {
fs.copyFileSync(dest, backup);
}
backedUp = true;
}
fs.renameSync(temp, dest);
@@ -55,11 +57,17 @@ function writeJson(dest, value) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
else if (backedUp && fs.existsSync(backup)) fs.unlinkSync(backup);
} catch (rollback) { /* 下次读取时从 .bak 恢复 */ }
throw e;
}
}
// 写入 dest,保留一份 .bak 以便写入失败或异常退出时恢复。
function writeJson(dest, value) {
writeAtomicReplace(dest, JSON.stringify(value, null, 2), 'utf8');
}
// 二进制落地(封面等)。已存在则不覆盖,由调用方决定语义。
function writeBytesExclusive(dest, bytes) {
const temp = `${dest}.tmp`;
@@ -75,4 +83,8 @@ function writeBytesExclusive(dest, bytes) {
syncDirectory(path.dirname(dest));
}
module.exports = { writeJson, writeSynced, syncDirectory, writeBytesExclusive };
function writeBytes(dest, bytes) {
writeAtomicReplace(dest, bytes, null);
}
module.exports = { writeJson, writeSynced, syncDirectory, writeBytesExclusive, writeBytes };
+284
View File
@@ -0,0 +1,284 @@
// MangaDex 章节下载编排:抓图 -> 组装/追加 EPUB -> 落库。
//
// 图片必须由本进程代理转发(渲染层永远不直连 mangadex 域名,参见 AGENTS 安全边界),
// 这里就是那层代理。@Home 节点的健康上报是强制义务:不上报,故障节点不会被剔除,
// 参见 https://api.mangadex.org/docs/04-chapter/retrieving-chapter/。
const fs = require('fs');
const path = require('path');
const { fetchWithProxy, responseBytes, UA } = require('../sources/http');
const atomic = require('../atomic-file');
const mangaEpub = require('./manga-epub');
const MAX_CONCURRENCY = 4;
const MAX_CHAPTER_RETRIES = 1; // 失败页重新申请 baseUrl 后只重试一次,避免无限重试卡死
const IMAGE_TIMEOUT_MS = 30000;
const REPORT_TIMEOUT_MS = 15000;
const MAX_PAGE_BYTES = 50 * 1024 * 1024;
const MAX_CHAPTER_BYTES = 512 * 1024 * 1024;
const mangaQueues = new Map();
function limiter(limit) {
let active = 0;
const waiting = [];
return async (fn) => {
if (active >= limit) await new Promise((resolve) => waiting.push(resolve));
else active++;
try {
return await fn();
} finally {
const next = waiting.shift();
if (next) next();
else active--;
}
};
}
const withImageSlot = limiter(8);
const withReportSlot = limiter(8);
function extFromUrl(url) {
const m = String(url).match(/\.([a-z0-9]+)(?:$|[?#])/i);
return m ? m[1].toLowerCase() : 'jpg';
}
async function fetchImage(url, extraHeaders, maxBytes = MAX_PAGE_BYTES, outerSignal) {
const started = Date.now();
const controller = new AbortController();
const abort = () => controller.abort();
if (outerSignal) {
if (outerSignal.aborted) controller.abort();
else outerSignal.addEventListener('abort', abort, { once: true });
}
const timer = setTimeout(abort, IMAGE_TIMEOUT_MS);
try {
const res = await fetchWithProxy(url, {
headers: { 'User-Agent': UA, ...(extraHeaders || {}) },
signal: controller.signal
});
if (!res.ok) {
if (res.body && typeof res.body.cancel === 'function') await res.body.cancel().catch(() => {});
return { ok: false, bytes: 0, duration: Date.now() - started };
}
const cached = /^HIT/i.test(res.headers.get('x-cache') || '');
const data = await responseBytes(res, maxBytes);
if (!data) return { ok: false, bytes: 0, duration: Date.now() - started };
return { ok: true, data, bytes: data.length, duration: Date.now() - started, cached };
} catch (e) {
return { ok: false, bytes: 0, duration: Date.now() - started };
} finally {
clearTimeout(timer);
if (outerSignal) outerSignal.removeEventListener('abort', abort);
}
}
async function fetchOnlineImage(url, headers, mustReport, maxBytes, signal) {
const result = await withImageSlot(() => fetchImage(url, headers, maxBytes, signal));
if (mustReport) await withReportSlot(() => reportOne(url, result));
if (!result.ok || !result.data) throw new Error('漫画图片加载失败,请重试');
return result.data;
}
// 上报失败不影响下载结果本身:健康检测允许丢失个别上报,不值得因此让整个下载失败。
async function reportOne(url, result) {
try {
const res = await fetchWithProxy('https://api.mangadex.network/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': UA },
signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),
body: JSON.stringify({
url,
success: !!result.ok,
bytes: result.bytes || 0,
duration: result.duration || 0,
cached: !!result.cached
})
});
if (res.body && typeof res.body.cancel === 'function') await res.body.cancel().catch(() => {});
} catch (e) { /* ignore */ }
}
async function downloadPages(
urls,
mustReport,
headers,
onProgress,
doneOffset,
total,
maxBytes = MAX_CHAPTER_BYTES
) {
const results = new Array(urls.length).fill(null);
const reports = [];
let cursor = 0;
let done = doneOffset || 0;
let downloadedBytes = 0;
let overLimit = false;
async function worker() {
for (;;) {
if (overLimit) return;
const index = cursor++;
if (index >= urls.length) return;
const result = await withImageSlot(() => fetchImage(urls[index], headers));
results[index] = result;
if (mustReport) reports.push(withReportSlot(() => reportOne(urls[index], result)));
if (result.ok) {
downloadedBytes += result.bytes;
if (downloadedBytes > maxBytes) {
overLimit = true;
continue;
}
done++;
if (onProgress) onProgress(done, total || urls.length);
}
}
}
const workers = Array.from({ length: Math.min(MAX_CONCURRENCY, urls.length) }, worker);
await Promise.all(workers);
await Promise.all(reports);
if (overLimit) throw new Error('单章漫画图片超过 512 MB,已停止下载');
return results;
}
// 返回按页码顺序排列的 [{ ext, data }],供 manga-epub 组装。
async function fetchChapterImages(mangaSource, chapterId, quality, onProgress) {
let { urls, mustReport, headers } = await mangaSource.chapterImageUrls(chapterId, quality);
const total = urls.length;
let results = await downloadPages(urls, mustReport, headers, onProgress, 0, total);
let failedIndexes = results.map((r, i) => (r.ok ? -1 : i)).filter((i) => i >= 0);
for (let attempt = 0; attempt < MAX_CHAPTER_RETRIES && failedIndexes.length; attempt++) {
// 同一 baseUrl 反复失败大概率是节点故障,必须重新申请(可能换一台节点)再试
const fresh = await mangaSource.chapterImageUrls(chapterId, quality);
urls = fresh.urls;
mustReport = fresh.mustReport;
headers = fresh.headers;
const retryUrls = failedIndexes.map((i) => urls[i]);
const retryResults = await downloadPages(
retryUrls,
mustReport,
headers,
onProgress,
total - failedIndexes.length,
total,
MAX_CHAPTER_BYTES - results.reduce((sum, result) => (
sum + (result && result.ok ? result.bytes : 0)
), 0)
);
failedIndexes.forEach((originalIndex, k) => { results[originalIndex] = retryResults[k]; });
failedIndexes = results.map((r, i) => (r.ok ? -1 : i)).filter((i) => i >= 0);
}
if (failedIndexes.length) {
throw new Error(`章节下载失败:${failedIndexes.length}/${total} 张图片无法获取,请重试`);
}
return results.map((r, i) => ({ ext: extFromUrl(urls[i]), data: r.data }));
}
// payload: { mangaId, title, cover, authors, date, brief, url, originalLanguage,
// chapterId, label, volume, chapter, translatedLanguage, group, quality }
function findTarget(library, sourceId, mangaId, chapterId) {
const existing = library.findBySource(sourceId, mangaId);
if (existing) {
for (const file of (existing.files || []).filter((f) => f.exists && /\.epub$/i.test(f.path || f.name || ''))) {
const bytes = fs.readFileSync(file.path);
let parsed;
try {
parsed = mangaEpub.parseMangaEpub(bytes);
} catch (e) {
continue;
}
if (parsed.sourceId !== sourceId || parsed.mangaId !== mangaId) continue;
if (parsed.chapters.some((chapter) => chapter.chapterId === chapterId)) {
throw new Error('该章节已下载');
}
return { existing, existingFile: file, existingParsed: parsed };
}
}
return { existing, existingFile: null, existingParsed: null };
}
async function saveChapter(library, sourceId, payload, images, target) {
const mangaId = String(payload && payload.mangaId || '').trim();
const chapterId = String(payload && payload.chapterId || '').trim();
const { existing, existingFile, existingParsed } = target;
const chapterFields = {
chapterId,
label: payload.label || '未命名章节',
volume: payload.volume || '',
chapter: payload.chapter || '',
translatedLanguage: payload.translatedLanguage || '',
group: payload.group || ''
};
if (existing && existingFile && existingParsed) {
const { bytes: nextBytes } = mangaEpub.appendMangaChapter(existingParsed, {
title: existing.title,
sourceId,
mangaId,
originalLanguage: payload.originalLanguage || 'ja',
images,
...chapterFields
});
atomic.writeBytes(existingFile.path, nextBytes);
const updated = library.attachFile(existing.id, existingFile.path);
return { entry: updated, chapterLabel: chapterFields.label, created: false };
}
const { bytes } = mangaEpub.createMangaEpub({
title: (existing && existing.title) || payload.title || '未命名漫画',
sourceId,
mangaId,
originalLanguage: payload.originalLanguage || 'ja',
images,
...chapterFields
});
const fileTitle = (existing && existing.title) || payload.title || '未命名漫画';
const absPath = library.allocFilePath(`${fileTitle}.epub`);
atomic.writeBytesExclusive(absPath, bytes);
// 已有条目但原文件缺失(用户手动删过文件):把新文件挂回同一条目,
// 不能再调 library.add(),否则同一个 sourceId+sourcePostId 会出现两条记录,
// findBySource 只能返回其中一条,另一条从此变成孤儿。
if (existing) {
const updated = library.attachFile(existing.id, absPath);
return { entry: updated, chapterLabel: chapterFields.label, created: false };
}
const created = library.add({
title: payload.title || '未命名漫画',
authors: payload.authors || [],
cover: payload.cover || '',
date: payload.date || '',
brief: payload.brief || '',
url: payload.url || '',
sourceId,
sourcePostId: mangaId,
files: [{ path: absPath, name: path.basename(absPath), format: 'EPUB' }]
});
return { entry: created, chapterLabel: chapterFields.label, created: true };
}
function downloadChapter(mangaSource, library, payload, onProgress) {
const sourceId = String(mangaSource && mangaSource.id || '').trim();
const mangaId = String(payload && payload.mangaId || '').trim();
const chapterId = String(payload && payload.chapterId || '').trim();
if (!sourceId) return Promise.reject(new Error('缺少漫画源 ID'));
if (!mangaId) return Promise.reject(new Error('缺少漫画 ID'));
if (!chapterId) return Promise.reject(new Error('缺少章节 ID'));
const quality = payload.quality === 'data' ? 'data' : 'dataSaver';
const queueKey = `${sourceId}:${mangaId}`;
const previous = mangaQueues.get(queueKey) || Promise.resolve();
const current = previous
.catch(() => {})
.then(async () => {
const target = findTarget(library, sourceId, mangaId, chapterId);
const images = await fetchChapterImages(mangaSource, chapterId, quality, onProgress);
return saveChapter(library, sourceId, payload, images, target);
});
mangaQueues.set(queueKey, current);
return current.finally(() => {
if (mangaQueues.get(queueKey) === current) mangaQueues.delete(queueKey);
});
}
module.exports = { fetchChapterImages, fetchOnlineImage, downloadChapter, extFromUrl };
+226
View File
@@ -0,0 +1,226 @@
// 把漫画章节的图片序列组装成 EPUB(每页一张全屏图片),交给现有 epub 阅读器渲染。
// 不新增专用的分页漫画阅读器:epub-adapter 已经会把图片转成 data URL 内联展示,
// 复用它是最小的实现面(做法参照 text-adapter 把 txt/md 转 EPUB 复用同一渲染路径)。
//
// 章节追加(同一部漫画的多个章节合并进一条书库条目)靠 peoplelib/chapters.json
// 这个非标准的旁路文件记录每章的文件清单:追加新章节时不需要重新下载/重新编码
// 已有章节的图片,直接从旧 zip 里搬运原始字节,只重建 opf/nav/清单三个小文件。
const { writeZip, readZip } = require('../zip');
const XHTML_NS = 'http://www.w3.org/1999/xhtml';
const CONTAINER_XML = '<?xml version="1.0" encoding="UTF-8"?>'
+ '<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
+ '<rootfiles><rootfile full-path="content.opf" media-type="application/oebps-package+xml"/></rootfiles>'
+ '</container>';
function xmlText(value) {
return String(value == null ? '' : value)
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function xmlAttr(value) {
return xmlText(value).replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
function extMime(ext) {
const e = String(ext || '').toLowerCase();
if (e === 'jpg' || e === 'jpeg') return 'image/jpeg';
if (e === 'png') return 'image/png';
if (e === 'gif') return 'image/gif';
if (e === 'webp') return 'image/webp';
return 'application/octet-stream';
}
function manifestId(path) {
return `f_${path.replace(/[^A-Za-z0-9]+/g, '_')}`;
}
function pageXhtml(imgHref, label) {
return `<?xml version="1.0" encoding="UTF-8"?><html xmlns="${XHTML_NS}"><head><title>${xmlText(label)}</title>`
+ '<style>html,body{margin:0;padding:0;background:#000}img{display:block;width:100%;height:auto}</style></head>'
+ `<body><img src="${xmlAttr(imgHref)}" alt="${xmlAttr(label)}"/></body></html>`;
}
// 章节内文件都在 chapters/<id>/ 与 images/<id>/ 下,二者深度相同,
// 页面到图片的相对路径固定是「上两级再进图片目录」。
function buildChapterFiles(chapterId, images) {
const files = [];
images.forEach((img, i) => {
const n = String(i + 1).padStart(4, '0');
const imgPath = `images/${chapterId}/${n}.${img.ext}`;
const pagePath = `chapters/${chapterId}/p${n}.xhtml`;
const mediaType = extMime(img.ext);
files.push({ path: imgPath, data: img.data, mediaType, kind: 'image' });
files.push({
path: pagePath,
data: Buffer.from(pageXhtml(`../../${imgPath}`, `${i + 1}`), 'utf8'),
mediaType: 'application/xhtml+xml',
kind: 'page'
});
});
return files;
}
// 卷号/话号都可能缺省(单行本、番外常常没有卷号);缺省的排到最后,
// 匹配"先追加已编号章节、新章节持续追更"这一最常见的使用顺序。
function chapterSortKey(c) {
const vol = c.volume === '' || c.volume == null ? NaN : parseFloat(c.volume);
const num = c.chapter === '' || c.chapter == null ? NaN : parseFloat(c.chapter);
return [Number.isFinite(vol) ? vol : Infinity, Number.isFinite(num) ? num : Infinity];
}
function compareChapters(a, b) {
const ka = chapterSortKey(a);
const kb = chapterSortKey(b);
if (ka[0] !== kb[0]) return ka[0] - kb[0];
if (ka[1] !== kb[1]) return ka[1] - kb[1];
return String(a.chapterId).localeCompare(String(b.chapterId));
}
function buildNav(chapters) {
const items = chapters.map((c) => {
const firstPage = c.files.find((f) => f.kind === 'page');
return `<li><a href="${xmlAttr(firstPage.path)}">${xmlText(c.label)}</a></li>`;
}).join('');
return `<?xml version="1.0" encoding="UTF-8"?><html xmlns="${XHTML_NS}" xmlns:epub="http://www.idpf.org/2007/ops">`
+ `<head><title>目录</title></head><body><nav epub:type="toc"><ol>${items}</ol></nav></body></html>`;
}
function buildOpf(title, sourceId, mangaId, originalLanguage, manifestItems, spineIds) {
const modified = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
return '<?xml version="1.0" encoding="UTF-8"?>'
+ '<package version="3.0" unique-identifier="book-id" xmlns="http://www.idpf.org/2007/opf">'
+ '<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">'
+ `<dc:identifier id="book-id">peoplelib-${xmlAttr(sourceId)}-${xmlAttr(mangaId)}</dc:identifier>`
+ `<dc:title>${xmlText(title)}</dc:title>`
+ `<dc:language>${xmlAttr(originalLanguage || 'ja')}</dc:language>`
+ `<meta property="dcterms:modified">${modified}</meta>`
+ '</metadata>'
+ `<manifest>${manifestItems.join('')}</manifest>`
+ `<spine>${spineIds.map((id) => `<itemref idref="${id}"/>`).join('')}</spine>`
+ '</package>';
}
function assembleEpub({ title, sourceId, mangaId, originalLanguage, chapters }) {
const sorted = chapters.slice().sort(compareChapters);
const manifestItems = [];
const spineIds = [];
const entries = [
{ name: 'mimetype', data: Buffer.from('application/epub+zip') },
{ name: 'META-INF/container.xml', data: Buffer.from(CONTAINER_XML, 'utf8') }
];
for (const chapter of sorted) {
for (const file of chapter.files) {
entries.push({ name: file.path, data: file.data });
const id = manifestId(file.path);
manifestItems.push(`<item id="${id}" href="${xmlAttr(file.path)}" media-type="${file.mediaType}"/>`);
if (file.kind === 'page') spineIds.push(id);
}
}
manifestItems.push('<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>');
const chaptersMeta = sorted.map((c) => ({
chapterId: c.chapterId,
label: c.label,
volume: c.volume || '',
chapter: c.chapter || '',
translatedLanguage: c.translatedLanguage || '',
group: c.group || '',
pageCount: c.files.filter((f) => f.kind === 'page').length,
files: c.files.map((f) => ({ path: f.path, mediaType: f.mediaType, kind: f.kind }))
}));
entries.push({ name: 'content.opf', data: Buffer.from(buildOpf(title, sourceId, mangaId, originalLanguage, manifestItems, spineIds), 'utf8') });
entries.push({ name: 'nav.xhtml', data: Buffer.from(buildNav(sorted), 'utf8') });
entries.push({
name: 'peoplelib/chapters.json',
data: Buffer.from(JSON.stringify({ sourceId, mangaId, chapters: chaptersMeta }), 'utf8')
});
return { bytes: writeZip(entries), chapterCount: sorted.length, pageCount: spineIds.length };
}
function readExistingChapters(epubBytes) {
const zip = readZip(epubBytes);
const metaBuf = zip.get('peoplelib/chapters.json');
if (!metaBuf) throw new Error('该文件不是可续传的漫画 EPUB,请改为新建条目');
let meta;
try { meta = JSON.parse(metaBuf.toString('utf8')); } catch (e) { throw new Error('漫画 EPUB 的章节索引已损坏'); }
const chapters = (meta.chapters || []).map((c) => ({
...c,
files: (c.files || []).map((f) => {
const data = zip.get(f.path);
if (!data) throw new Error(`漫画 EPUB 内容缺失: ${f.path}`);
return { ...f, data };
})
}));
return { sourceId: meta.sourceId || 'mangadex', mangaId: meta.mangaId, chapters };
}
// images: [{ ext, data: Buffer }],按页码顺序传入
function createMangaEpub({ title, sourceId = 'mangadex', mangaId, originalLanguage, chapterId, label, volume, chapter, translatedLanguage, group, images }) {
const files = buildChapterFiles(chapterId, images);
return assembleEpub({
title,
sourceId,
mangaId,
originalLanguage,
chapters: [{ chapterId, label, volume, chapter, translatedLanguage, group, files }]
});
}
function appendMangaChapter(existingInput, { title, sourceId = 'mangadex', mangaId, originalLanguage, chapterId, label, volume, chapter, translatedLanguage, group, images }) {
const existing = Buffer.isBuffer(existingInput)
? readExistingChapters(existingInput)
: existingInput;
if (existing.sourceId !== sourceId || existing.mangaId !== mangaId) {
throw new Error('目标文件不是同一部漫画,无法合并章节');
}
if (existing.chapters.some((c) => c.chapterId === chapterId)) {
throw new Error('该章节已存在于此书中');
}
const files = buildChapterFiles(chapterId, images);
const merged = existing.chapters.concat([{ chapterId, label, volume, chapter, translatedLanguage, group, files }]);
return assembleEpub({ title, sourceId, mangaId, originalLanguage, chapters: merged });
}
function hasChapter(epubBytes, chapterId) {
try {
return readExistingChapters(epubBytes).chapters.some((c) => c.chapterId === chapterId);
} catch (e) {
return false;
}
}
function isMangaEpub(epubBytes, mangaId, sourceId = 'mangadex') {
try {
const parsed = readExistingChapters(epubBytes);
return parsed.sourceId === sourceId && parsed.mangaId === mangaId;
} catch (e) {
return false;
}
}
function listChapters(epubBytes) {
try {
return readExistingChapters(epubBytes).chapters.map((c) => ({
chapterId: c.chapterId, label: c.label, volume: c.volume, chapter: c.chapter
}));
} catch (e) {
return [];
}
}
module.exports = {
createMangaEpub,
appendMangaChapter,
parseMangaEpub: readExistingChapters,
hasChapter,
isMangaEpub,
listChapters,
compareChapters
};
+1 -32
View File
@@ -14,7 +14,7 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const atomic = require('../atomic-file');
const { UA: DL_UA, fetchWithProxy } = require('../sources/http');
const { UA: DL_UA, fetchWithProxy, responseBytes } = require('../sources/http');
const SCHEMA_VERSION = 4;
const MAX_TAGS = 50;
@@ -330,37 +330,6 @@ function imageExt(bytes) {
return '';
}
async function responseBytes(res, maxBytes) {
const declared = Number(res.headers && res.headers.get && res.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
try { if (res.body && res.body.cancel) await res.body.cancel(); } catch (e) { /* ignore */ }
return null;
}
if (!res.body || typeof res.body.getReader !== 'function') {
const bytes = Buffer.from(await res.arrayBuffer());
return bytes.length <= maxBytes ? bytes : null;
}
const reader = res.body.getReader();
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
size += chunk.length;
if (size > maxBytes) {
await reader.cancel();
return null;
}
chunks.push(chunk);
}
} finally {
try { reader.releaseLock(); } catch (e) { /* ignore */ }
}
return Buffer.concat(chunks, size);
}
async function cacheCover(id, url, baseDir) {
let temp = '';
const controller = new AbortController();
+227
View File
@@ -0,0 +1,227 @@
const crypto = require('crypto');
const mangaDownload = require('../library/manga-download');
const MAX_SESSIONS = 8;
const MAX_IMAGE_BYTES = 30 * 1024 * 1024;
const sessions = new Map();
function cleanText(value, max) {
return String(value || '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, max);
}
function sessionId() {
return crypto.randomBytes(18).toString('base64url');
}
function create(ownerId, source, input) {
if (!source || !source.id || typeof source.chapters !== 'function'
|| typeof source.chapterImageUrls !== 'function') {
throw new Error('该数据源不支持在线漫画阅读');
}
const mangaId = cleanText(input && input.mangaId, 300);
if (!mangaId) throw new Error('缺少漫画 ID');
const id = sessionId();
const language = input && ['zh', 'zh-hk', 'all'].includes(input.language)
? input.language
: 'zh';
const quality = input && input.quality === 'data' ? 'data' : 'dataSaver';
const session = {
id,
ownerId,
source,
sourceId: source.id,
mangaId,
title: cleanText(input && input.title, 300) || '未命名漫画',
cover: cleanText(input && input.cover, 2000),
authors: (Array.isArray(input && input.authors) ? input.authors : [])
.map((author) => cleanText(author, 120))
.filter(Boolean)
.slice(0, 20),
language,
quality,
chapterPages: new Map(),
allowedChapterIds: new Set(),
manifests: new Map(),
manifestGeneration: 0,
createdAt: Date.now()
};
sessions.set(id, session);
while (sessions.size > MAX_SESSIONS) sessions.delete(sessions.keys().next().value);
return metaOf(session);
}
function owned(ownerId, id) {
const session = sessions.get(String(id || ''));
if (!session || session.ownerId !== ownerId) throw new Error('在线漫画会话无效或已关闭');
return session;
}
function metaOf(session) {
return {
sessionId: session.id,
sourceId: session.sourceId,
mangaId: session.mangaId,
title: session.title,
cover: session.cover,
authors: session.authors.slice(),
language: session.language,
quality: session.quality
};
}
function meta(ownerId, id) {
return metaOf(owned(ownerId, id));
}
function abortManifest(manifest) {
for (const controller of manifest.controllers || []) controller.abort();
manifest.controllers.clear();
}
async function chapters(ownerId, id, page) {
const session = owned(ownerId, id);
const currentPage = Math.max(1, Math.floor(Number(page) || 1));
const cached = session.chapterPages.get(currentPage);
if (cached) return cached;
const result = await session.source.chapters(
session.mangaId,
currentPage,
{ language: session.language }
);
const items = (Array.isArray(result && result.items) ? result.items : [])
.filter((chapter) => chapter && chapter.chapterId && !chapter.external && !chapter.unavailable)
.map((chapter) => ({
chapterId: cleanText(chapter.chapterId, 500),
label: cleanText(chapter.label, 300) || '未命名章节',
volume: cleanText(chapter.volume, 80),
chapter: cleanText(chapter.chapter, 80),
pages: Math.max(0, Math.floor(Number(chapter.pages) || 0)),
group: cleanText(chapter.group, 200)
}));
for (const chapter of items) session.allowedChapterIds.add(chapter.chapterId);
const value = {
items,
page: currentPage,
maxPage: Math.max(currentPage, Math.floor(Number(result && result.maxPage) || 1))
};
session.chapterPages.set(currentPage, value);
return value;
}
async function chapterManifest(ownerId, id, chapterId) {
const session = owned(ownerId, id);
const normalizedId = cleanText(chapterId, 500);
if (!session.allowedChapterIds.has(normalizedId)) throw new Error('该章节不属于当前在线漫画会话');
const generation = ++session.manifestGeneration;
for (const manifest of session.manifests.values()) abortManifest(manifest);
const info = await session.source.chapterImageUrls(normalizedId, session.quality);
if (generation !== session.manifestGeneration) throw new Error('章节加载已取消');
const urls = Array.isArray(info && info.urls) ? info.urls.filter(Boolean) : [];
if (!urls.length) throw new Error('该章节没有可阅读的图片');
const manifestId = sessionId();
session.manifests.clear();
session.manifests.set(manifestId, {
chapterId: normalizedId,
urls,
headers: info.headers || {},
mustReport: !!info.mustReport,
version: 0,
refreshPromise: null,
controllers: new Set()
});
return { manifestId, pages: urls.length };
}
function mimeType(bytes) {
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'image/jpeg';
}
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
return 'image/png';
}
if (bytes.length >= 6 && /^GIF8[79]a$/.test(bytes.subarray(0, 6).toString('ascii'))) {
return 'image/gif';
}
if (bytes.length >= 12
&& bytes.subarray(0, 4).toString('ascii') === 'RIFF'
&& bytes.subarray(8, 12).toString('ascii') === 'WEBP') {
return 'image/webp';
}
throw new Error('在线漫画返回了不支持的图片格式');
}
async function image(ownerId, id, manifestId, index) {
const session = owned(ownerId, id);
const manifest = session.manifests.get(String(manifestId || ''));
const pageIndex = Math.floor(Number(index));
if (!manifest || !Number.isSafeInteger(pageIndex)
|| pageIndex < 0 || pageIndex >= manifest.urls.length) {
throw new Error('在线漫画图片请求无效');
}
const controller = new AbortController();
manifest.controllers.add(controller);
const version = manifest.version;
let bytes;
try {
bytes = await mangaDownload.fetchOnlineImage(
manifest.urls[pageIndex],
manifest.headers,
manifest.mustReport,
MAX_IMAGE_BYTES,
controller.signal
);
} catch (error) {
if (controller.signal.aborted) throw new Error('图片加载已取消');
if (manifest.version === version) {
if (!manifest.refreshPromise) {
manifest.refreshPromise = session.source
.chapterImageUrls(manifest.chapterId, session.quality)
.then((fresh) => {
const urls = Array.isArray(fresh && fresh.urls) ? fresh.urls.filter(Boolean) : [];
if (!urls[pageIndex]) throw error;
manifest.urls = urls;
manifest.headers = fresh.headers || {};
manifest.mustReport = !!fresh.mustReport;
manifest.version++;
})
.finally(() => { manifest.refreshPromise = null; });
}
await manifest.refreshPromise;
}
bytes = await mangaDownload.fetchOnlineImage(
manifest.urls[pageIndex],
manifest.headers,
manifest.mustReport,
MAX_IMAGE_BYTES,
controller.signal
);
} finally {
manifest.controllers.delete(controller);
}
if (bytes.length > MAX_IMAGE_BYTES) throw new Error('单张漫画图片超过 30 MB,已停止加载');
return { bytes, mimeType: mimeType(bytes) };
}
function close(ownerId, id) {
const session = owned(ownerId, id);
for (const manifest of session.manifests.values()) abortManifest(manifest);
sessions.delete(session.id);
return true;
}
function closeOwner(ownerId) {
for (const [id, session] of sessions) {
if (session.ownerId !== ownerId) continue;
for (const manifest of session.manifests.values()) abortManifest(manifest);
sessions.delete(id);
}
}
function reset() {
for (const session of sessions.values()) {
for (const manifest of session.manifests.values()) abortManifest(manifest);
}
sessions.clear();
}
module.exports = { create, meta, chapters, chapterManifest, image, close, closeOwner, reset };
+73
View File
@@ -0,0 +1,73 @@
const path = require('path');
const { pathToFileURL } = require('url');
const { BrowserWindow } = require('electron');
let mangaWindow = null;
function alive(win) {
return !!win && !win.isDestroyed();
}
function get() {
if (alive(mangaWindow)) return mangaWindow;
mangaWindow = null;
return null;
}
function create(rootDir, uiTheme) {
const existing = get();
if (existing) existing.destroy();
const win = new BrowserWindow({
width: 1180,
height: 900,
minWidth: 680,
minHeight: 520,
frame: false,
backgroundColor: '#111318',
icon: path.join(
rootDir,
'icons',
'dist',
uiTheme === 'light' ? 'book-ai-light.ico' : 'book-ai-dark.ico'
),
title: '在线漫画',
webPreferences: {
preload: path.join(rootDir, 'manga-online-preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false
}
});
mangaWindow = win;
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
win.webContents.on('will-navigate', (event, url) => {
const expected = pathToFileURL(path.join(rootDir, 'src', 'ui', 'manga-online.html')).href;
if (!String(url).startsWith(expected)) event.preventDefault();
});
win.on('closed', () => {
if (mangaWindow === win) mangaWindow = null;
});
return win;
}
function load(rootDir, sessionId) {
const win = get();
if (!win) throw new Error('在线漫画窗口不可用');
win.loadFile(path.join(rootDir, 'src', 'ui', 'manga-online.html'), {
query: { sessionId: String(sessionId) }
});
return win;
}
function fromWebContents(webContents) {
const win = get();
return win && win.webContents.id === webContents.id;
}
function all() {
const win = get();
return win ? [win] : [];
}
module.exports = { create, load, get, fromWebContents, all };
+228
View File
@@ -0,0 +1,228 @@
const { fetchJson, clampPage } = require('./http');
const { tryMirrors } = require('./mirror');
const mangaDownload = require('../library/manga-download');
const MIRRORS = [
'https://api.2024manga.com',
'https://api.mangacopy.com',
'https://api.copy-manga.com'
];
const PAGE_SIZE = 21;
const PLATFORM = '3';
const CACHE_TTL = 5 * 60 * 1000;
const detailCache = new Map();
const chapterCache = new Map();
const API_HEADERS = {
source: 'com.manga2020.app',
version: '2024.4.28',
Referer: 'https://www.mangacopy.com/'
};
function query(params) {
const value = new URLSearchParams();
for (const [key, item] of Object.entries(params)) {
if (item != null) value.set(key, item);
}
return value.toString();
}
async function api(path) {
return tryMirrors('copymanga', MIRRORS, (base) => (
fetchJson(`${base}${path}`, { headers: API_HEADERS, retries: 0, timeout: 20000 })
));
}
function detailUrl(slug) {
return `https://www.mangacopy.com/comic/${encodeURIComponent(slug)}`;
}
function names(value) {
return (Array.isArray(value) ? value : []).map((item) => item && item.name).filter(Boolean);
}
function toItem(item) {
return {
postId: item.path_word,
title: item.name || '(无标题)',
cover: item.cover || '',
date: item.datetime_updated || '',
url: detailUrl(item.path_word),
subtitle: names(item.author).join(', ')
};
}
function chapterNumber(chapter) {
const match = String(chapter.name || '').match(/(\d+(?:\.\d+)?)/);
if (match) return match[1];
return chapter.ordered ? String(Number(chapter.ordered) / 10) : '';
}
async function cached(cache, key, maxEntries, load) {
const existing = cache.get(key);
if (existing && existing.expiresAt > Date.now()) return existing.promise;
const promise = Promise.resolve().then(load);
cache.set(key, { promise, expiresAt: Date.now() + CACHE_TTL });
while (cache.size > maxEntries) cache.delete(cache.keys().next().value);
try {
return await promise;
} catch (error) {
cache.delete(key);
throw error;
}
}
function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
for (;;) {
const index = cursor++;
if (index >= items.length) return;
results[index] = await fn(items[index], index);
}
}
return Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
.then(() => results);
}
function book(slug) {
return cached(detailCache, slug, 50, async () => {
const json = await api(`/api/v3/comic2/${encodeURIComponent(slug)}?platform=${PLATFORM}`);
const results = json && json.results || {};
if (!results.comic || !results.comic.path_word) throw new Error('拷贝漫画未返回漫画详情');
return results;
});
}
function allChapters(slug) {
return cached(chapterCache, slug, 30, async () => {
const info = await book(slug);
const version = info.comic.reclass == null ? '2' : '';
const groups = Object.values(info.groups || {});
const requests = groups.flatMap((group) => {
const count = Math.max(1, Number(group.count) || 1);
const pages = Math.ceil(count / 100);
return Array.from({ length: pages }, (_, index) => ({ group, index }));
});
const chunks = await mapLimit(requests, 4, async ({ group, index }) => {
const qs = query({ limit: 100, offset: index * 100, platform: PLATFORM });
const json = await api(
`/api/v3/comic/${encodeURIComponent(slug)}/group/${encodeURIComponent(group.path_word)}/chapters?${qs}`
);
const list = json && json.results && json.results.list || [];
return list.map((chapter) => ({
chapterId: `${slug}|${version}|${chapter.uuid}`,
volume: '',
chapter: chapterNumber(chapter),
title: chapter.name || '',
label: chapter.name || '未命名章节',
translatedLanguage: 'zh',
pages: chapter.size || 0,
publishAt: chapter.datetime_created || '',
group: group.name || '',
external: false,
unavailable: false
}));
});
return chunks.flat();
});
}
module.exports = {
id: 'copymanga',
name: '拷贝漫画(实验)',
category: 'manga',
experimental: true,
supportsSearch: true,
chapterBased: true,
async list(page) {
page = clampPage(page);
const qs = query({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
ordering: '-datetime_updated',
platform: PLATFORM
});
const json = await api(`/api/v3/comics?${qs}`);
const data = json && json.results || {};
return {
items: (data.list || []).map(toItem).filter((item) => item.postId),
maxPage: Math.max(1, Math.ceil((Number(data.total) || 0) / PAGE_SIZE)),
page
};
},
async search(keyword, page) {
page = clampPage(page);
const value = String(keyword || '').trim();
if (!value) return { items: [], maxPage: 1, page };
const qs = query({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
q: value,
q_type: '',
platform: PLATFORM
});
const json = await api(`/api/v3/search/comic?${qs}`);
const data = json && json.results || {};
return {
items: (data.list || []).map(toItem).filter((item) => item.postId),
maxPage: Math.max(1, Math.ceil((Number(data.total) || 0) / PAGE_SIZE)),
page
};
},
async detail(postId) {
const data = await book(postId);
const comic = data.comic;
const tags = names(comic.theme).map((name) => `标签:${name}`);
if (comic.region && comic.region.display) tags.unshift(`地区:${comic.region.display}`);
if (comic.status && comic.status.display) tags.unshift(`状态:${comic.status.display}`);
const url = detailUrl(postId);
return {
postId,
title: comic.name || '(无标题)',
cover: comic.cover || '',
authors: names(comic.author),
date: comic.datetime_updated || '',
tags,
brief: comic.brief || '',
url,
links: [{ name: '拷贝漫画页面', url }],
originalLanguage: 'zh'
};
},
async download() {
throw new Error('拷贝漫画请先在章节列表中选择要下载的具体章节');
},
async chapters(postId, page) {
page = clampPage(page);
const items = await allChapters(postId);
const maxPage = Math.max(1, Math.ceil(items.length / 100));
return { items: items.slice((page - 1) * 100, page * 100), maxPage, page };
},
async chapterImageUrls(chapterId) {
const [slug, version, uuid] = String(chapterId || '').split('|');
if (!slug || !uuid || !['', '2'].includes(version)) throw new Error('拷贝漫画章节 ID 无效');
const json = await api(
`/api/v3/comic/${encodeURIComponent(slug)}/chapter${version}/${encodeURIComponent(uuid)}?platform=${PLATFORM}`
);
const contents = json && json.results && json.results.chapter && json.results.chapter.contents;
const urls = (contents || []).map((item) => item && item.url).filter(Boolean);
if (!urls.length) throw new Error('拷贝漫画未返回章节图片');
return {
urls,
mustReport: false,
quality: 'data',
headers: { Referer: 'https://www.mangacopy.com/' }
};
},
async downloadChapter(library, payload, onProgress) {
return mangaDownload.downloadChapter(this, library, payload, onProgress);
}
};
+36 -1
View File
@@ -38,6 +38,37 @@ function fetchWithElectron(url, options = {}) {
return fetchWithProxy(url, options);
}
async function responseBytes(res, maxBytes) {
const declared = Number(res.headers && res.headers.get && res.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
try { if (res.body && res.body.cancel) await res.body.cancel(); } catch (e) { /* ignore */ }
return null;
}
if (!res.body || typeof res.body.getReader !== 'function') {
const bytes = Buffer.from(await res.arrayBuffer());
return bytes.length <= maxBytes ? bytes : null;
}
const reader = res.body.getReader();
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
size += chunk.length;
if (size > maxBytes) {
await reader.cancel();
return null;
}
chunks.push(chunk);
}
} finally {
try { reader.releaseLock(); } catch (e) { /* ignore */ }
}
return Buffer.concat(chunks, size);
}
// 简易 cookie jar: Map<domain, Map<name, value>>
const cookieJar = new Map();
@@ -218,4 +249,8 @@ async function withRetry(fn, { tries = 2, delay = 1000 } = {}) {
}
}
module.exports = { UA, fetchRaw, fetchText, fetchJson, decodeEntities, stripTags, clampPage, tooShort, isRetryable, withRetry, getCookies, setCookies, clearCookies, setProxy, getProxy, fetchWithProxy };
module.exports = {
UA, fetchRaw, fetchText, fetchJson, responseBytes, decodeEntities, stripTags,
clampPage, tooShort, isRetryable, withRetry, getCookies, setCookies, clearCookies,
setProxy, getProxy, fetchWithProxy
};
+28 -2
View File
@@ -14,6 +14,27 @@ const openstax = require('./openstax');
const opentextbook = require('./opentextbook');
const wikisourceZh = require('./wikisource-zh');
const wikisourceEn = require('./wikisource-en');
const mangadex = require('./mangadex');
const copymanga = require('./copymanga');
const CATEGORY_BY_ID = {
arxiv: 'academic',
doaj: 'academic',
pmc: 'academic',
biorxiv: 'academic',
semanticscholar: 'academic',
scihub: 'academic',
gutenberg: 'books',
openlibrary: 'books',
standardebooks: 'books',
libgen: 'books',
zlib: 'books',
openstax: 'open',
opentextbook: 'open',
'wikisource-zh': 'open',
'wikisource-en': 'open',
motw: 'archive'
};
const sources = [
arxiv,
@@ -31,7 +52,9 @@ const sources = [
libgen,
zlib,
scihub,
motw
motw,
mangadex,
copymanga
];
const byId = new Map(sources.map((s) => [s.id, s]));
@@ -39,8 +62,11 @@ function listSources() {
return sources.map((s) => ({
id: s.id,
name: s.name,
category: s.category || CATEGORY_BY_ID[s.id] || 'other',
experimental: s.experimental === true,
supportsSearch: s.supportsSearch !== false,
downloadOnDemand: s.downloadOnDemand === true
downloadOnDemand: s.downloadOnDemand === true,
chapterBased: s.chapterBased === true
}));
}
+248
View File
@@ -0,0 +1,248 @@
// MangaDex 数据源:公开漫画聚合站,官方文档化 REST API(无需登录即可只读浏览)。
// 与其它源的关键差异:条目本身不是可下载的单文件,而是一部漫画下的多个章节,
// 每个章节又是几十张图片。list/search/detail 沿用通用接口语义(对象是"一部漫画"),
// download() 按接口约定必须存在但没有意义,交给 chapters()/atHome() 支撑的专用
// 下载流程(组装漫画 EPUB,见 main.js 与 src/library/manga-epub.js)。
//
// 图片必须由服务端代理转发:MangaDex 明确禁止渲染层热链其图片域名
// https://api.mangadex.org/docs/2-limitations/),这与本仓库“渲染层不直接
// 访问外部资源”的既有边界天然吻合。
//
// MangaDex@Home 的图片分发要求调用方对每张图片上报成功/失败
// POST https://api.mangadex.network/report),否则健康检测无法剔除故障节点;
// 这一步在实际下载编排里完成(main.js),本模块只负责取地址。
const { fetchJson, clampPage } = require('./http');
const mangaDownload = require('../library/manga-download');
const BASE = 'https://api.mangadex.org';
const PAGE_SIZE = 20;
const CHAPTER_PAGE_SIZE = 100;
const MAX_OFFSET_TOTAL = 10000; // 接口硬限制:offset + size 不能超过 10000
// 标题/标签是 LocalizedString{ en: '...', ja: '...' } 形式),按偏好语言取值。
const PREFERRED_LANGS = ['zh', 'zh-hk', 'en', 'ja-ro', 'ja'];
function pickLocalized(obj) {
if (!obj || typeof obj !== 'object') return '';
for (const lang of PREFERRED_LANGS) {
if (obj[lang]) return obj[lang];
}
const first = Object.values(obj).find(Boolean);
return first || '';
}
function pickTitle(attrs) {
const direct = pickLocalized(attrs && attrs.title);
if (direct) return direct;
for (const alt of (attrs && attrs.altTitles) || []) {
const t = pickLocalized(alt);
if (t) return t;
}
return '(无标题)';
}
function buildQuery(params) {
const q = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value == null) continue;
if (Array.isArray(value)) value.forEach((item) => q.append(key, item));
else q.append(key, value);
}
return q.toString();
}
function findRelationship(relationships, type) {
return (relationships || []).find((r) => r.type === type);
}
function coverUrl(mangaId, relationships) {
const cover = findRelationship(relationships, 'cover_art');
const fileName = cover && cover.attributes && cover.attributes.fileName;
if (!fileName) return '';
return `https://uploads.mangadex.org/covers/${mangaId}/${fileName}.512.jpg`;
}
function authorNames(relationships) {
return [...new Set((relationships || [])
.filter((r) => r.type === 'author' || r.type === 'artist')
.map((r) => r.attributes && r.attributes.name)
.filter(Boolean))];
}
function mangaUrl(id) { return `https://mangadex.org/title/${id}`; }
function toItem(m) {
const attrs = m.attributes || {};
return {
postId: m.id,
title: pickTitle(attrs),
cover: coverUrl(m.id, m.relationships),
date: attrs.year ? String(attrs.year) : '',
url: mangaUrl(m.id),
subtitle: authorNames(m.relationships).slice(0, 3).join(', ')
};
}
const STATUS_LABEL = { ongoing: '连载中', completed: '已完结', hiatus: '暂停', cancelled: '已取消' };
const DEMOGRAPHIC_LABEL = { shounen: '少年', shoujo: '少女', josei: '女性向', seinen: '青年' };
function chapterLabel(attrs) {
const parts = [];
if (attrs.volume) parts.push(`${attrs.volume}`);
parts.push(attrs.chapter ? `${attrs.chapter}` : '单话');
if (attrs.title) parts.push(attrs.title);
return parts.join(' ') || '未命名章节';
}
function toChapterItem(c) {
const attrs = c.attributes || {};
const group = findRelationship(c.relationships, 'scanlation_group');
return {
chapterId: c.id,
volume: attrs.volume || '',
chapter: attrs.chapter || '',
title: attrs.title || '',
label: chapterLabel(attrs),
translatedLanguage: attrs.translatedLanguage || '',
pages: attrs.pages || 0,
publishAt: attrs.publishAt || '',
group: (group && group.attributes && group.attributes.name) || '',
external: !!attrs.externalUrl,
unavailable: !!attrs.isUnavailable
};
}
function clampedMaxPage(total, pageSize) {
return Math.max(1, Math.ceil(Math.min(total, MAX_OFFSET_TOTAL) / pageSize));
}
async function fetchMangaList(query, page) {
page = clampPage(page);
const offset = (page - 1) * PAGE_SIZE;
const qs = buildQuery({
...query,
limit: PAGE_SIZE,
offset,
'includes[]': ['cover_art', 'author', 'artist']
});
const j = await fetchJson(`${BASE}/manga?${qs}`);
const total = j.total || 0;
return { items: (j.data || []).map(toItem), maxPage: clampedMaxPage(total, PAGE_SIZE), page };
}
module.exports = {
id: 'mangadex',
name: 'MangaDex 漫画',
category: 'manga',
supportsSearch: true,
// 与 zlib 的 downloadOnDemand 语义不同:这里不是"点击才解析",而是条目本身
// 就没有单一下载文件,必须先选具体章节。sources/index.js 与 browse.js 用这个
// 标记切换到章节列表面板,而不是通用的下载框。
chapterBased: true,
// 默认只看安全/暗示性内容,不含情色/色情分级:面向通用书库场景的保守默认值,
// 与年龄分级过滤无关的高级选项超出本次范围。
async list(page) {
return fetchMangaList({
'order[followedCount]': 'desc',
'contentRating[]': ['safe', 'suggestive']
}, page);
},
async search(keyword, page) {
const title = String(keyword || '').trim();
if (!title) return { items: [], maxPage: 1, page: clampPage(page) };
return fetchMangaList({
title,
'contentRating[]': ['safe', 'suggestive']
}, page);
},
async detail(postId) {
const qs = buildQuery({ 'includes[]': ['cover_art', 'author', 'artist'] });
const j = await fetchJson(`${BASE}/manga/${encodeURIComponent(postId)}?${qs}`);
const m = j.data;
if (!m) throw new Error('未找到该漫画');
const attrs = m.attributes || {};
const tags = [];
if (attrs.status) tags.push(`状态:${STATUS_LABEL[attrs.status] || attrs.status}`);
if (attrs.publicationDemographic) {
tags.push(`分级:${DEMOGRAPHIC_LABEL[attrs.publicationDemographic] || attrs.publicationDemographic}`);
}
for (const tag of attrs.tags || []) {
const name = pickLocalized(tag.attributes && tag.attributes.name);
if (name) tags.push(`标签:${name}`);
}
return {
postId: m.id,
title: pickTitle(attrs),
cover: coverUrl(m.id, m.relationships),
authors: authorNames(m.relationships),
date: attrs.year ? String(attrs.year) : '',
tags,
brief: pickLocalized(attrs.description),
url: mangaUrl(m.id),
links: [{ name: 'MangaDex 页', url: mangaUrl(m.id) }],
// 供下载编排复用,避免为拼 EPUB 元数据再多打一次详情请求
originalLanguage: attrs.originalLanguage || 'ja'
};
},
// 按接口约定必须存在,但漫画没有"整部下载"的单一文件;
// 真正的下载走 chapters() 选出具体章节后调用 atHome()。
async download() {
throw new Error('MangaDex 请先在章节列表中选择要下载的具体章节');
},
// 不按语言过滤:不同语言的翻译组各自独立,筛选逻辑交给调用方按 translatedLanguage 分组展示,
// 避免对用户能读什么语言做隐性假设。
async chapters(mangaId, page, options) {
page = clampPage(page);
const offset = (page - 1) * CHAPTER_PAGE_SIZE;
const language = options && ['zh', 'zh-hk', 'all'].includes(options.language)
? options.language
: 'zh';
const qs = buildQuery({
limit: CHAPTER_PAGE_SIZE,
offset,
'order[volume]': 'asc',
'order[chapter]': 'asc',
'includes[]': ['scanlation_group'],
'translatedLanguage[]': language === 'all' ? null : [language]
});
const j = await fetchJson(`${BASE}/manga/${encodeURIComponent(mangaId)}/feed?${qs}`);
const total = j.total || 0;
return {
items: (j.data || []).map(toChapterItem),
maxPage: clampedMaxPage(total, CHAPTER_PAGE_SIZE),
page
};
},
// baseUrl 只保证 15 分钟有效,调用方不能缓存它去拼后续的图片地址。
async atHome(chapterId, forcePort443 = false) {
const qs = forcePort443 ? '?forcePort443=true' : '';
const j = await fetchJson(`${BASE}/at-home/server/${encodeURIComponent(chapterId)}${qs}`);
if (!j || !j.baseUrl || !j.chapter || !j.chapter.hash) throw new Error('获取章节图片地址失败');
return j;
},
// quality: 'data'(原画质)| 'dataSaver'(压缩,默认)
async chapterImageUrls(chapterId, quality) {
const q = quality === 'data' ? 'data' : 'dataSaver';
const pathSegment = q === 'data' ? 'data' : 'data-saver';
const info = await this.atHome(chapterId);
const files = q === 'data' ? info.chapter.data : info.chapter.dataSaver;
if (!Array.isArray(files) || !files.length) throw new Error('该章节没有可下载的图片');
const urls = files.map((name) => `${info.baseUrl}/${pathSegment}/${info.chapter.hash}/${name}`);
// 官方主域名(mangadex.org)不需要健康上报;只有转发到 @Home 志愿节点时才需要,
// 用 baseUrl 是否落在主域名判断,不能靠是否有端口号等启发式。
const mustReport = !/(^|\.)mangadex\.org(:|\/|$)/i.test(new URL(info.baseUrl).hostname);
return { urls, mustReport, quality: q };
},
async downloadChapter(library, payload, onProgress) {
return mangaDownload.downloadChapter(this, library, payload, onProgress);
}
};
+20
View File
@@ -172,8 +172,28 @@ async function refreshAskSave() {
}
$('askSaveChk').onchange = () => window.api.settings.set('askSavePath', $('askSaveChk').checked);
async function refreshMangadexQuality() {
const r = await window.api.settings.get('mangadex.imageQuality', 'dataSaver');
$('mangadexQualitySelect').value = (r.ok && r.data === 'data') ? 'data' : 'dataSaver';
}
$('mangadexQualitySelect').onchange = () => (
window.api.settings.set('mangadex.imageQuality', $('mangadexQualitySelect').value)
);
async function refreshMangadexLanguage() {
const r = await window.api.settings.get('mangadex.chapterLanguage', 'zh');
$('mangadexLanguageSelect').value = r.ok && ['zh', 'zh-hk', 'all'].includes(r.data)
? r.data
: 'zh';
}
$('mangadexLanguageSelect').onchange = () => (
window.api.settings.set('mangadex.chapterLanguage', $('mangadexLanguageSelect').value)
);
refreshLibraryDir();
refreshAskSave();
refreshMangadexQuality();
refreshMangadexLanguage();
async function refreshProxy() {
const r = await window.api.proxy.get();
+23
View File
@@ -195,6 +195,29 @@
<div id="sourceList" class="source-list"></div>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">MangaDex 章节语言</div>
<div class="settings-item-desc">默认只显示简体中文章节,也可切换到繁体中文或全部语言</div>
</div>
<select id="mangadexLanguageSelect" class="source-select">
<option value="zh">简体中文</option>
<option value="zh-hk">繁体中文</option>
<option value="all">全部语言</option>
</select>
</div>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">漫画图片画质</div>
<div class="settings-item-desc">压缩画质体积小、下载快;原画质更清晰但占用更多空间与流量</div>
</div>
<select id="mangadexQualitySelect" class="source-select">
<option value="dataSaver">压缩画质</option>
<option value="data">原画质</option>
</select>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
+276
View File
@@ -0,0 +1,276 @@
:root {
--bg: #111318;
--panel: #171a20;
--panel-strong: #1c2027;
--line: #2b313b;
--text: #e3e7ee;
--muted: #929cab;
--accent: #6ea8fe;
--hover: rgba(255, 255, 255, 0.07);
--title-start: #171b26;
--title-end: #12141c;
}
:root[data-ui-theme="light"] {
--bg: #eef2f7;
--panel: #ffffff;
--panel-strong: #f7f9fc;
--line: #d5dde8;
--text: #1f2937;
--muted: #667386;
--accent: #2563eb;
--hover: rgba(15, 23, 42, 0.07);
--title-start: #ffffff;
--title-end: #f1f4f8;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
color: var(--text);
background: var(--bg);
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
}
body {
display: flex;
flex-direction: column;
}
button, input { font: inherit; }
.hidden { display: none !important; }
.titlebar {
height: 44px;
flex: 0 0 44px;
display: flex;
align-items: center;
padding-left: 14px;
border-bottom: 1px solid var(--line);
background: linear-gradient(135deg, var(--title-start), var(--title-end));
-webkit-app-region: drag;
}
.brand {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
color: var(--accent);
font-size: 15px;
font-weight: 700;
}
.brand-logo { width: 25px; height: 25px; border-radius: 6px; }
.brand-logo-light { display: none; }
:root[data-ui-theme="light"] .brand-logo-dark { display: none; }
:root[data-ui-theme="light"] .brand-logo-light { display: block; }
.brand-sub {
min-width: 0;
max-width: 56vw;
overflow: hidden;
color: var(--muted);
font-size: 12px;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
.titlebar-spacer { flex: 1; }
.titlebar-controls { height: 100%; display: flex; -webkit-app-region: no-drag; }
.win-btn {
width: 46px;
border: 0;
color: var(--text);
background: transparent;
cursor: pointer;
}
.win-btn:hover { background: var(--hover); }
.win-close:hover { color: #fff; background: #c42b1c; }
.toolbar {
min-height: 48px;
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
.tool-btn, .load-more, .chapter-end, .icon-btn {
border: 1px solid var(--line);
border-radius: 7px;
color: var(--text);
background: var(--panel-strong);
cursor: pointer;
}
.tool-btn { height: 32px; padding: 0 13px; }
.tool-btn:hover, .load-more:hover, .chapter-end:hover, .icon-btn:hover { border-color: var(--accent); }
.tool-btn:disabled { cursor: default; opacity: 0.42; }
.chapter-title {
min-width: 120px;
max-width: 30vw;
overflow: hidden;
color: var(--text);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.width-control {
margin-left: auto;
display: flex;
align-items: center;
gap: 7px;
color: var(--muted);
font-size: 12px;
}
.width-control input { width: 130px; accent-color: var(--accent); }
.quality-label { min-width: 54px; color: var(--muted); font-size: 12px; }
.reader-layout {
min-height: 0;
flex: 1;
display: flex;
}
.toc-pane {
width: 290px;
min-width: 240px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
background: var(--panel);
}
.toc-pane.collapsed { display: none; }
.toc-head {
flex: 0 0 auto;
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 14px 12px 10px;
border-bottom: 1px solid var(--line);
}
.toc-meta { margin-top: 4px; color: var(--muted); font-size: 12px; }
.icon-btn { width: 28px; height: 28px; }
.chapter-list {
min-height: 0;
flex: 1;
overflow: auto;
padding: 7px;
}
.chapter-item {
width: 100%;
min-height: 39px;
display: block;
padding: 8px 10px;
overflow: hidden;
border: 0;
border-radius: 7px;
color: var(--text);
background: transparent;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.chapter-item:hover { background: var(--hover); }
.chapter-item.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }
.chapter-item.loading::after { content: " · 加载中"; color: var(--muted); font-size: 11px; }
.load-more {
min-height: 36px;
margin: 8px;
flex: 0 0 auto;
}
.page-scroller {
min-width: 0;
flex: 1;
overflow: auto;
overscroll-behavior: contain;
background: var(--bg);
}
.reader-status {
max-width: 720px;
margin: 28px auto;
padding: 16px;
color: var(--muted);
text-align: center;
}
.reader-status.error { color: #e36b66; }
.page-stack {
width: min(var(--manga-page-width, 900px), 100%);
margin: 0 auto;
background: #0b0c0f;
}
.manga-page {
position: relative;
width: 100%;
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: #8f98a6;
background: #11141a;
border-bottom: 1px solid #252a33;
}
.manga-page::before {
content: attr(data-placeholder);
padding: 30px;
text-align: center;
}
.manga-page.loaded { min-height: 0; }
.manga-page.loaded::before { display: none; }
.manga-page.error { min-height: 160px; color: #e36b66; cursor: pointer; }
.manga-page img {
display: block;
width: 100%;
height: auto;
}
.chapter-end {
min-width: 220px;
min-height: 42px;
display: block;
margin: 24px auto 44px;
padding: 0 24px;
}
@media (max-width: 850px) {
.width-control { display: none; }
.quality-label { display: none; }
.toc-pane {
position: absolute;
z-index: 5;
top: 92px;
bottom: 0;
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.28);
}
}
+61
View File
@@ -0,0 +1,61 @@
<!doctype html>
<html lang="zh-CN" data-ui-theme="dark">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self'" />
<title>在线漫画</title>
<link rel="stylesheet" href="manga-online.css" />
</head>
<body>
<header class="titlebar">
<div class="brand">
<img class="brand-logo brand-logo-dark" src="../../icons/dist/dark/icon-32.png" alt="" />
<img class="brand-logo brand-logo-light" src="../../icons/dist/light/icon-32.png" alt="" />
<span>在线漫画</span>
<span id="bookTitle" class="brand-sub">正在打开…</span>
</div>
<div class="titlebar-spacer"></div>
<div class="titlebar-controls">
<button id="themeBtn" class="win-btn" title="切换主题" aria-label="切换主题"></button>
<button id="minBtn" class="win-btn" title="最小化"></button>
<button id="maxBtn" class="win-btn" title="最大化"></button>
<button id="closeBtn" class="win-btn win-close" title="关闭">×</button>
</div>
</header>
<div class="toolbar">
<button id="tocToggleBtn" class="tool-btn">目录</button>
<button id="prevChapterBtn" class="tool-btn">上一章</button>
<span id="chapterTitle" class="chapter-title">尚未加载章节</span>
<button id="nextChapterBtn" class="tool-btn">下一章</button>
<label class="width-control">
页面宽度
<input id="pageWidthRange" type="range" min="480" max="1400" step="40" value="900" />
<span id="pageWidthLabel">900px</span>
</label>
<span id="qualityLabel" class="quality-label"></span>
</div>
<div class="reader-layout">
<aside id="tocPane" class="toc-pane">
<div class="toc-head">
<div>
<strong id="tocTitle">章节目录</strong>
<div id="tocMeta" class="toc-meta"></div>
</div>
<button id="tocCloseBtn" class="icon-btn" title="收起目录">×</button>
</div>
<div id="chapterList" class="chapter-list"></div>
<button id="loadMoreBtn" class="load-more hidden">加载更多章节</button>
</aside>
<main id="pageScroller" class="page-scroller">
<div id="readerStatus" class="reader-status">正在读取漫画信息…</div>
<div id="pageStack" class="page-stack"></div>
<button id="chapterEndNextBtn" class="chapter-end hidden">继续阅读下一章</button>
</main>
</div>
<script src="manga-online.js"></script>
</body>
</html>
+313
View File
@@ -0,0 +1,313 @@
const api = window.mangaApi;
const $ = (id) => document.getElementById(id);
const params = new URLSearchParams(location.search);
const sessionId = params.get('sessionId') || '';
const state = {
meta: null,
chapters: [],
chapterIds: new Set(),
chapterPage: 0,
chapterMaxPage: 1,
chapterIndex: -1,
manifestId: '',
loadToken: 0,
objectUrls: new Set(),
observer: null,
retentionObserver: null,
queuedPages: [],
activePages: 0,
chapterNavigation: false,
theme: 'dark'
};
function errorText(result, fallback) {
return result && result.error ? String(result.error) : fallback;
}
function setStatus(text, error) {
const status = $('readerStatus');
status.textContent = text;
status.classList.toggle('error', !!error);
status.classList.remove('hidden');
}
function applyTheme(theme) {
state.theme = theme === 'light' ? 'light' : 'dark';
document.documentElement.dataset.uiTheme = state.theme;
}
function revokeImages() {
for (const url of state.objectUrls) URL.revokeObjectURL(url);
state.objectUrls.clear();
}
function renderChapterList() {
const list = $('chapterList');
list.textContent = '';
state.chapters.forEach((chapter, index) => {
const button = document.createElement('button');
button.className = 'chapter-item';
button.textContent = chapter.label;
button.title = chapter.group ? `${chapter.label} · ${chapter.group}` : chapter.label;
button.classList.toggle('active', index === state.chapterIndex);
button.addEventListener('click', () => openChapter(index));
list.appendChild(button);
});
$('loadMoreBtn').classList.toggle('hidden', state.chapterPage >= state.chapterMaxPage);
$('tocMeta').textContent = `已载入 ${state.chapters.length}`;
}
async function loadChapterPage(page) {
const button = $('loadMoreBtn');
button.disabled = true;
button.textContent = '加载中…';
const result = await api.chapters(sessionId, page);
button.disabled = false;
button.textContent = '加载更多章节';
if (!result || !result.ok || !result.data) {
setStatus(errorText(result, '章节目录加载失败'), true);
return false;
}
for (const chapter of result.data.items || []) {
if (state.chapterIds.has(chapter.chapterId)) continue;
state.chapterIds.add(chapter.chapterId);
state.chapters.push(chapter);
}
state.chapterPage = result.data.page || page;
state.chapterMaxPage = result.data.maxPage || state.chapterPage;
renderChapterList();
return true;
}
async function ensureNextChapter() {
if (state.chapterIndex + 1 < state.chapters.length) return true;
if (state.chapterPage >= state.chapterMaxPage) return false;
return loadChapterPage(state.chapterPage + 1);
}
function updateNavigation() {
const hasPrevious = state.chapterIndex > 0;
const mayHaveNext = state.chapterIndex + 1 < state.chapters.length
|| state.chapterPage < state.chapterMaxPage;
$('prevChapterBtn').disabled = state.chapterNavigation || !hasPrevious;
$('nextChapterBtn').disabled = state.chapterNavigation || !mayHaveNext;
$('chapterEndNextBtn').classList.toggle('hidden', !mayHaveNext);
renderChapterList();
}
function bytesOf(value) {
if (value instanceof Uint8Array) return value;
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (value && value.data) return Uint8Array.from(value.data);
throw new Error('图片数据格式无效');
}
function schedulePage(element, index, token) {
if (element.dataset.state) return;
element.dataset.state = 'queued';
state.queuedPages.push({ element, index, token, manifestId: state.manifestId });
pumpPages();
}
function pumpPages() {
while (state.activePages < 4 && state.queuedPages.length) {
const job = state.queuedPages.shift();
if (job.token !== state.loadToken || !job.element.isConnected) continue;
state.activePages++;
loadPage(job).finally(() => {
state.activePages--;
pumpPages();
});
}
}
async function loadPage(job) {
const { element, index, token } = job;
element.dataset.state = 'loading';
element.dataset.placeholder = `${index + 1} 页加载中…`;
const result = await api.image(sessionId, job.manifestId, index);
if (token !== state.loadToken || !element.isConnected) return;
if (!result || !result.ok || !result.data) {
element.dataset.state = 'error';
element.dataset.placeholder = `${errorText(result, '图片加载失败')},点击重试`;
element.classList.add('error');
return;
}
let bytes;
try {
bytes = bytesOf(result.data.bytes);
} catch (error) {
element.dataset.state = 'error';
element.dataset.placeholder = `${error.message},点击重试`;
element.classList.add('error');
return;
}
const url = URL.createObjectURL(new Blob([bytes], { type: result.data.mimeType }));
state.objectUrls.add(url);
const image = document.createElement('img');
image.alt = `${index + 1}`;
image.decoding = 'async';
image.addEventListener('load', () => {
if (token !== state.loadToken) return;
URL.revokeObjectURL(url);
state.objectUrls.delete(url);
element.style.minHeight = '';
element.classList.add('loaded');
element.dataset.state = 'loaded';
}, { once: true });
image.addEventListener('error', () => {
URL.revokeObjectURL(url);
state.objectUrls.delete(url);
element.textContent = '';
element.classList.add('error');
element.classList.remove('loaded');
element.dataset.state = 'error';
element.dataset.placeholder = '图片解码失败,点击重试';
}, { once: true });
image.src = url;
element.textContent = '';
element.appendChild(image);
}
function buildPages(count, token) {
const stack = $('pageStack');
stack.textContent = '';
state.queuedPages = [];
state.retentionObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
const page = entry.target;
if (entry.isIntersecting || page.dataset.state !== 'loaded') continue;
const height = Math.max(160, Math.ceil(page.getBoundingClientRect().height));
const image = page.querySelector('img');
if (image) image.remove();
page.style.minHeight = `${height}px`;
page.classList.remove('loaded');
page.dataset.state = '';
page.dataset.placeholder = `${Number(page.dataset.index) + 1}`;
state.observer.observe(page);
}
}, { root: $('pageScroller'), rootMargin: '2600px 0px' });
state.observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const index = Number(entry.target.dataset.index);
schedulePage(entry.target, index, token);
state.observer.unobserve(entry.target);
}
}, { root: $('pageScroller'), rootMargin: '1200px 0px' });
for (let index = 0; index < count; index++) {
const page = document.createElement('div');
page.className = 'manga-page';
page.dataset.index = String(index);
page.dataset.placeholder = `${index + 1}`;
page.addEventListener('click', () => {
if (page.dataset.state !== 'error') return;
page.classList.remove('error');
page.dataset.state = '';
schedulePage(page, index, token);
});
stack.appendChild(page);
state.observer.observe(page);
state.retentionObserver.observe(page);
}
}
async function openChapter(index) {
const chapter = state.chapters[index];
if (!chapter) return;
const token = ++state.loadToken;
if (state.observer) state.observer.disconnect();
if (state.retentionObserver) state.retentionObserver.disconnect();
state.queuedPages = [];
revokeImages();
state.manifestId = '';
state.chapterIndex = index;
$('pageStack').textContent = '';
$('chapterTitle').textContent = chapter.label;
$('chapterEndNextBtn').classList.add('hidden');
$('pageScroller').scrollTop = 0;
setStatus(`正在加载「${chapter.label}」…`);
updateNavigation();
const result = await api.chapterManifest(sessionId, chapter.chapterId);
if (token !== state.loadToken) return;
if (!result || !result.ok || !result.data) {
setStatus(errorText(result, '章节加载失败'), true);
return;
}
state.manifestId = result.data.manifestId;
$('readerStatus').classList.add('hidden');
buildPages(result.data.pages, token);
updateNavigation();
}
async function nextChapter() {
if (state.chapterNavigation) return;
state.chapterNavigation = true;
const currentIndex = state.chapterIndex;
updateNavigation();
try {
if (!await ensureNextChapter()) return;
await openChapter(currentIndex + 1);
} finally {
state.chapterNavigation = false;
updateNavigation();
}
}
async function init() {
if (!sessionId) {
setStatus('在线漫画会话参数缺失', true);
return;
}
const [themeResult, metaResult] = await Promise.all([
api.getTheme(),
api.meta(sessionId)
]);
applyTheme(themeResult && themeResult.ok ? themeResult.data : 'dark');
if (!metaResult || !metaResult.ok || !metaResult.data) {
setStatus(errorText(metaResult, '在线漫画会话已失效'), true);
return;
}
state.meta = metaResult.data;
$('bookTitle').textContent = state.meta.title;
$('tocTitle').textContent = state.meta.title;
$('qualityLabel').textContent = state.meta.quality === 'data' ? '原画质' : '压缩画质';
document.title = `${state.meta.title} · 在线漫画`;
const loaded = await loadChapterPage(1);
if (!loaded) return;
if (!state.chapters.length) {
setStatus('该漫画暂无可在线阅读的章节', true);
return;
}
openChapter(0);
}
$('tocToggleBtn').addEventListener('click', () => $('tocPane').classList.toggle('collapsed'));
$('tocCloseBtn').addEventListener('click', () => $('tocPane').classList.add('collapsed'));
$('prevChapterBtn').addEventListener('click', () => openChapter(state.chapterIndex - 1));
$('nextChapterBtn').addEventListener('click', nextChapter);
$('chapterEndNextBtn').addEventListener('click', nextChapter);
$('loadMoreBtn').addEventListener('click', () => loadChapterPage(state.chapterPage + 1));
$('pageWidthRange').addEventListener('input', (event) => {
const value = Math.max(480, Math.min(1400, Number(event.target.value) || 900));
document.documentElement.style.setProperty('--manga-page-width', `${value}px`);
$('pageWidthLabel').textContent = `${value}px`;
});
$('themeBtn').addEventListener('click', async () => {
const result = await api.setTheme(state.theme === 'dark' ? 'light' : 'dark');
if (result && result.ok) applyTheme(result.data);
});
$('minBtn').addEventListener('click', api.minimize);
$('maxBtn').addEventListener('click', api.maximize);
$('closeBtn').addEventListener('click', api.close);
api.onThemeChanged(applyTheme);
window.addEventListener('beforeunload', () => {
if (state.observer) state.observer.disconnect();
if (state.retentionObserver) state.retentionObserver.disconnect();
revokeImages();
api.closeSession(sessionId).catch(() => {});
});
init().catch((error) => setStatus(error.message || String(error), true));
+5
View File
@@ -485,6 +485,7 @@ body {
.meta-row a { color: var(--accent); text-decoration: none; word-break: break-all; }
.meta-row a:hover { text-decoration: underline; }
.add-lib-btn { margin-top: 10px; }
.online-read-btn { margin: 10px 8px 0 0; }
.add-lib-hint { margin-top: 6px; font-size: 12px; color: var(--text-dim); }
.section-title { font-size: 14px; font-weight: 700; color: var(--accent-bright); margin: 18px 0 10px; }
@@ -494,6 +495,10 @@ body {
max-height: 220px; overflow-y: auto;
}
/* 章节列表(MangaDex 等按章下载的源) */
.chapter-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.chapter-page-info { color: var(--text-dim); font-size: 12px; margin-right: auto; }
/* 下载区 */
.download-box { display: flex; flex-direction: column; gap: 10px; }
.dl-loading, .dl-error { color: var(--text-dim); font-size: 13px; padding: 10px 0; }
+227 -8
View File
@@ -1,6 +1,14 @@
const Browse = (() => {
const AGG_ID = '__all__';
const AGG_LIMIT = 5;
const SOURCE_CATEGORIES = [
['academic', '学术论文'],
['books', '电子书'],
['open', '开放教材与文库'],
['archive', '档案与特藏'],
['manga', '漫画'],
['other', '其他']
];
const state = {
sourceId: null,
@@ -16,7 +24,10 @@ const Browse = (() => {
detailToken: 0,
activeSourceId: null,
currentPostId: null,
currentDetail: null
currentDetail: null,
chapterToken: 0,
chapterPage: 1,
chapterMaxPage: 1
};
let grid, statusBar, pager, gridView, detailView, detailContent, mainEl, sourceSelect, searchInput;
@@ -77,8 +88,15 @@ const Browse = (() => {
state.sourceList = list;
const aggOpt = list.filter((s) => s.supportsSearch).length > 1
? `<option value="${AGG_ID}" data-search="1">🔎 聚合搜索(全部源)</option>` : '';
sourceSelect.innerHTML = aggOpt + list.map((s) =>
`<option value="${escapeHtml(s.id)}" data-search="${s.supportsSearch ? '1' : '0'}">${escapeHtml(s.name)}</option>`).join('');
const grouped = SOURCE_CATEGORIES.map(([id, label]) => {
const entries = list.filter((source) => source.category === id);
if (!entries.length) return '';
const options = entries.map((source) => (
`<option value="${escapeHtml(source.id)}" data-search="${source.supportsSearch ? '1' : '0'}">${escapeHtml(source.name)}</option>`
)).join('');
return `<optgroup label="${label}">${options}</optgroup>`;
}).join('');
sourceSelect.innerHTML = aggOpt + grouped;
if (!list.length) {
state.sourceId = null;
grid.innerHTML = '<div class="empty">未启用任何数据源,请在设置中开启</div>';
@@ -313,8 +331,12 @@ const Browse = (() => {
return;
}
state.currentDetail = res.data;
renderDetail(res.data);
if (sourceDownloadsOnDemand(activeSourceId)) {
const chapterBased = sourceIsChapterBased(activeSourceId);
renderDetail(res.data, chapterBased);
if (chapterBased) {
state.chapterPage = 1;
loadChapters(postId, token, 1);
} else if (sourceDownloadsOnDemand(activeSourceId)) {
renderOnDemandDownload(postId, activeSourceId, token);
} else {
loadDownload(postId, activeSourceId, token);
@@ -332,7 +354,12 @@ const Browse = (() => {
return !!(source && source.downloadOnDemand);
}
function renderDetail(d) {
function sourceIsChapterBased(id) {
const source = state.sourceList.find((item) => item.id === id);
return !!(source && source.chapterBased);
}
function renderDetail(d, chapterBased) {
const tagsHtml = (d.tags || []).map((t) => {
const idx = t.indexOf('');
if (idx > 0) return `<div class="meta-row"><b>${escapeHtml(t.slice(0, idx))}</b>${escapeHtml(t.slice(idx + 1))}</div>`;
@@ -341,6 +368,15 @@ const Browse = (() => {
const authorsHtml = (d.authors && d.authors.length)
? `<div class="detail-authors">${escapeHtml(d.authors.join(', '))}</div>` : '';
const briefHtml = d.brief ? `<div class="section-title">简介 / 摘要</div><div class="brief-panel">${escapeHtml(d.brief)}</div>` : '';
const sectionTitle = chapterBased ? '章节列表' : '下载 / 全文';
const sectionBody = chapterBased
? `<div class="chapter-toolbar">
<span class="chapter-page-info" id="chapterPageInfo"></span>
<button class="tb-btn ghost sm" id="chapterPrevBtn">上一页</button>
<button class="tb-btn ghost sm" id="chapterNextBtn">下一页</button>
</div>
<div class="dl-files" id="chapterList"><div class="dl-loading">加载中...</div></div>`
: '<div class="download-box" id="downloadBox"><div class="dl-loading">下载信息获取中...</div></div>';
detailContent.innerHTML = `
<div class="detail-head">
@@ -352,17 +388,200 @@ const Browse = (() => {
${d.date ? `<div class="meta-row"><b>日期</b>${escapeHtml(d.date)}</div>` : ''}
${tagsHtml}
${d.url ? `<div class="meta-row"><b>原始链接</b><a href="#" id="detailUrlLink">${escapeHtml(d.url)}</a></div>` : ''}
${chapterBased ? '<button class="tb-btn online-read-btn" id="onlineReadBtn">在线阅读整部漫画</button>' : ''}
<button class="tb-btn add-lib-btn" id="addLibBtn">加入我的书库</button>
</div>
</div>
<div class="section-title">下载 / 全文</div>
<div class="download-box" id="downloadBox"><div class="dl-loading">下载信息获取中...</div></div>
<div class="section-title">${sectionTitle}</div>
${sectionBody}
${briefHtml}
`;
$('addLibBtn').onclick = addToLibrary;
const onlineReadBtn = $('onlineReadBtn');
if (onlineReadBtn) onlineReadBtn.onclick = readOnline;
const urlLink = $('detailUrlLink');
if (urlLink) urlLink.onclick = (e) => { e.preventDefault(); window.api.openExternal(d.url); };
if (chapterBased) {
$('chapterPrevBtn').onclick = () => {
if (state.chapterPage > 1) {
loadChapters(state.currentPostId, state.detailToken, state.chapterPage - 1);
}
};
$('chapterNextBtn').onclick = () => {
if (state.chapterPage < state.chapterMaxPage) {
loadChapters(state.currentPostId, state.detailToken, state.chapterPage + 1);
}
};
}
}
async function readOnline() {
const button = $('onlineReadBtn');
if (!button || !state.currentDetail) return;
button.disabled = true;
button.textContent = '正在打开…';
const [qualitySetting, languageSetting] = await Promise.all([
window.api.settings.get('mangadex.imageQuality', 'dataSaver'),
state.activeSourceId === 'mangadex'
? window.api.settings.get('mangadex.chapterLanguage', 'zh')
: Promise.resolve({ ok: true, data: 'zh' })
]);
const quality = qualitySetting.ok && qualitySetting.data === 'data' ? 'data' : 'dataSaver';
const language = languageSetting.ok && ['zh', 'zh-hk', 'all'].includes(languageSetting.data)
? languageSetting.data
: 'zh';
const result = await window.api.sources.readOnline(state.activeSourceId, {
mangaId: state.currentPostId,
title: state.currentDetail.title,
cover: state.currentDetail.cover,
authors: state.currentDetail.authors,
language,
quality
});
button.disabled = false;
button.textContent = result.ok ? '在线阅读器已打开' : '在线阅读整部漫画';
button.title = result.ok ? '' : result.error || '在线阅读器打开失败';
}
const CHAPTER_LANG_LABEL = {
zh: '中文', 'zh-hk': '繁体中文', en: '英语', ja: '日语', ko: '韩语'
};
function chapterMetaLine(c) {
const bits = [];
if (c.translatedLanguage) bits.push(CHAPTER_LANG_LABEL[c.translatedLanguage] || c.translatedLanguage);
if (c.group) bits.push(c.group);
if (c.pages) bits.push(`${c.pages}`);
return bits.join(' · ');
}
function chapterRowHtml(c) {
const disabled = c.external || c.unavailable;
const hint = c.external ? '(站外章节,无法下载)' : (c.unavailable ? '(暂不可用)' : '');
const meta = chapterMetaLine(c);
return `
<div class="dl-file-row" data-chapter-id="${escapeHtml(c.chapterId)}">
<span class="dl-file-name" title="${escapeHtml(c.label)}">${escapeHtml(c.label)}${hint}</span>
${meta ? `<span class="dl-fmt">${escapeHtml(meta)}</span>` : ''}
<button class="dl-btn" data-chapter-dl="${escapeHtml(c.chapterId)}" ${disabled ? 'disabled' : ''}>下载</button>
</div>`;
}
async function loadChapters(mangaId, token, page) {
const chapterToken = ++state.chapterToken;
const sourceId = state.activeSourceId;
const list = $('chapterList');
const info = $('chapterPageInfo');
if (!list) return;
list.innerHTML = '<div class="dl-loading">加载中...</div>';
const prevBtn = $('chapterPrevBtn');
const nextBtn = $('chapterNextBtn');
if (prevBtn) prevBtn.disabled = true;
if (nextBtn) nextBtn.disabled = true;
let options = {};
if (sourceId === 'mangadex') {
const setting = await window.api.settings.get('mangadex.chapterLanguage', 'zh');
const language = setting.ok && ['zh', 'zh-hk', 'all'].includes(setting.data)
? setting.data
: 'zh';
options = { language };
}
const res = await window.api.sources.chapters(sourceId, mangaId, page, options);
if (!list.isConnected || token !== state.detailToken || chapterToken !== state.chapterToken) return;
if (!res.ok) {
list.innerHTML = `<div class="dl-error">加载失败:${escapeHtml(res.error)}<button class="retry-btn" id="chapterRetry">重试</button></div>`;
$('chapterRetry').onclick = () => loadChapters(mangaId, token, page);
return;
}
const { items, maxPage } = res.data;
state.chapterMaxPage = maxPage || 1;
state.chapterPage = page;
if (info) info.textContent = `${page} / ${state.chapterMaxPage}`;
if (prevBtn) prevBtn.disabled = page <= 1;
if (nextBtn) nextBtn.disabled = page >= state.chapterMaxPage;
if (!items.length) {
list.innerHTML = '<div class="empty">该漫画暂无可用章节</div>';
return;
}
list.innerHTML = items.map(chapterRowHtml).join('');
list.querySelectorAll('.dl-btn[data-chapter-dl]').forEach((btn) => {
const chapter = items.find((c) => c.chapterId === btn.dataset.chapterDl);
btn.onclick = () => downloadChapter(btn, chapter);
});
}
function mangaEntryMeta() {
const d = state.currentDetail || {};
return {
...entryMeta(),
mangaId: state.currentPostId,
originalLanguage: d.originalLanguage || 'ja'
};
}
async function downloadChapter(btn, chapter) {
if (!chapter) return;
const sourceId = state.activeSourceId;
const entry = mangaEntryMeta();
const row = btn.closest('.dl-file-row');
const oldProgress = row && row.querySelector('.dl-progress');
if (oldProgress) oldProgress.remove();
const oldError = row && row.querySelector('.dl-error');
if (oldError) oldError.remove();
const progress = row ? createDownloadProgress(row) : null;
if (progress) progress.label.textContent = '准备下载…';
btn.disabled = true;
btn.textContent = '下载中...';
const qualitySetting = await window.api.settings.get('mangadex.imageQuality', 'dataSaver');
const quality = qualitySetting.ok && qualitySetting.data === 'data' ? 'data' : 'dataSaver';
const payload = {
...entry,
chapterId: chapter.chapterId,
label: chapter.label,
volume: chapter.volume,
chapter: chapter.chapter,
translatedLanguage: chapter.translatedLanguage,
group: chapter.group,
quality
};
let res;
try {
res = await window.api.sources.downloadChapter(sourceId, payload, (data) => {
if (!progress || !data || !data.total) return;
const percent = Math.max(0, Math.min(1, data.done / data.total));
progress.box.classList.remove('indeterminate');
progress.fill.style.width = `${Math.round(percent * 100)}%`;
progress.label.textContent = `${data.done} / ${data.total}`;
});
} catch (error) {
res = { ok: false, error: (error && error.message) || String(error) };
}
if (!res.ok) {
if (progress) {
progress.box.classList.add('failed');
progress.label.textContent = res.error || '下载失败';
}
btn.textContent = '重试';
btn.title = res.error || '';
btn.disabled = false;
return;
}
if (progress) {
progress.box.classList.remove('indeterminate');
progress.fill.style.width = '100%';
progress.label.textContent = '下载完成';
setTimeout(() => progress.box.remove(), 900);
}
btn.textContent = '已下载';
btn.title = '';
btn.classList.add('downloaded');
if (window.Library) window.Library.markDirty();
refreshAddButton();
}
async function refreshAddButton() {
+146
View File
@@ -0,0 +1,146 @@
// 最小 ZIP 读写:写入仅支持 STORE(无压缩)条目。
//
// 用途:主进程侧组装/追加漫画 EPUB(EPUB 本质是 ZIP 容器)。图片本身已经是
// JPEG/PNG,压缩收益很低,STORE 把复杂度降到最低——不需要引入 deflate 编解码,
// Node 内置 zlib 从 v22.15 起自带 crc32Electron 43 打包的 Node 24 满足要求。
//
// 读取端兼容 STORE 与 DEFLATE(用 zlib.inflateRawSync 解压),但写入端只产出
// STORE,因为唯一的写入场景(漫画 EPUB)由本模块自己创建,不需要往复压缩。
const zlib = require('zlib');
const LOCAL_FILE_SIG = 0x04034b50;
const CENTRAL_DIR_SIG = 0x02014b50;
const END_OF_CENTRAL_DIR_SIG = 0x06054b50;
const METHOD_STORE = 0;
const METHOD_DEFLATE = 8;
function dosDateTime(date) {
const d = date || new Date();
const time = ((d.getHours() & 0x1f) << 11) | ((d.getMinutes() & 0x3f) << 5) | ((d.getSeconds() >> 1) & 0x1f);
const dosDate = (((d.getFullYear() - 1980) & 0x7f) << 9) | (((d.getMonth() + 1) & 0xf) << 5) | (d.getDate() & 0x1f);
return { time, dosDate };
}
// 写入端只产出 STORE 条目:entries 为 [{ name, data(Buffer) }]。
function writeZip(entries) {
const { time, dosDate } = dosDateTime(new Date());
const localParts = [];
const centralParts = [];
let offset = 0;
for (const entry of entries) {
const nameBuf = Buffer.from(entry.name, 'utf8');
const data = entry.data;
const crc = zlib.crc32(data) >>> 0;
const size = data.length;
const local = Buffer.alloc(30);
local.writeUInt32LE(LOCAL_FILE_SIG, 0);
local.writeUInt16LE(20, 4); // version needed
local.writeUInt16LE(0x0800, 6); // 通用标志位:文件名 UTF-8
local.writeUInt16LE(METHOD_STORE, 8);
local.writeUInt16LE(time, 10);
local.writeUInt16LE(dosDate, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(size, 18);
local.writeUInt32LE(size, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28);
localParts.push(local, nameBuf, data);
const central = Buffer.alloc(46);
central.writeUInt32LE(CENTRAL_DIR_SIG, 0);
central.writeUInt16LE(20, 4); // version made by
central.writeUInt16LE(20, 6); // version needed
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(METHOD_STORE, 10);
central.writeUInt16LE(time, 12);
central.writeUInt16LE(dosDate, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(size, 20);
central.writeUInt32LE(size, 24);
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt16LE(0, 30); // extra length
central.writeUInt16LE(0, 32); // comment length
central.writeUInt16LE(0, 34); // disk number
central.writeUInt16LE(0, 36); // internal attrs
central.writeUInt32LE(0, 38); // external attrs
central.writeUInt32LE(offset, 42);
centralParts.push(central, nameBuf);
offset += local.length + nameBuf.length + data.length;
}
const centralStart = offset;
const centralBuf = Buffer.concat(centralParts);
const end = Buffer.alloc(22);
end.writeUInt32LE(END_OF_CENTRAL_DIR_SIG, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralBuf.length, 12);
end.writeUInt32LE(centralStart, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...localParts, centralBuf, end]);
}
// 从尾部往前找 EOCD:注释字段长度可变,不能假设它贴在文件末尾固定偏移。
function findEndOfCentralDir(buf) {
const minLen = 22;
if (buf.length < minLen) throw new Error('不是有效的 ZIP 文件');
const maxComment = Math.min(buf.length - minLen, 0xffff);
for (let i = 0; i <= maxComment; i++) {
const pos = buf.length - minLen - i;
if (buf.readUInt32LE(pos) === END_OF_CENTRAL_DIR_SIG) return pos;
}
throw new Error('不是有效的 ZIP 文件:找不到目录结尾标记');
}
// 读取端兼容 STORE 与 DEFLATE,返回 Map<name, Buffer>。
function readZip(buf) {
const eocdPos = findEndOfCentralDir(buf);
const total = buf.readUInt16LE(eocdPos + 10);
let centralOffset = buf.readUInt32LE(eocdPos + 16);
const out = new Map();
for (let i = 0; i < total; i++) {
if (buf.readUInt32LE(centralOffset) !== CENTRAL_DIR_SIG) {
throw new Error('ZIP 中央目录已损坏');
}
const method = buf.readUInt16LE(centralOffset + 10);
const expectedCrc = buf.readUInt32LE(centralOffset + 16);
const compSize = buf.readUInt32LE(centralOffset + 20);
const size = buf.readUInt32LE(centralOffset + 24);
const nameLen = buf.readUInt16LE(centralOffset + 28);
const extraLen = buf.readUInt16LE(centralOffset + 30);
const commentLen = buf.readUInt16LE(centralOffset + 32);
const localOffset = buf.readUInt32LE(centralOffset + 42);
const name = buf.toString('utf8', centralOffset + 46, centralOffset + 46 + nameLen);
const localNameLen = buf.readUInt16LE(localOffset + 26);
const localExtraLen = buf.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
const raw = buf.subarray(dataStart, dataStart + compSize);
if (!name.endsWith('/')) {
if (method !== METHOD_STORE && method !== METHOD_DEFLATE) {
throw new Error(`ZIP 压缩方式不受支持: ${method}`);
}
const data = method === METHOD_DEFLATE ? zlib.inflateRawSync(raw) : raw;
if (data.length !== size || (zlib.crc32(data) >>> 0) !== expectedCrc) {
throw new Error(`ZIP 条目校验失败: ${name}`);
}
out.set(name, data);
}
centralOffset += 46 + nameLen + extraLen + commentLen;
}
return out;
}
module.exports = { writeZip, readZip };