feat: 集成漫画源并支持在线阅读
This commit is contained in:
@@ -108,6 +108,8 @@ const annotations = require('./src/reader/annotations');
|
|||||||
const noteAssets = require('./src/reader/note-assets');
|
const noteAssets = require('./src/reader/note-assets');
|
||||||
const readerWindow = require('./src/reader/window');
|
const readerWindow = require('./src/reader/window');
|
||||||
const noteWindow = require('./src/reader/note-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 rangeSessions = require('./src/reader/range-sessions');
|
||||||
const aiConfig = require('./src/reader/ai-config');
|
const aiConfig = require('./src/reader/ai-config');
|
||||||
const aiClient = require('./src/reader/ai-client');
|
const aiClient = require('./src/reader/ai-client');
|
||||||
@@ -276,7 +278,7 @@ noteWindow.setChangeListener(notifyNoteWindowsChanged);
|
|||||||
function applyWindowIcons(theme) {
|
function applyWindowIcons(theme) {
|
||||||
currentUiTheme = theme === 'light' ? 'light' : 'dark';
|
currentUiTheme = theme === 'light' ? 'light' : 'dark';
|
||||||
const icon = iconForTheme(currentUiTheme);
|
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) {
|
for (const win of windows) {
|
||||||
if (!win || win.isDestroyed()) continue;
|
if (!win || win.isDestroyed()) continue;
|
||||||
try { win.setIcon(icon); } catch (e) { /* 平台不支持动态图标时保留创建时图标 */ }
|
try { win.setIcon(icon); } catch (e) { /* 平台不支持动态图标时保留创建时图标 */ }
|
||||||
@@ -284,7 +286,7 @@ function applyWindowIcons(theme) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notifyUiThemeChanged() {
|
function notifyUiThemeChanged() {
|
||||||
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
|
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all(), ...mangaOnlineWindow.all()];
|
||||||
for (const win of windows) {
|
for (const win of windows) {
|
||||||
if (win && !win.isDestroyed()) {
|
if (win && !win.isDestroyed()) {
|
||||||
win.webContents.send('ui:themeChanged', currentUiTheme);
|
win.webContents.send('ui:themeChanged', currentUiTheme);
|
||||||
@@ -314,6 +316,7 @@ app.on('before-quit', () => {
|
|||||||
for (const download of downloadSessions.values()) discardDownloadSession(download);
|
for (const download of downloadSessions.values()) discardDownloadSession(download);
|
||||||
coverGenerator.close();
|
coverGenerator.close();
|
||||||
rangeSessions.closeAll().catch(() => {});
|
rangeSessions.closeAll().catch(() => {});
|
||||||
|
mangaOnlineSessions.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
// fn 同步抛出时也必须变成 { ok:false },否则 invoke 直接 reject,
|
// 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:detail', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).detail(postId)));
|
||||||
ipcMain.handle('source:download', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).download(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 请求与文件下载
|
// 代理配置:全局生效,影响所有数据源的 HTTP 请求与文件下载
|
||||||
ipcMain.handle('proxy:get', () => wrap(() => getProxy()));
|
ipcMain.handle('proxy:get', () => wrap(() => getProxy()));
|
||||||
ipcMain.handle('proxy:set', (_e, url) => {
|
ipcMain.handle('proxy:set', (_e, url) => {
|
||||||
|
|||||||
@@ -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
@@ -1,15 +1,25 @@
|
|||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
let downloadSeq = 0;
|
let downloadSeq = 0;
|
||||||
function runDownload(requestId, url, suggestName, entryId, extraHeaders, meta, onProgress) {
|
function invokeWithProgress(progressChannel, invokeChannel, requestId, args, onProgress) {
|
||||||
const listener = (_event, data) => {
|
const listener = (_event, data) => {
|
||||||
if (!data || data.requestId !== requestId || typeof onProgress !== 'function') return;
|
if (!data || data.requestId !== requestId || typeof onProgress !== 'function') return;
|
||||||
try { onProgress(data); } catch (e) { /* 渲染层进度回调异常不影响下载 */ }
|
try { onProgress(data); } catch (e) { /* 渲染层进度回调异常不影响下载 */ }
|
||||||
};
|
};
|
||||||
if (typeof onProgress === 'function') ipcRenderer.on('download:progress', listener);
|
if (typeof onProgress === 'function') ipcRenderer.on(progressChannel, listener);
|
||||||
return ipcRenderer
|
return ipcRenderer
|
||||||
.invoke('download:file', url, suggestName, entryId, extraHeaders, meta, requestId)
|
.invoke(invokeChannel, ...args, requestId)
|
||||||
.finally(() => ipcRenderer.removeListener('download:progress', listener));
|
.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) {
|
function downloadFile(url, suggestName, entryId, extraHeaders, meta, onProgress) {
|
||||||
@@ -40,13 +50,30 @@ function captureReaderRect(rect) {
|
|||||||
return ipcRenderer.invoke('reader:captureRect', area);
|
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', {
|
contextBridge.exposeInMainWorld('api', {
|
||||||
sources: {
|
sources: {
|
||||||
list: () => ipcRenderer.invoke('sources:list'),
|
list: () => ipcRenderer.invoke('sources:list'),
|
||||||
browse: (sourceId, page) => ipcRenderer.invoke('source:list', sourceId, page),
|
browse: (sourceId, page) => ipcRenderer.invoke('source:list', sourceId, page),
|
||||||
search: (sourceId, keyword, page) => ipcRenderer.invoke('source:search', sourceId, keyword, page),
|
search: (sourceId, keyword, page) => ipcRenderer.invoke('source:search', sourceId, keyword, page),
|
||||||
detail: (sourceId, postId) => ipcRenderer.invoke('source:detail', sourceId, postId),
|
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: {
|
library: {
|
||||||
list: () => ipcRenderer.invoke('library:list'),
|
list: () => ipcRenderer.invoke('library:list'),
|
||||||
|
|||||||
@@ -89,3 +89,36 @@ test('目录 fsync 失败不影响写入结果', () => {
|
|||||||
}
|
}
|
||||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { ok: true });
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const http = require('http');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { app, BrowserWindow } = require('electron');
|
const { app, BrowserWindow } = require('electron');
|
||||||
@@ -9,6 +10,7 @@ app.setPath('appData', TMP);
|
|||||||
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true';
|
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true';
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
let imageServer = null;
|
||||||
function check(name, pass, detail) {
|
function check(name, pass, detail) {
|
||||||
results.push([pass ? 'OK' : 'FAIL', name, detail || '']);
|
results.push([pass ? 'OK' : 'FAIL', name, detail || '']);
|
||||||
}
|
}
|
||||||
@@ -36,6 +38,31 @@ function printSummary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
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'));
|
const library = require(path.join(ROOT, 'src', 'library', 'store'));
|
||||||
let scanStartedAt = 0;
|
let scanStartedAt = 0;
|
||||||
library.scan = () => {
|
library.scan = () => {
|
||||||
@@ -63,23 +90,91 @@ app.whenReady().then(async () => {
|
|||||||
checked: input.checked,
|
checked: input.checked,
|
||||||
name: input.parentElement.querySelector('span').textContent
|
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('新增开放书源出现在设置页且新安装默认启用',
|
check('新增开放书源出现在设置页且新安装默认启用',
|
||||||
expectedSources.every((id) => sourceRows.some((row) => row.id === id && row.checked)),
|
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(', '));
|
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);
|
await waitUntil(() => scanStartedAt > 0, 7000);
|
||||||
check('启动维护在首屏完成后延迟执行', scanStartedAt - startedAt >= 1400,
|
check('启动维护在首屏完成后延迟执行', scanStartedAt - startedAt >= 1400,
|
||||||
`${scanStartedAt - startedAt}ms`);
|
`${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()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
if (!window.isDestroyed()) window.destroy();
|
if (!window.isDestroyed()) window.destroy();
|
||||||
}
|
}
|
||||||
|
await new Promise((resolve) => imageServer.close(resolve));
|
||||||
const failed = printSummary();
|
const failed = printSummary();
|
||||||
app.exit(failed ? 1 : 0);
|
app.exit(failed ? 1 : 0);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
|
if (imageServer) imageServer.close();
|
||||||
console.error('异常:', error);
|
console.error('异常:', error);
|
||||||
check('启动验证未发生异常', false, error.message || String(error));
|
check('启动验证未发生异常', false, error.message || String(error));
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
|
|||||||
@@ -43,6 +43,32 @@ test('所有 IPC handler 都通过 thunk 调用 wrap', () => {
|
|||||||
assert.deepStrictEqual(bare, [], '存在绕过 wrap 的 handler: ' + bare);
|
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('版本比较:预发布版本低于同号正式版', () => {
|
test('版本比较:预发布版本低于同号正式版', () => {
|
||||||
assert.strictEqual(compareVersion('1.1.0', '1.1.0-beta'), 1);
|
assert.strictEqual(compareVersion('1.1.0', '1.1.0-beta'), 1);
|
||||||
assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0'), -1);
|
assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0'), -1);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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)]
|
||||||
|
}), /不是同一部漫画/);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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('注册表:开放教材与中英文维基文库已启用', () => {
|
test('注册表:开放教材与中英文维基文库已启用', () => {
|
||||||
const ids = sources.listSources().map((source) => source.id);
|
const ids = sources.listSources().map((source) => source.id);
|
||||||
for (const id of ['openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en']) {
|
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.items[0].cover, 'https://x/c.jpg');
|
||||||
assert.strictEqual(r.maxPage, 2);
|
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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -240,6 +240,54 @@ test('Z-Library 进入详情不消耗下载额度,点击下载后才解析并
|
|||||||
assert.match(onDemand, /每日免费下载额度有限,仅在点击后获取下载地址/);
|
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('主窗口在设置旁提供持久化明暗主题切换', () => {
|
test('主窗口在设置旁提供持久化明暗主题切换', () => {
|
||||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||||
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
|
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
|
||||||
|
|||||||
@@ -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: 空 ZIP(EOCD 但无条目)不抛错,返回空 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
@@ -32,18 +32,20 @@ function syncDirectory(dir) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入 dest,保留一份 .bak 以便下次读取时恢复。
|
function writeAtomicReplace(dest, data, encoding) {
|
||||||
// 失败时清理临时文件,并在目标已被改走时把备份换回去。
|
|
||||||
function writeJson(dest, value) {
|
|
||||||
const temp = `${dest}.tmp`;
|
const temp = `${dest}.tmp`;
|
||||||
const backup = `${dest}.bak`;
|
const backup = `${dest}.bak`;
|
||||||
let backedUp = false;
|
let backedUp = false;
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
try {
|
try {
|
||||||
writeSynced(temp, JSON.stringify(value, null, 2));
|
writeSynced(temp, data, encoding);
|
||||||
if (fs.existsSync(backup)) fs.unlinkSync(backup);
|
if (fs.existsSync(backup)) fs.unlinkSync(backup);
|
||||||
if (fs.existsSync(dest)) {
|
if (fs.existsSync(dest)) {
|
||||||
fs.renameSync(dest, backup);
|
try {
|
||||||
|
fs.linkSync(dest, backup);
|
||||||
|
} catch (e) {
|
||||||
|
fs.copyFileSync(dest, backup);
|
||||||
|
}
|
||||||
backedUp = true;
|
backedUp = true;
|
||||||
}
|
}
|
||||||
fs.renameSync(temp, dest);
|
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 (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
|
||||||
try {
|
try {
|
||||||
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
|
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
|
||||||
|
else if (backedUp && fs.existsSync(backup)) fs.unlinkSync(backup);
|
||||||
} catch (rollback) { /* 下次读取时从 .bak 恢复 */ }
|
} catch (rollback) { /* 下次读取时从 .bak 恢复 */ }
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 写入 dest,保留一份 .bak 以便写入失败或异常退出时恢复。
|
||||||
|
function writeJson(dest, value) {
|
||||||
|
writeAtomicReplace(dest, JSON.stringify(value, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
// 二进制落地(封面等)。已存在则不覆盖,由调用方决定语义。
|
// 二进制落地(封面等)。已存在则不覆盖,由调用方决定语义。
|
||||||
function writeBytesExclusive(dest, bytes) {
|
function writeBytesExclusive(dest, bytes) {
|
||||||
const temp = `${dest}.tmp`;
|
const temp = `${dest}.tmp`;
|
||||||
@@ -75,4 +83,8 @@ function writeBytesExclusive(dest, bytes) {
|
|||||||
syncDirectory(path.dirname(dest));
|
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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlAttr(value) {
|
||||||
|
return xmlText(value).replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -14,7 +14,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const atomic = require('../atomic-file');
|
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 SCHEMA_VERSION = 4;
|
||||||
const MAX_TAGS = 50;
|
const MAX_TAGS = 50;
|
||||||
@@ -330,37 +330,6 @@ function imageExt(bytes) {
|
|||||||
return '';
|
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) {
|
async function cacheCover(id, url, baseDir) {
|
||||||
let temp = '';
|
let temp = '';
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -38,6 +38,37 @@ function fetchWithElectron(url, options = {}) {
|
|||||||
return fetchWithProxy(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>>
|
// 简易 cookie jar: Map<domain, Map<name, value>>
|
||||||
const cookieJar = new Map();
|
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
@@ -14,6 +14,27 @@ const openstax = require('./openstax');
|
|||||||
const opentextbook = require('./opentextbook');
|
const opentextbook = require('./opentextbook');
|
||||||
const wikisourceZh = require('./wikisource-zh');
|
const wikisourceZh = require('./wikisource-zh');
|
||||||
const wikisourceEn = require('./wikisource-en');
|
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 = [
|
const sources = [
|
||||||
arxiv,
|
arxiv,
|
||||||
@@ -31,7 +52,9 @@ const sources = [
|
|||||||
libgen,
|
libgen,
|
||||||
zlib,
|
zlib,
|
||||||
scihub,
|
scihub,
|
||||||
motw
|
motw,
|
||||||
|
mangadex,
|
||||||
|
copymanga
|
||||||
];
|
];
|
||||||
const byId = new Map(sources.map((s) => [s.id, s]));
|
const byId = new Map(sources.map((s) => [s.id, s]));
|
||||||
|
|
||||||
@@ -39,8 +62,11 @@ function listSources() {
|
|||||||
return sources.map((s) => ({
|
return sources.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
|
category: s.category || CATEGORY_BY_ID[s.id] || 'other',
|
||||||
|
experimental: s.experimental === true,
|
||||||
supportsSearch: s.supportsSearch !== false,
|
supportsSearch: s.supportsSearch !== false,
|
||||||
downloadOnDemand: s.downloadOnDemand === true
|
downloadOnDemand: s.downloadOnDemand === true,
|
||||||
|
chapterBased: s.chapterBased === true
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -172,8 +172,28 @@ async function refreshAskSave() {
|
|||||||
}
|
}
|
||||||
$('askSaveChk').onchange = () => window.api.settings.set('askSavePath', $('askSaveChk').checked);
|
$('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();
|
refreshLibraryDir();
|
||||||
refreshAskSave();
|
refreshAskSave();
|
||||||
|
refreshMangadexQuality();
|
||||||
|
refreshMangadexLanguage();
|
||||||
|
|
||||||
async function refreshProxy() {
|
async function refreshProxy() {
|
||||||
const r = await window.api.proxy.get();
|
const r = await window.api.proxy.get();
|
||||||
|
|||||||
@@ -195,6 +195,29 @@
|
|||||||
<div id="sourceList" class="source-list"></div>
|
<div id="sourceList" class="source-list"></div>
|
||||||
</div>
|
</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-group">
|
||||||
<div class="settings-item">
|
<div class="settings-item">
|
||||||
<div class="settings-item-info">
|
<div class="settings-item-info">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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));
|
||||||
@@ -485,6 +485,7 @@ body {
|
|||||||
.meta-row a { color: var(--accent); text-decoration: none; word-break: break-all; }
|
.meta-row a { color: var(--accent); text-decoration: none; word-break: break-all; }
|
||||||
.meta-row a:hover { text-decoration: underline; }
|
.meta-row a:hover { text-decoration: underline; }
|
||||||
.add-lib-btn { margin-top: 10px; }
|
.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); }
|
.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; }
|
.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;
|
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; }
|
.download-box { display: flex; flex-direction: column; gap: 10px; }
|
||||||
.dl-loading, .dl-error { color: var(--text-dim); font-size: 13px; padding: 10px 0; }
|
.dl-loading, .dl-error { color: var(--text-dim); font-size: 13px; padding: 10px 0; }
|
||||||
|
|||||||
+227
-8
@@ -1,6 +1,14 @@
|
|||||||
const Browse = (() => {
|
const Browse = (() => {
|
||||||
const AGG_ID = '__all__';
|
const AGG_ID = '__all__';
|
||||||
const AGG_LIMIT = 5;
|
const AGG_LIMIT = 5;
|
||||||
|
const SOURCE_CATEGORIES = [
|
||||||
|
['academic', '学术论文'],
|
||||||
|
['books', '电子书'],
|
||||||
|
['open', '开放教材与文库'],
|
||||||
|
['archive', '档案与特藏'],
|
||||||
|
['manga', '漫画'],
|
||||||
|
['other', '其他']
|
||||||
|
];
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
sourceId: null,
|
sourceId: null,
|
||||||
@@ -16,7 +24,10 @@ const Browse = (() => {
|
|||||||
detailToken: 0,
|
detailToken: 0,
|
||||||
activeSourceId: null,
|
activeSourceId: null,
|
||||||
currentPostId: null,
|
currentPostId: null,
|
||||||
currentDetail: null
|
currentDetail: null,
|
||||||
|
chapterToken: 0,
|
||||||
|
chapterPage: 1,
|
||||||
|
chapterMaxPage: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
let grid, statusBar, pager, gridView, detailView, detailContent, mainEl, sourceSelect, searchInput;
|
let grid, statusBar, pager, gridView, detailView, detailContent, mainEl, sourceSelect, searchInput;
|
||||||
@@ -77,8 +88,15 @@ const Browse = (() => {
|
|||||||
state.sourceList = list;
|
state.sourceList = list;
|
||||||
const aggOpt = list.filter((s) => s.supportsSearch).length > 1
|
const aggOpt = list.filter((s) => s.supportsSearch).length > 1
|
||||||
? `<option value="${AGG_ID}" data-search="1">🔎 聚合搜索(全部源)</option>` : '';
|
? `<option value="${AGG_ID}" data-search="1">🔎 聚合搜索(全部源)</option>` : '';
|
||||||
sourceSelect.innerHTML = aggOpt + list.map((s) =>
|
const grouped = SOURCE_CATEGORIES.map(([id, label]) => {
|
||||||
`<option value="${escapeHtml(s.id)}" data-search="${s.supportsSearch ? '1' : '0'}">${escapeHtml(s.name)}</option>`).join('');
|
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) {
|
if (!list.length) {
|
||||||
state.sourceId = null;
|
state.sourceId = null;
|
||||||
grid.innerHTML = '<div class="empty">未启用任何数据源,请在设置中开启</div>';
|
grid.innerHTML = '<div class="empty">未启用任何数据源,请在设置中开启</div>';
|
||||||
@@ -313,8 +331,12 @@ const Browse = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.currentDetail = res.data;
|
state.currentDetail = res.data;
|
||||||
renderDetail(res.data);
|
const chapterBased = sourceIsChapterBased(activeSourceId);
|
||||||
if (sourceDownloadsOnDemand(activeSourceId)) {
|
renderDetail(res.data, chapterBased);
|
||||||
|
if (chapterBased) {
|
||||||
|
state.chapterPage = 1;
|
||||||
|
loadChapters(postId, token, 1);
|
||||||
|
} else if (sourceDownloadsOnDemand(activeSourceId)) {
|
||||||
renderOnDemandDownload(postId, activeSourceId, token);
|
renderOnDemandDownload(postId, activeSourceId, token);
|
||||||
} else {
|
} else {
|
||||||
loadDownload(postId, activeSourceId, token);
|
loadDownload(postId, activeSourceId, token);
|
||||||
@@ -332,7 +354,12 @@ const Browse = (() => {
|
|||||||
return !!(source && source.downloadOnDemand);
|
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 tagsHtml = (d.tags || []).map((t) => {
|
||||||
const idx = t.indexOf(':');
|
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>`;
|
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)
|
const authorsHtml = (d.authors && d.authors.length)
|
||||||
? `<div class="detail-authors">${escapeHtml(d.authors.join(', '))}</div>` : '';
|
? `<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 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 = `
|
detailContent.innerHTML = `
|
||||||
<div class="detail-head">
|
<div class="detail-head">
|
||||||
@@ -352,17 +388,200 @@ const Browse = (() => {
|
|||||||
${d.date ? `<div class="meta-row"><b>日期</b>${escapeHtml(d.date)}</div>` : ''}
|
${d.date ? `<div class="meta-row"><b>日期</b>${escapeHtml(d.date)}</div>` : ''}
|
||||||
${tagsHtml}
|
${tagsHtml}
|
||||||
${d.url ? `<div class="meta-row"><b>原始链接</b><a href="#" id="detailUrlLink">${escapeHtml(d.url)}</a></div>` : ''}
|
${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>
|
<button class="tb-btn add-lib-btn" id="addLibBtn">加入我的书库</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-title">下载 / 全文</div>
|
<div class="section-title">${sectionTitle}</div>
|
||||||
<div class="download-box" id="downloadBox"><div class="dl-loading">下载信息获取中...</div></div>
|
${sectionBody}
|
||||||
${briefHtml}
|
${briefHtml}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
$('addLibBtn').onclick = addToLibrary;
|
$('addLibBtn').onclick = addToLibrary;
|
||||||
|
const onlineReadBtn = $('onlineReadBtn');
|
||||||
|
if (onlineReadBtn) onlineReadBtn.onclick = readOnline;
|
||||||
const urlLink = $('detailUrlLink');
|
const urlLink = $('detailUrlLink');
|
||||||
if (urlLink) urlLink.onclick = (e) => { e.preventDefault(); window.api.openExternal(d.url); };
|
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() {
|
async function refreshAddButton() {
|
||||||
|
|||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
// 最小 ZIP 读写:写入仅支持 STORE(无压缩)条目。
|
||||||
|
//
|
||||||
|
// 用途:主进程侧组装/追加漫画 EPUB(EPUB 本质是 ZIP 容器)。图片本身已经是
|
||||||
|
// JPEG/PNG,压缩收益很低,STORE 把复杂度降到最低——不需要引入 deflate 编解码,
|
||||||
|
// Node 内置 zlib 从 v22.15 起自带 crc32,Electron 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 };
|
||||||
Reference in New Issue
Block a user