Compare commits

...
10 Commits
Author SHA1 Message Date
lofyer 26c7aaff66 fix: 修正自定义镜像优先级并发布 v2.1.6
构建与发布 / 发布 GitHub Release (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
构建与发布 / 单测与集成测试 (push) Has been cancelled
用户配置自定义 Z-Library 镜像后,旧会话记录的内置镜像仍被排在前面,
与界面“自定义优先”的约定不一致。调整请求顺序,让自定义镜像严格优先;
全部镜像失败时保留首个自定义镜像的错误,避免被后续兜底错误覆盖。

版本更新到 2.1.6,并补充优先级与错误归因回归测试。
2026-08-09 15:48:52 +08:00
lofyer 488de94289 feat: 支持自定义 Z-Library 镜像并发布 v2.1.5
在 Z-Library 账户设置内增加镜像站点编辑入口,一行一个 HTTPS 地址,
自定义地址经 origin 规范化后优先于内置列表。镜像配置与凭据分开处理,
退出登录后仍保留;移除当前会话依赖的镜像时清理旧会话和 Cookie。

版本更新到 2.1.5,并补充存储、校验、IPC、界面与真实 Electron 保存测试。
2026-08-09 15:25:32 +08:00
lofyer 7f0eea7464 fix: 修复 Z-Library 加载无限转圈
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
构建与发布 / 单测与集成测试 (push) Waiting to run
登录时注入页面的 fetch 没有超时,executeJavaScriptInIsolatedWorld 在页面
卡死时也不会 reject,两处 Promise 永不落定会让 IPC 不回包,界面停在加载中
且没有任何错误。wrap() 只能捕获抛出,捕获不了不返回。

页内 fetch 加 AbortSignal.timeout,注入求值外挂超时,数据源 IPC 统一经
sourceCall() 加兜底时限。另外启动与保存代理时等待 Chromium 切换完成并关闭
旧连接,避免继续复用切换前的连接。
2026-08-09 15:02:39 +08:00
lofyer 763f2b02ac fix: 修复漫画阅读器卡死并支持章节直读 2026-08-09 13:02:24 +08:00
lofyer 87dcc307e6 feat: 集成漫画源并支持在线阅读 2026-08-08 19:32:47 +08:00
lofyer c2292da442 fix: Z-Library 按需获取下载地址并发布 v2.1.4
构建与发布 / 单测与集成测试 (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
构建与发布 / 发布 GitHub Release (push) Has been cancelled
2026-08-07 21:29:19 +08:00
lofyer 69c9ce1c2a fix: 长分类名称省略时保留数量 2026-08-06 23:41:17 +08:00
lofyer c294b68e96 feat: 分类计数改为括号样式并发布 v2.1.3
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
2026-08-06 18:37:50 +08:00
lofyer d6078098d6 feat: 分类显示书籍数量并发布 v2.1.2
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
2026-08-06 18:27:47 +08:00
lofyer eaf9939436 feat: 缺失文件扫描支持清理并发布 v2.1.1
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
2026-08-06 17:57:45 +08:00
44 changed files with 4716 additions and 109 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ DRM 保护的 MOBI/AZW/AZW3、KFX、Topaz 以及损坏或不兼容的文件不
2. 双击目录中的 `PeopleLib.exe`
3. 保留整个程序目录,不要只移动 exe。用户数据默认保存在程序同级的 `data/`
当前版本为 **2.1.0**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
当前版本为 **2.1.3**。可在「设置」中手动检查更新,也可启用启动时自动检查。检测到新版本后,应用会打开对应的 GitHub Release 下载页,更新前请退出旧版本并覆盖程序文件,`data/` 目录无需替换。
## 开发
+4
View File
@@ -135,6 +135,10 @@ function appEntries() {
const entries = [
collectFile(path.join(ROOT, 'main.js'), `${app}/main.js`),
collectFile(path.join(ROOT, 'preload.js'), `${app}/preload.js`),
collectFile(
path.join(ROOT, 'manga-online-preload.js'),
`${app}/manga-online-preload.js`
),
...collectDir(path.join(ROOT, 'src'), `${app}/src`, (e) => e.name.startsWith('_test'))
];
+4
View File
@@ -273,6 +273,10 @@ async function build() {
fs.mkdirSync(APP, { recursive: true });
fs.copyFileSync(path.join(ROOT, 'main.js'), path.join(APP, 'main.js'));
fs.copyFileSync(path.join(ROOT, 'preload.js'), path.join(APP, 'preload.js'));
fs.copyFileSync(
path.join(ROOT, 'manga-online-preload.js'),
path.join(APP, 'manga-online-preload.js')
);
copyDir(path.join(ROOT, 'src'), path.join(APP, 'src'), (e) => e.name.startsWith('_test'));
const iconDir = path.join(APP, 'icons', 'dist');
fs.mkdirSync(iconDir, { recursive: true });
+4
View File
@@ -165,6 +165,10 @@ async function build() {
fs.mkdirSync(APP, { recursive: true });
fs.copyFileSync(path.join(ROOT, 'main.js'), path.join(APP, 'main.js'));
fs.copyFileSync(path.join(ROOT, 'preload.js'), path.join(APP, 'preload.js'));
fs.copyFileSync(
path.join(ROOT, 'manga-online-preload.js'),
path.join(APP, 'manga-online-preload.js')
);
copyDir(path.join(ROOT, 'src'), path.join(APP, 'src'), (e) => e.name.startsWith('_test'));
const iconDir = path.join(APP, 'icons', 'dist');
fs.mkdirSync(iconDir, { recursive: true });
+13 -2
View File
@@ -114,7 +114,12 @@ async function verifyWindowsZip(file) {
const JSZip = require('jszip');
const zip = await JSZip.loadAsync(fs.readFileSync(file));
const names = Object.keys(zip.files).map((n) => n.replace(/^[^/]+\//, ''));
requireEntries(names, [`${PRODUCT}.exe`, 'resources/app/main.js', 'locales/zh-CN.pak']);
requireEntries(names, [
`${PRODUCT}.exe`,
'resources/app/main.js',
'resources/app/manga-online-preload.js',
'locales/zh-CN.pak'
]);
forbidEntries(names, [/^data\//, /(^|\/)_test\//]);
}
@@ -131,7 +136,13 @@ async function verifyDmg(file) {
async function verifyLinuxTarball(file) {
const entries = readTarEntries(zlib.gunzipSync(fs.readFileSync(file)));
const names = entries.map((e) => e.name.replace(/^[^/]+\//, ''));
requireEntries(names, [PRODUCT, `${PRODUCT}.sh`, 'resources/app/main.js', 'locales/zh-CN.pak']);
requireEntries(names, [
PRODUCT,
`${PRODUCT}.sh`,
'resources/app/main.js',
'resources/app/manga-online-preload.js',
'locales/zh-CN.pak'
]);
forbidEntries(names, [/^data\//, /(^|\/)_test\//]);
for (const required of [PRODUCT, `${PRODUCT}.sh`, 'chrome-sandbox']) {
+131 -20
View File
@@ -108,6 +108,8 @@ const annotations = require('./src/reader/annotations');
const noteAssets = require('./src/reader/note-assets');
const readerWindow = require('./src/reader/window');
const noteWindow = require('./src/reader/note-window');
const mangaOnlineWindow = require('./src/manga-online/window');
const mangaOnlineSessions = require('./src/manga-online/session');
const rangeSessions = require('./src/reader/range-sessions');
const aiConfig = require('./src/reader/ai-config');
const aiClient = require('./src/reader/ai-client');
@@ -276,7 +278,7 @@ noteWindow.setChangeListener(notifyNoteWindowsChanged);
function applyWindowIcons(theme) {
currentUiTheme = theme === 'light' ? 'light' : 'dark';
const icon = iconForTheme(currentUiTheme);
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all(), ...mangaOnlineWindow.all()];
for (const win of windows) {
if (!win || win.isDestroyed()) continue;
try { win.setIcon(icon); } catch (e) { /* 平台不支持动态图标时保留创建时图标 */ }
@@ -284,7 +286,7 @@ function applyWindowIcons(theme) {
}
function notifyUiThemeChanged() {
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all(), ...mangaOnlineWindow.all()];
for (const win of windows) {
if (win && !win.isDestroyed()) {
win.webContents.send('ui:themeChanged', currentUiTheme);
@@ -294,11 +296,17 @@ function notifyUiThemeChanged() {
library.setChangeListener(notifyLibraryChanged);
app.whenReady().then(() => {
// 应用代理到 Chromium defaultSession(影响 net.fetch、窗口加载、所有请求)
const p = getProxy();
if (p) {
session.defaultSession.setProxy({ proxyRules: p }).catch(() => {});
async function applySessionProxy(ses, proxy) {
await ses.setProxy({ proxyRules: proxy || 'direct://' });
await ses.closeAllConnections();
}
app.whenReady().then(async () => {
// 必须在建窗前等 Chromium 代理真正生效,否则启动后首批 net.fetch 会沿用直连。
try {
await applySessionProxy(session.defaultSession, getProxy());
} catch (e) {
console.warn('Chromium 代理应用失败:', e.message);
}
createWindow();
setTimeout(cleanupNoteAssets, 0);
@@ -314,6 +322,7 @@ app.on('before-quit', () => {
for (const download of downloadSessions.values()) discardDownloadSession(download);
coverGenerator.close();
rangeSessions.closeAll().catch(() => {});
mangaOnlineSessions.reset();
});
// fn 同步抛出时也必须变成 { ok:false },否则 invoke 直接 reject
@@ -325,28 +334,118 @@ function wrap(fn) {
.catch((err) => ({ ok: false, error: (err && err.message) || String(err) }));
}
// Promise 永不落定和同步抛出一样会把界面钉在"加载中",而且更难发现:
// executeJavaScript 在页面卡死或窗口销毁时不会 reject,页内 fetch 默认也没有超时。
function withTimeout(promise, ms, message) {
let timer = null;
return Promise.race([
promise,
new Promise((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms);
})
]).finally(() => { if (timer) clearTimeout(timer); });
}
// 兜底时限,不是精确预算:正常情况下各层自己的超时(fetchRaw 15s、登录页 30s
// 会先给出具体原因。串行走完 5 个镜像的登录最坏可达数分钟,这里会先截断并给出
// 通用提示,宁可让用户拿到可操作的错误,也不要让界面无限转圈。
const SOURCE_TIMEOUT_MS = 120000;
function sourceCall(fn) {
return wrap(() => withTimeout(
Promise.resolve().then(fn),
SOURCE_TIMEOUT_MS,
'数据源长时间无响应,请稍后重试或更换数据源'
));
}
// 数据源
ipcMain.handle('sources:list', () => wrap(() => sources.listSources()));
ipcMain.handle('source:list', (_e, sourceId, page) => wrap(() => sources.getSource(sourceId).list(page)));
ipcMain.handle('source:search', (_e, sourceId, keyword, page) => wrap(() => sources.getSource(sourceId).search(keyword, page)));
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:list', (_e, sourceId, page) => sourceCall(() => sources.getSource(sourceId).list(page)));
ipcMain.handle('source:search', (_e, sourceId, keyword, page) => sourceCall(() => sources.getSource(sourceId).search(keyword, page)));
ipcMain.handle('source:detail', (_e, sourceId, postId) => sourceCall(() => sources.getSource(sourceId).detail(postId)));
ipcMain.handle('source:download', (_e, sourceId, postId) => sourceCall(() => sources.getSource(sourceId).download(postId)));
// 按章节下载的源不复用 download:file;后者只处理单个直链文件。
ipcMain.handle('source:chapters', (_e, sourceId, postId, page, options) => sourceCall(() => {
const source = sources.getSource(sourceId);
if (typeof source.chapters !== 'function') throw new Error('该数据源不支持章节列表');
return source.chapters(postId, page, options || {});
}));
ipcMain.handle('source:readOnline', (event, sourceId, input) => wrap(() => {
if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) {
throw new Error('只有主窗口可以打开在线漫画');
}
const source = sources.getSource(sourceId);
const win = mangaOnlineWindow.create(__dirname, currentUiTheme);
const ownerId = win.webContents.id;
win.webContents.once('destroyed', () => mangaOnlineSessions.closeOwner(ownerId));
try {
const value = mangaOnlineSessions.create(ownerId, source, input || {});
mangaOnlineWindow.load(__dirname, value.sessionId);
return value;
} catch (error) {
win.destroy();
throw error;
}
}));
ipcMain.handle('source:downloadChapter', async (event, sourceId, payload, requestId) => {
const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
const sendProgress = (done, total) => {
if (!progressId || event.sender.isDestroyed()) return;
event.sender.send('source:chapterProgress', { requestId: progressId, done, total });
};
return wrap(async () => {
const source = sources.getSource(sourceId);
if (typeof source.downloadChapter !== 'function') throw new Error('该数据源不支持章节下载');
// library.add()/attachFile() 内部已经在 commit() 里触发变更通知,这里不需要再发一次
const result = await source.downloadChapter(library, payload || {}, sendProgress);
return { entryId: result.entry ? result.entry.id : null, chapterLabel: result.chapterLabel, created: result.created };
});
});
function requireMangaOnlineSender(event) {
if (!mangaOnlineWindow.fromWebContents(event.sender)) throw new Error('只有在线漫画阅读器可以读取内容');
return event.sender.id;
}
ipcMain.handle('mangaOnline:meta', (event, sessionId) => wrap(() => (
mangaOnlineSessions.meta(requireMangaOnlineSender(event), sessionId)
)));
ipcMain.handle('mangaOnline:chapters', (event, sessionId, page) => wrap(() => (
mangaOnlineSessions.chapters(requireMangaOnlineSender(event), sessionId, page)
)));
ipcMain.handle('mangaOnline:chapterManifest', (event, sessionId, chapterId) => wrap(() => (
mangaOnlineSessions.chapterManifest(requireMangaOnlineSender(event), sessionId, chapterId)
)));
ipcMain.handle('mangaOnline:image', (event, sessionId, manifestId, index) => wrap(() => (
mangaOnlineSessions.image(
requireMangaOnlineSender(event),
sessionId,
manifestId,
index
)
)));
ipcMain.handle('mangaOnline:close', (event, sessionId) => wrap(() => (
mangaOnlineSessions.close(requireMangaOnlineSender(event), sessionId)
)));
// 代理配置:全局生效,影响所有数据源的 HTTP 请求与文件下载
ipcMain.handle('proxy:get', () => wrap(() => getProxy()));
ipcMain.handle('proxy:set', (_e, url) => {
ipcMain.handle('proxy:set', (_e, url) => wrap(async () => {
const previous = getProxy();
try {
const u = String(url || '').trim();
setProxy(u);
await applySessionProxy(session.defaultSession, u);
settings.set('proxy', u);
session.defaultSession.setProxy({ proxyRules: u || 'direct://' }).catch(() => {});
return { ok: true };
return true;
} catch (e) {
try { setProxy(previous); } catch (rollbackError) { /* ignore */ }
return { ok: false, error: e.message || String(e) };
try { await applySessionProxy(session.defaultSession, previous); } catch (rollbackError) { /* ignore */ }
throw e;
}
});
}));
function zlibOrigin(value) {
let url;
@@ -357,6 +456,8 @@ function zlibOrigin(value) {
return url.origin;
}
const ZLIB_LOGIN_FETCH_MS = 20000;
async function waitForZlibPage(win, origin) {
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
@@ -409,6 +510,7 @@ async function browserZlibLogin(mirror, email, password) {
const code = `fetch(${JSON.stringify(`${origin}/rpc.php`)}, {
method: 'POST',
credentials: 'include',
signal: AbortSignal.timeout(${ZLIB_LOGIN_FETCH_MS}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
@@ -419,10 +521,10 @@ async function browserZlibLogin(mirror, email, password) {
contentType: response.headers.get('content-type') || '',
text: await response.text()
}))`;
const result = await win.webContents.executeJavaScriptInIsolatedWorld(
1001,
[{ code }],
true
const result = await withTimeout(
win.webContents.executeJavaScriptInIsolatedWorld(1001, [{ code }], true),
ZLIB_LOGIN_FETCH_MS + 5000,
'登录镜像无响应'
);
let data = null;
try { data = JSON.parse(result && result.text); } catch (e) { /* 非 JSON */ }
@@ -454,6 +556,8 @@ sources.getSource('zlib').setLoginTransport(browserZlibLogin);
ipcMain.handle('zlib:hasCreds', () => wrap(() => zlibAuth.hasCreds()));
ipcMain.handle('zlib:login', (_e, email, password) => wrap(() => sources.getSource('zlib').login(email, password)));
ipcMain.handle('zlib:logout', () => wrap(() => sources.getSource('zlib').logout()));
ipcMain.handle('zlib:getMirrors', () => wrap(() => sources.getSource('zlib').getMirrorConfig()));
ipcMain.handle('zlib:setMirrors', (_e, list) => wrap(() => sources.getSource('zlib').setMirrors(list)));
// Semantic Scholar API Key
ipcMain.handle('semanticScholar:keyStatus', () => wrap(() => semanticKey.status()));
@@ -506,6 +610,13 @@ ipcMain.handle('library:scan', () => wrap(() => {
for (const job of coverGenerator.ensureAll()) job.catch(() => {});
return r;
}));
ipcMain.handle('library:removeMissing', () => wrap(async () => {
const ids = library.list()
.filter((item) => item.missing)
.map((item) => String(item.id));
for (const id of ids) await requestReaderPurge(id);
return library.removeMissing();
}));
// 本地书库
ipcMain.handle('library:list', () => wrap(() => library.list().map((item) => ({
+23
View File
@@ -0,0 +1,23 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('mangaApi', {
meta: (sessionId) => ipcRenderer.invoke('mangaOnline:meta', sessionId),
chapters: (sessionId, page) => ipcRenderer.invoke('mangaOnline:chapters', sessionId, page),
chapterManifest: (sessionId, chapterId) => (
ipcRenderer.invoke('mangaOnline:chapterManifest', sessionId, chapterId)
),
image: (sessionId, manifestId, index) => (
ipcRenderer.invoke('mangaOnline:image', sessionId, manifestId, index)
),
closeSession: (sessionId) => ipcRenderer.invoke('mangaOnline:close', sessionId),
getTheme: () => ipcRenderer.invoke('ui:getTheme'),
setTheme: (theme) => ipcRenderer.invoke('ui:setTheme', theme),
onThemeChanged: (callback) => {
const listener = (_event, theme) => callback(theme);
ipcRenderer.on('ui:themeChanged', listener);
return () => ipcRenderer.removeListener('ui:themeChanged', listener);
},
minimize: () => ipcRenderer.send('win:minimize'),
maximize: () => ipcRenderer.send('win:maximize'),
close: () => ipcRenderer.send('win:close')
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "peoplelib",
"version": "2.1.0",
"version": "2.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "peoplelib",
"version": "2.1.0",
"version": "2.1.6",
"license": "MIT",
"dependencies": {
"foliate-js": "1.0.1",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "peoplelib",
"version": "2.1.0",
"version": "2.1.6",
"description": "多源开放文献、电子书与本地书库客户端",
"main": "main.js",
"author": "peoplelib",
@@ -42,6 +42,7 @@
"files": [
"main.js",
"preload.js",
"manga-online-preload.js",
"src/**/*",
"icons/dist/*.ico",
"icons/dist/dark/icon-32.png",
+36 -6
View File
@@ -1,15 +1,25 @@
const { contextBridge, ipcRenderer } = require('electron');
let downloadSeq = 0;
function runDownload(requestId, url, suggestName, entryId, extraHeaders, meta, onProgress) {
function invokeWithProgress(progressChannel, invokeChannel, requestId, args, onProgress) {
const listener = (_event, data) => {
if (!data || data.requestId !== requestId || typeof onProgress !== 'function') return;
try { onProgress(data); } catch (e) { /* 渲染层进度回调异常不影响下载 */ }
};
if (typeof onProgress === 'function') ipcRenderer.on('download:progress', listener);
if (typeof onProgress === 'function') ipcRenderer.on(progressChannel, listener);
return ipcRenderer
.invoke('download:file', url, suggestName, entryId, extraHeaders, meta, requestId)
.finally(() => ipcRenderer.removeListener('download:progress', listener));
.invoke(invokeChannel, ...args, requestId)
.finally(() => ipcRenderer.removeListener(progressChannel, listener));
}
function runDownload(requestId, url, suggestName, entryId, extraHeaders, meta, onProgress) {
return invokeWithProgress(
'download:progress',
'download:file',
requestId,
[url, suggestName, entryId, extraHeaders, meta],
onProgress
);
}
function downloadFile(url, suggestName, entryId, extraHeaders, meta, onProgress) {
@@ -40,13 +50,30 @@ function captureReaderRect(rect) {
return ipcRenderer.invoke('reader:captureRect', area);
}
let chapterDownloadSeq = 0;
function runChapterDownload(sourceId, payload, onProgress) {
const requestId = `chapter_${Date.now().toString(36)}_${(++chapterDownloadSeq).toString(36)}`;
return invokeWithProgress(
'source:chapterProgress',
'source:downloadChapter',
requestId,
[sourceId, payload],
onProgress
);
}
contextBridge.exposeInMainWorld('api', {
sources: {
list: () => ipcRenderer.invoke('sources:list'),
browse: (sourceId, page) => ipcRenderer.invoke('source:list', sourceId, page),
search: (sourceId, keyword, page) => ipcRenderer.invoke('source:search', sourceId, keyword, page),
detail: (sourceId, postId) => ipcRenderer.invoke('source:detail', sourceId, postId),
download: (sourceId, postId) => ipcRenderer.invoke('source:download', sourceId, postId)
download: (sourceId, postId) => ipcRenderer.invoke('source:download', sourceId, postId),
chapters: (sourceId, postId, page, options) => (
ipcRenderer.invoke('source:chapters', sourceId, postId, page, options)
),
readOnline: (sourceId, input) => ipcRenderer.invoke('source:readOnline', sourceId, input),
downloadChapter: (sourceId, payload, onProgress) => runChapterDownload(sourceId, payload, onProgress)
},
library: {
list: () => ipcRenderer.invoke('library:list'),
@@ -73,6 +100,7 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.invoke('library:importLocal', selectionId, options)
),
scan: () => ipcRenderer.invoke('library:scan'),
removeMissing: () => ipcRenderer.invoke('library:removeMissing'),
onChanged: (cb) => {
const h = () => cb();
ipcRenderer.on('library:changed', h);
@@ -101,7 +129,9 @@ contextBridge.exposeInMainWorld('api', {
zlib: {
hasCreds: () => ipcRenderer.invoke('zlib:hasCreds'),
login: (email, password) => ipcRenderer.invoke('zlib:login', email, password),
logout: () => ipcRenderer.invoke('zlib:logout')
logout: () => ipcRenderer.invoke('zlib:logout'),
getMirrors: () => ipcRenderer.invoke('zlib:getMirrors'),
setMirrors: (list) => ipcRenderer.invoke('zlib:setMirrors', list)
},
semanticScholar: {
keyStatus: () => ipcRenderer.invoke('semanticScholar:keyStatus'),
+33
View File
@@ -89,3 +89,36 @@ test('目录 fsync 失败不影响写入结果', () => {
}
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { ok: true });
});
test('writeBytes 原子覆盖二进制文件且不残留临时文件', () => {
const dir = fresh('bytes');
const dest = path.join(dir, 'book.epub');
fs.writeFileSync(dest, Buffer.from([1, 2, 3]));
atomic.writeBytes(dest, Buffer.from([4, 5, 0, 255]));
assert.deepStrictEqual([...fs.readFileSync(dest)], [4, 5, 0, 255]);
assert.strictEqual(fs.existsSync(`${dest}.tmp`), false);
assert.strictEqual(fs.existsSync(`${dest}.bak`), false);
});
test('writeBytes 替换失败时恢复旧二进制内容', () => {
const dir = fresh('bytes-rollback');
const dest = path.join(dir, 'book.epub');
fs.writeFileSync(dest, Buffer.from([1, 2, 3]));
const realRename = fs.renameSync;
let failed = false;
fs.renameSync = function failing(from, to) {
if (!failed && String(from) === `${dest}.tmp` && String(to) === dest) {
failed = true;
throw new Error('模拟二进制替换失败');
}
return realRename.apply(this, arguments);
};
try {
assert.throws(() => atomic.writeBytes(dest, Buffer.from([9, 9])), /模拟二进制替换失败/);
} finally {
fs.renameSync = realRename;
}
assert.deepStrictEqual([...fs.readFileSync(dest)], [1, 2, 3]);
assert.strictEqual(fs.existsSync(`${dest}.tmp`), false);
assert.strictEqual(fs.existsSync(`${dest}.bak`), false);
});
+30
View File
@@ -135,6 +135,36 @@ test('customMirrors 往返不丢失', () => {
assert.deepStrictEqual(auth.read().customMirrors, ['https://m1', 'https://m2']);
});
test('setCustomMirrors 不重写密文,凭据与会话都保留', () => {
const d = tmp();
const auth = freshAuth();
auth.init(d, fakeStorage());
auth.write({ email: 'a@b.c', password: 'Keep', userId: '1', userKey: 'k', mirror: 'https://m1' });
auth.setCustomMirrors(['https://m1', 'https://m2']);
assert.deepStrictEqual(auth.getCustomMirrors(), ['https://m1', 'https://m2']);
assert.strictEqual(auth.read().password, 'Keep', '改镜像不该动凭据');
assert.deepStrictEqual(auth.getSession(), { userId: '1', userKey: 'k', mirror: 'https://m1' });
auth.setCustomMirrors([]);
assert.deepStrictEqual(auth.getCustomMirrors(), []);
assert.strictEqual(auth.hasCreds(), true);
});
test('退出登录清掉凭据但保留自定义镜像', () => {
const d = tmp();
const auth = freshAuth();
auth.init(d, fakeStorage());
auth.write({ email: 'a@b.c', password: 'p', userId: '1', userKey: 'k' });
auth.setCustomMirrors(['https://mine.example']);
auth.clear();
assert.strictEqual(auth.hasCreds(), false, '凭据未清除');
assert.strictEqual(auth.getSession(), null, '会话未清除');
// 默认域名可能已全部失效,退出登录顺带清掉镜像会让用户无从登录
assert.deepStrictEqual(auth.getCustomMirrors(), ['https://mine.example']);
});
test('损坏的密文不影响会话字段读取', () => {
const d = tmp();
const auth = freshAuth();
+117 -4
View File
@@ -357,6 +357,66 @@ app.whenReady().then(async () => {
.every((title) => allTitles.includes(title))
&& (await js("document.getElementById('libStatus').textContent")) === '共 3 条'
);
const rescanMissingFile = path.join(TMP, 'rescan-missing.txt');
fs.writeFileSync(rescanMissingFile, 'rescan missing fixture');
const rescanMissingShelf = library.addShelf({ name: 'Rescan Missing Shelf' });
const rescanMissingBook = library.add({
title: 'Rescan Missing Book',
shelfId: rescanMissingShelf.id,
tags: ['Rescan Missing Tag'],
files: [{ path: rescanMissingFile, name: 'rescan-missing.txt', format: 'TXT' }]
});
await pollJs(
'缺失扫描夹具显示在书库',
`!!document.querySelector('#libGrid .card[data-id="${rescanMissingBook.id}"]')`
);
fs.unlinkSync(rescanMissingFile);
await js("document.getElementById('rescanBtn').click()");
await waitForModal('发现文件缺失');
check(
'重新扫描报告缺失数量并说明保留阅读资料',
await js(`(() => {
const text = document.getElementById('modalBody').textContent;
return text.includes('1 条书库内容的文件均已缺失')
&& text.includes('对应分类和标签也会一并清理')
&& text.includes('笔记、书签、进度和标注会保留');
})()`)
);
await js("document.getElementById('modalCancel').click()");
await pollJs(
'取消缺失清理后恢复扫描按钮',
"!document.getElementById('rescanBtn').disabled",
5000
);
check(
'取消清理保留缺失书库条目',
!!library.get(rescanMissingBook.id)
&& library.get(rescanMissingBook.id).missing
&& library.listShelves().some((shelf) => shelf.id === rescanMissingShelf.id)
&& library.listTags().some((tag) => tag.name === 'Rescan Missing Tag')
);
await js("document.getElementById('rescanBtn').click()");
await waitForModal('发现文件缺失');
await submitModal();
await poll(
'确认后清理缺失书库条目',
() => Promise.resolve(!library.get(rescanMissingBook.id))
);
await pollJs(
'缺失条目清理后书库恢复',
`!document.querySelector('#libGrid .card[data-id="${rescanMissingBook.id}"]')
&& document.querySelectorAll('#libGrid .card').length === 3`
);
check(
'缺失清理移除对应空分类和标签且不影响其余内容',
library.list().length === 3
&& library.list().every((item) => item.id !== rescanMissingBook.id)
&& !library.listShelves().some((shelf) => shelf.id === rescanMissingShelf.id)
&& !library.listTags().some((tag) => tag.name === 'Rescan Missing Tag')
);
await js(`(() => {
document.getElementById('librarySearchInput').value = 'Rsrch';
document.getElementById('librarySearchBtn').click();
@@ -509,6 +569,59 @@ app.whenReady().then(async () => {
);
readerWindow.destroy();
await wait(200);
check(
'分类侧栏显示全部、未分类和各书架的真实书籍数量',
await js(`(() => {
const all = document.querySelector('#libraryTab .library-filter[data-shelf=""]');
const uncategorized = document.querySelector(
'#libraryTab .library-filter[data-shelf="__uncategorized__"]'
);
const shelves = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'));
const research = shelves.find((button) => button.textContent.includes('研究书架'));
const review = shelves.find((button) => button.textContent.includes('待复核书架'));
const actionButtons = Array.from(
document.querySelectorAll('.library-shelf-actions .notes-icon-btn')
);
return all.textContent.trim() === '全部书籍(3'
&& uncategorized.textContent.trim() === '未分类(1'
&& research.textContent.trim() === '研究书架(1'
&& review.textContent.trim() === '待复核书架(1'
&& actionButtons.every((button) => {
const style = getComputedStyle(button);
return style.backgroundColor === 'rgba(0, 0, 0, 0)'
&& style.borderTopColor === 'rgba(0, 0, 0, 0)';
});
})()`)
);
const longShelfName = 'TheArtOfComputerProgramming超长分类名称';
library.updateShelf(researchShelf.id, { name: longShelfName });
await pollJs(
'超长分类名称刷新',
`Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.some((button) => button.firstElementChild.textContent === ${JSON.stringify(longShelfName)})`
);
check(
'超长分类只省略名称并完整保留数量与操作区',
await js(`(() => {
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.find((candidate) => candidate.firstElementChild.textContent === ${JSON.stringify(longShelfName)});
const name = button.firstElementChild;
const count = button.querySelector('.library-filter-count');
const actions = button.closest('.library-shelf-row').querySelector('.library-shelf-actions');
return getComputedStyle(button).display === 'flex'
&& getComputedStyle(name).textOverflow === 'ellipsis'
&& name.scrollWidth > name.clientWidth
&& count.textContent === '1'
&& count.getBoundingClientRect().width > 0
&& count.getBoundingClientRect().right <= actions.getBoundingClientRect().left + 1;
})()`)
);
library.updateShelf(researchShelf.id, { name: researchShelf.name });
await pollJs(
'恢复研究书架名称',
`Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.some((button) => button.firstElementChild.textContent === ${JSON.stringify(researchShelf.name)})`
);
check(
'标签侧栏显示真实聚合计数',
await js(`(() => {
@@ -572,7 +685,7 @@ app.whenReady().then(async () => {
await js(`(() => {
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.find((candidate) => candidate.textContent.trim() === '研究书架');
.find((candidate) => candidate.firstElementChild.textContent.trim() === '研究书架');
button.click();
})()`);
await pollJs(
@@ -621,7 +734,7 @@ app.whenReady().then(async () => {
!!uiShelf
&& await js(`(() => {
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.find((candidate) => candidate.textContent.trim() === '界面书架');
.find((candidate) => candidate.firstElementChild.textContent.trim() === '界面书架');
return !!button && button.classList.contains('active')
&& document.getElementById('libStatus').textContent === '显示 0 条,共 3 条';
})()`)
@@ -723,7 +836,7 @@ app.whenReady().then(async () => {
await js(`(() => {
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.find((candidate) => candidate.textContent.trim() === '界面书架已重命名');
.find((candidate) => candidate.firstElementChild.textContent.trim() === '界面书架已重命名');
button.click();
})()`);
await pollJs(
@@ -799,7 +912,7 @@ app.whenReady().then(async () => {
// 否则批量操作会误伤用户看不到的书
await js(`(() => {
const button = Array.from(document.querySelectorAll('#libraryShelfList .library-filter'))
.find((candidate) => candidate.textContent.trim() === '研究书架');
.find((candidate) => candidate.firstElementChild.textContent.trim() === '研究书架');
button.click();
})()`);
await pollJs(
+139 -2
View File
@@ -1,4 +1,5 @@
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const { app, BrowserWindow } = require('electron');
@@ -9,6 +10,7 @@ app.setPath('appData', TMP);
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true';
const results = [];
let imageServer = null;
function check(name, pass, detail) {
results.push([pass ? 'OK' : 'FAIL', name, detail || '']);
}
@@ -36,6 +38,31 @@ function printSummary() {
}
app.whenReady().then(async () => {
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
);
imageServer = http.createServer((_request, response) => {
response.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': png.length });
response.end(png);
});
await new Promise((resolve) => imageServer.listen(0, '127.0.0.1', resolve));
const imageUrl = `http://127.0.0.1:${imageServer.address().port}/page.png`;
const mangaDex = require(path.join(ROOT, 'src', 'sources', 'mangadex'));
mangaDex.chapters = async () => ({
items: [
{ chapterId: 'online-c1', label: '在线第 1 话', pages: 8 },
{ chapterId: 'online-c2', label: '在线第 2 话', pages: 8 }
],
page: 1,
maxPage: 1
});
mangaDex.chapterImageUrls = async () => ({
urls: Array(8).fill(imageUrl),
mustReport: false,
quality: 'dataSaver'
});
const library = require(path.join(ROOT, 'src', 'library', 'store'));
let scanStartedAt = 0;
library.scan = () => {
@@ -57,29 +84,139 @@ app.whenReady().then(async () => {
const configReadyMs = Date.now() - startedAt;
check('慢速书库扫描不会阻塞窗口配置加载', configReadyMs < 1200, `${configReadyMs}ms`);
const proxyResult = await win.webContents.executeJavaScript(
`window.api.proxy.set('http://127.0.0.1:9')`
);
const proxyRoute = await app.whenReady().then(() => (
require('electron').session.defaultSession.resolveProxy('https://example.com/')
));
check('保存代理后 Chromium 会话已切换到新代理',
proxyResult && proxyResult.ok && /PROXY 127\.0\.0\.1:9/i.test(proxyRoute),
proxyRoute);
await win.webContents.executeJavaScript(`window.api.proxy.set('')`);
await win.webContents.executeJavaScript(`document.getElementById('zlibMirrorBtn').click()`);
await waitUntil(() => win.webContents.executeJavaScript(
`!!document.getElementById('zlibMirrorList')`
));
await win.webContents.executeJavaScript(`(() => {
document.getElementById('zlibMirrorList').value =
'https://mirror.example/path\\nhttps://second.example/';
document.getElementById('modalOk').click();
})()`);
const mirrorConfig = await waitUntil(() => win.webContents.executeJavaScript(`(async () => {
const result = await window.api.zlib.getMirrors();
const status = document.getElementById('zlibMirrorStatus').textContent;
return result.ok && result.data.custom.length === 2 && /已自定义 2 个镜像/.test(status)
? { custom: result.data.custom, status }
: null;
})()`));
check('Z-Library 账户设置可保存并优先使用自定义镜像',
JSON.stringify(mirrorConfig.custom) === JSON.stringify([
'https://mirror.example',
'https://second.example'
]),
JSON.stringify(mirrorConfig));
const sourceRows = await waitUntil(() => win.webContents.executeJavaScript(`(() => {
const rows = Array.from(document.querySelectorAll('#sourceList input[data-id]')).map((input) => ({
id: input.dataset.id,
checked: input.checked,
name: input.parentElement.querySelector('span').textContent
}));
return rows.length >= 16 ? rows : null;
return rows.length >= 18 ? rows : null;
})()`));
const expectedSources = ['openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en'];
const expectedSources = [
'openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en',
'mangadex', 'copymanga'
];
check('新增开放书源出现在设置页且新安装默认启用',
expectedSources.every((id) => sourceRows.some((row) => row.id === id && row.checked)),
sourceRows.filter((row) => expectedSources.includes(row.id)).map((row) => `${row.id}:${row.name}`).join(', '));
const sourceGroups = await waitUntil(() => win.webContents.executeJavaScript(`(() => {
const labels = Array.from(document.querySelectorAll('#sourceSelect optgroup')).map((group) => group.label);
return labels.includes('漫画') ? labels : null;
})()`));
check('检索书源下拉框按内容类型分组',
['学术论文', '电子书', '开放教材与文库', '档案与特藏', '漫画']
.every((label) => sourceGroups.includes(label)),
sourceGroups.join(', '));
const mangaLanguage = await win.webContents.executeJavaScript(
`document.getElementById('mangadexLanguageSelect').value`
);
check('MangaDex 新安装默认只显示简体中文章节', mangaLanguage === 'zh', mangaLanguage);
await waitUntil(() => scanStartedAt > 0, 7000);
check('启动维护在首屏完成后延迟执行', scanStartedAt - startedAt >= 1400,
`${scanStartedAt - startedAt}ms`);
const beforeOnline = await win.webContents.executeJavaScript(
`window.api.library.list().then((result) => result.data.length)`
);
const openedOnline = await win.webContents.executeJavaScript(`window.api.sources.readOnline('mangadex', {
mangaId: 'online-manga',
title: '在线阅读夹具',
authors: ['测试作者'],
initialChapterId: 'online-c2',
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.getElementById('chapterTitle').textContent === '在线第 2 话'
&& Array.from(document.querySelectorAll('.manga-page img'))
.filter((image) => image.naturalWidth > 0).length >= 4`
));
} catch (error) { /* 由下面的诊断状态报告 */ }
const onlineState = await onlineWindow.webContents.executeJavaScript(`(() => ({
status: document.getElementById('readerStatus').textContent,
statusHidden: document.getElementById('readerStatus').classList.contains('hidden'),
chapterTitle: document.getElementById('chapterTitle').textContent,
chapters: document.querySelectorAll('.chapter-item').length,
pages: document.querySelectorAll('.manga-page').length,
scrollTop: document.getElementById('pageScroller').scrollTop,
pageState: document.querySelector('.manga-page')?.dataset.state || '',
pageText: document.querySelector('.manga-page')?.dataset.placeholder || ''
}))()`);
check('在线阅读器读取整部章节目录并通过主进程显示图片',
!!firstChapter, JSON.stringify(onlineState));
check('章节阅读入口会直接打开指定章节',
firstChapter && onlineState.chapterTitle === '在线第 2 话', JSON.stringify(onlineState));
check('漫画长图异步展开时停留在章节第一页',
firstChapter && onlineState.scrollTop === 0, JSON.stringify(onlineState));
let previousChapter = false;
if (firstChapter) {
await onlineWindow.webContents.executeJavaScript(`document.getElementById('prevChapterBtn').click()`);
previousChapter = await waitUntil(() => onlineWindow.webContents.executeJavaScript(`(() => {
const image = document.querySelector('.manga-page img');
return document.getElementById('chapterTitle').textContent === '在线第 1 话'
&& image && image.naturalWidth > 0;
})()`));
}
check('在线阅读器可连续切换到上一章', previousChapter);
const afterOnline = await win.webContents.executeJavaScript(
`window.api.library.list().then((result) => result.data.length)`
);
check('在线阅读不会创建书库条目', afterOnline === beforeOnline,
`${beforeOnline}${afterOnline}`);
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.destroy();
}
await new Promise((resolve) => imageServer.close(resolve));
const failed = printSummary();
app.exit(failed ? 1 : 0);
}).catch((error) => {
if (imageServer) imageServer.close();
console.error('异常:', error);
check('启动验证未发生异常', false, error.message || String(error));
for (const window of BrowserWindow.getAllWindows()) {
+55
View File
@@ -335,6 +335,61 @@ test('批量移除只写一次索引,可选删除库内文件', () => {
assert.deepStrictEqual(store.removeMany(['missing'], false), { removed: 0 });
});
test('清理缺失条目时一并清理对应的空书架和标签', () => {
const root = freshRoot('remove-missing');
const emptyShelf = store.addShelf({ name: '仅缺失书籍分类' });
const sharedShelf = store.addShelf({ name: '仍在使用分类' });
const unrelatedShelf = store.addShelf({ name: '无关空分类' });
store.addTag({ name: '仅缺失书籍标签' });
store.addTag({ name: '仍在使用标签' });
store.addTag({ name: '无关空标签' });
const missingA = store.add({
title: '缺失 A',
shelfId: emptyShelf.id,
tags: ['仅缺失书籍标签', '仍在使用标签'],
files: [{ path: path.join(root, 'missing-a.pdf') }]
});
const missingB = store.add({
title: '缺失 B',
shelfId: sharedShelf.id,
tags: ['仍在使用标签'],
files: [{ path: path.join(root, 'missing-b.pdf') }]
});
const existingPath = path.join(root, 'existing.pdf');
fs.writeFileSync(existingPath, '%PDF-1.4\n');
const existing = store.add({
title: '仍存在',
shelfId: sharedShelf.id,
tags: ['仍在使用标签'],
files: [{ path: existingPath }]
});
const noFiles = store.add({ title: '无文件元数据', files: [] });
assert.deepStrictEqual(store.removeMissing(), {
removed: 2,
shelvesRemoved: 1,
tagsRemoved: 1
});
assert.strictEqual(store.get(missingA.id), null);
assert.strictEqual(store.get(missingB.id), null);
assert.ok(store.get(existing.id));
assert.ok(store.get(noFiles.id), '没有文件的元数据条目不应按文件缺失清理');
assert.deepStrictEqual(
store.listShelves().map((shelf) => shelf.name).sort(),
[sharedShelf.name, unrelatedShelf.name].sort()
);
assert.deepStrictEqual(
store.listTags().map((tag) => tag.name).sort(),
['仍在使用标签', '无关空标签'].sort()
);
assert.deepStrictEqual(store.removeMissing(), {
removed: 0,
shelvesRemoved: 0,
tagsRemoved: 0
});
});
test('explicit tag creation enforces the existing catalog limit', () => {
const root = freshRoot('limit');
const now = Date.now();
+162 -1
View File
@@ -11,6 +11,12 @@ const { compareVersion } = h.extractFns(
mainFile, 'function parseVersion', 'async function checkUpdate', ['compareVersion']
);
const { wrap } = h.extractFns(mainFile, 'function wrap(', '// 数据源', ['wrap']);
const { applySessionProxy } = h.extractFns(
mainFile, 'async function applySessionProxy', 'app.whenReady()', ['applySessionProxy']
);
const { withTimeout } = h.extractFns(
mainFile, 'function withTimeout', 'const SOURCE_TIMEOUT_MS', ['withTimeout']
);
test('wrap 捕获同步抛出,不让 invoke reject', async () => {
const r = await wrap(() => { throw new Error('未知数据源: nope'); });
@@ -43,6 +49,130 @@ test('所有 IPC handler 都通过 thunk 调用 wrap', () => {
assert.deepStrictEqual(bare, [], '存在绕过 wrap 的 handler: ' + bare);
});
test('withTimeout 让永不落定的等待变成可读错误', async () => {
await assert.rejects(
withTimeout(new Promise(() => {}), 30, '数据源长时间无响应'),
/数据源长时间无响应/
);
assert.strictEqual(await withTimeout(Promise.resolve('ok'), 1000, '不该超时'), 'ok');
await assert.rejects(
withTimeout(Promise.reject(new Error('镜像返回了非预期内容')), 1000, '不该超时'),
/镜像返回了非预期内容/
);
});
test('withTimeout 在落定后清掉定时器,不吊住进程', async () => {
const timers = [];
const originalSetTimeout = global.setTimeout;
const originalClearTimeout = global.clearTimeout;
global.setTimeout = (fn, ms) => {
const handle = originalSetTimeout(fn, ms);
timers.push(handle);
return handle;
};
let cleared = 0;
global.clearTimeout = (handle) => { cleared += 1; return originalClearTimeout(handle); };
try {
await withTimeout(Promise.resolve(1), 60000, '不该超时');
} finally {
global.setTimeout = originalSetTimeout;
global.clearTimeout = originalClearTimeout;
for (const handle of timers) originalClearTimeout(handle);
}
assert.strictEqual(cleared, 1, '超时定时器没有被清理');
});
test('数据源 IPC 带总时限,永不落定也会回包', () => {
const source = mainSrc.slice(
mainSrc.indexOf('function sourceCall'),
mainSrc.indexOf("ipcMain.handle('source:readOnline'")
);
assert.match(source, /withTimeout\(\s*Promise\.resolve\(\)\.then\(fn\),\s*SOURCE_TIMEOUT_MS/);
for (const channel of ['source:list', 'source:search', 'source:detail', 'source:download', 'source:chapters']) {
const line = new RegExp(`ipcMain\\.handle\\('${channel}'[^\\n]*`).exec(mainSrc);
assert.ok(line, `缺少 ${channel} handler`);
assert.match(line[0], /sourceCall\(/, `${channel} 没有总时限,界面会卡在加载中`);
}
});
test('Z-Library 登录页内请求与注入求值都有超时', () => {
const start = mainSrc.indexOf('async function browserZlibLogin');
const end = mainSrc.indexOf('// Z-Library 凭据', start);
const segment = mainSrc.slice(start, end);
assert.match(segment, /signal: AbortSignal\.timeout\(\$\{ZLIB_LOGIN_FETCH_MS\}\)/,
'页内 fetch 没有超时,代理不通时会永远挂着');
assert.match(segment, /await withTimeout\(\s*win\.webContents\.executeJavaScriptInIsolatedWorld/,
'executeJavaScript 在页面卡死时不会 reject,必须外挂超时');
});
test('Chromium 代理切换完成并关闭旧连接后才继续请求', async () => {
const calls = [];
const fakeSession = {
setProxy: async (config) => { calls.push(['setProxy', config.proxyRules]); },
closeAllConnections: async () => { calls.push(['closeAllConnections']); }
};
await applySessionProxy(fakeSession, 'http://127.0.0.1:7890');
assert.deepStrictEqual(calls, [
['setProxy', 'http://127.0.0.1:7890'],
['closeAllConnections']
]);
calls.length = 0;
await applySessionProxy(fakeSession, '');
assert.deepStrictEqual(calls[0], ['setProxy', 'direct://']);
});
test('启动与保存设置都会等待 Chromium 代理生效', () => {
const startup = mainSrc.slice(
mainSrc.indexOf('app.whenReady().then'),
mainSrc.indexOf("app.on('window-all-closed'")
);
assert.match(startup, /app\.whenReady\(\)\.then\(async \(\) =>/);
assert.ok(
startup.indexOf('await applySessionProxy(session.defaultSession, getProxy())')
< startup.indexOf('createWindow()'),
'主窗口在 Chromium 代理生效前创建'
);
const proxyHandler = mainSrc.slice(
mainSrc.indexOf("ipcMain.handle('proxy:set'"),
mainSrc.indexOf('function zlibOrigin')
);
assert.match(proxyHandler, /wrap\(async \(\) =>/);
assert.match(proxyHandler, /await applySessionProxy\(session\.defaultSession, u\)/);
assert.ok(
proxyHandler.indexOf('await applySessionProxy(session.defaultSession, u)')
< proxyHandler.indexOf("settings.set('proxy', u)"),
'代理尚未应用成功就提前持久化'
);
});
test('章节制数据源 IPC 通过源能力下载并按请求隔离进度', () => {
const start = mainSrc.indexOf("ipcMain.handle('source:chapters'");
const end = mainSrc.indexOf('// 代理配置', start);
const segment = mainSrc.slice(start, end);
assert.ok(start >= 0 && end > start, '缺少章节制数据源 IPC');
assert.match(segment, /source\.chapters\(postId, page, options \|\| \{\}\)/);
assert.match(segment, /source\.downloadChapter\(library, payload \|\| \{\}, sendProgress\)/);
assert.match(segment, /requestId:\s*progressId/);
assert.match(segment, /event\.sender\.isDestroyed\(\)/);
assert.match(segment, /event\.sender\.send\('source:chapterProgress'/);
});
test('在线漫画使用独立窗口、sender 绑定会话和主进程图片代理', () => {
const start = mainSrc.indexOf("ipcMain.handle('source:readOnline'");
const end = mainSrc.indexOf('// 代理配置', start);
const segment = mainSrc.slice(start, end);
assert.ok(start >= 0 && end > start, '缺少在线漫画 IPC');
assert.match(segment, /event\.sender\.id !== mainWindow\.webContents\.id/);
assert.match(segment, /mangaOnlineWindow\.create\(__dirname, currentUiTheme\)/);
assert.match(segment, /mangaOnlineSessions\.create\(ownerId, source, input \|\| \{\}\)/);
assert.match(segment, /mangaOnlineWindow\.fromWebContents\(event\.sender\)/);
for (const channel of ['meta', 'chapters', 'chapterManifest', 'image', 'close']) {
assert.match(segment, new RegExp(`ipcMain\\.handle\\('mangaOnline:${channel}'`));
}
});
test('版本比较:预发布版本低于同号正式版', () => {
assert.strictEqual(compareVersion('1.1.0', '1.1.0-beta'), 1);
assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0'), -1);
@@ -73,7 +203,7 @@ test('下载校验协议,拒绝 file:// 等非 http(s)', () => {
test('下载请求使用可识别且带项目地址的 User-Agent', () => {
assert.match(mainSrc, /UA:\s*DL_UA[\s\S]+require\('\.\/src\/sources\/http'\)/);
const httpSrc = fs.readFileSync(path.join(__dirname, '..', 'sources', 'http.js'), 'utf8');
assert.match(httpSrc, /PeopleLib\/2\.1\.0 \(\+https:\/\/github\.com\/lofyer\/peoplelib\)/);
assert.match(httpSrc, /PeopleLib\/2\.1\.3 \(\+https:\/\/github\.com\/lofyer\/peoplelib\)/);
});
test('移除书籍默认保留阅读资料,仅显式勾选时清理', () => {
@@ -108,6 +238,18 @@ test('批量整理与移除走单次索引提交,不按条目循环 IPC', () =
'渲染层不应再逐条发 IPC');
});
test('重新扫描清理缺失条目前在主进程重新核对磁盘状态', () => {
const start = mainSrc.indexOf("ipcMain.handle('library:removeMissing'");
assert.ok(start > 0, '缺少文件缺失清理通道');
const end = mainSrc.indexOf('ipcMain.handle(', start + 20);
const segment = mainSrc.slice(start, end < 0 ? undefined : end);
assert.match(segment, /wrap\(async \(\) =>/);
assert.match(segment, /library\.list\(\)[\s\S]*\.filter\(\(item\) => item\.missing\)/);
assert.match(segment, /await requestReaderPurge\(id\)/);
assert.match(segment, /library\.removeMissing\(\)/);
assert.doesNotMatch(segment, /readerStore\.forget|annotations\.forget|aiSessions\.forget/);
});
test('孤立阅读资料只报告不自动删除,清理需分别勾选', () => {
const start = mainSrc.indexOf("ipcMain.handle('reader:orphanReport'");
assert.ok(start > 0, '缺少孤立资料对账通道');
@@ -330,6 +472,25 @@ test('标准构建入口固定输出目录并保留便携数据', () => {
assert.doesNotMatch(build, /\$\{PRODUCT\}-\$\{pkg\.version\}/);
});
test('所有构建与发布入口都包含在线漫画 preload', () => {
const root = path.join(__dirname, '..', '..');
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const portable = fs.readFileSync(path.join(root, 'build-portable.js'), 'utf8');
const mac = fs.readFileSync(path.join(root, 'build-mac.js'), 'utf8');
const linux = fs.readFileSync(path.join(root, 'build-linux.js'), 'utf8');
const release = fs.readFileSync(path.join(root, 'build-release.js'), 'utf8');
assert.ok(pkg.build.files.includes('manga-online-preload.js'));
for (const [name, source] of [['Windows', portable], ['macOS', mac], ['Linux', linux]]) {
assert.match(source, /manga-online-preload\.js/, `${name} 构建漏掉在线漫画 preload`);
}
assert.strictEqual(
(release.match(/resources\/app\/manga-online-preload\.js/g) || []).length,
2,
'Windows 与 Linux 发布件回读都必须检查在线漫画 preload'
);
});
function loadPathResolvers(platform, isPackaged) {
return h.extractFns(
mainFile,
+281
View File
@@ -0,0 +1,281 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const h = require('./helpers');
h.installFetchStub();
const store = require('../library/store');
const mangaDownload = require('../library/manga-download');
const mangaEpub = require('../library/manga-epub');
const roots = [];
function freshRoot(tag) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-manga-${tag}-`));
roots.push(root);
store.init(root);
return root;
}
function installImageHandler() {
h.setHandler((url) => {
const name = new URL(url).pathname.split('/').at(-1);
const byte = name.startsWith('one') ? 1 : 2;
const data = Buffer.from([byte, byte, byte]);
return {
ok: true,
status: 200,
headers: { get: () => null },
arrayBuffer: async () => data
};
});
}
function sourceFor(files) {
return {
id: 'mangadex',
chapterImageUrls: async () => ({
urls: files.map((name) => `https://uploads.mangadex.org/data/hash/${name}`),
mustReport: false
})
};
}
function payload(chapterId, chapter) {
return {
mangaId: 'manga-1',
title: '测试漫画',
authors: ['作者'],
originalLanguage: 'ja',
chapterId,
chapter,
label: `${chapter}`,
translatedLanguage: 'zh',
quality: 'dataSaver'
};
}
test.after(() => {
for (const root of roots) {
try { fs.rmSync(root, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
test('fetchChapterImages: 保持页序并上报准确进度', async () => {
installImageHandler();
const progress = [];
const result = await mangaDownload.fetchChapterImages(
sourceFor(['one.jpg', 'two.png']),
'chapter-1',
'dataSaver',
(done, total) => progress.push([done, total])
);
assert.deepStrictEqual(result.map((image) => image.ext), ['jpg', 'png']);
assert.deepStrictEqual([...result[0].data], [1, 1, 1]);
assert.deepStrictEqual([...result[1].data], [2, 2, 2]);
assert.deepStrictEqual(progress, [[1, 2], [2, 2]]);
});
test('fetchChapterImages: 失败页换节点重试,进度只按成功页单调增加', async () => {
let sourceCalls = 0;
h.setHandler((url) => {
if (url.includes('old-one.jpg')) {
return {
ok: false,
status: 500,
headers: { get: () => null },
body: { cancel: async () => {} }
};
}
const data = Buffer.from(url.includes('one.jpg') ? [1] : [2]);
return {
ok: true,
status: 200,
headers: { get: () => null },
arrayBuffer: async () => data
};
});
const source = {
chapterImageUrls: async () => {
sourceCalls++;
const prefix = sourceCalls === 1 ? 'old-' : 'fresh-';
return {
urls: [
`https://uploads.mangadex.org/data/hash/${prefix}one.jpg`,
`https://uploads.mangadex.org/data/hash/${prefix}two.jpg`
],
mustReport: false
};
}
};
const progress = [];
const result = await mangaDownload.fetchChapterImages(
source,
'chapter-1',
'dataSaver',
(done, total) => progress.push([done, total])
);
assert.strictEqual(sourceCalls, 2);
assert.deepStrictEqual(progress, [[1, 2], [2, 2]]);
assert.deepStrictEqual(result.map((image) => [...image.data]), [[1], [2]]);
});
test('fetchChapterImages: @Home 图片上报完成后才返回,且上报体包含传输指标', async () => {
let reportBody = null;
let reportFinished = false;
h.setHandler((url, options) => {
if (url === 'https://api.mangadex.network/report') {
reportBody = JSON.parse(options.body);
return {
ok: true,
status: 200,
headers: { get: () => null },
body: { cancel: async () => { reportFinished = true; } }
};
}
const data = Buffer.from([7, 8, 9]);
return {
ok: true,
status: 200,
headers: { get: (name) => name.toLowerCase() === 'x-cache' ? 'HIT' : null },
arrayBuffer: async () => data
};
});
const source = {
chapterImageUrls: async () => ({
urls: ['https://node.example.test/data/hash/one.jpg'],
mustReport: true
})
};
await mangaDownload.fetchChapterImages(source, 'chapter-1', 'dataSaver');
assert.strictEqual(reportFinished, true);
assert.strictEqual(reportBody.url, 'https://node.example.test/data/hash/one.jpg');
assert.strictEqual(reportBody.success, true);
assert.strictEqual(reportBody.bytes, 3);
assert.strictEqual(reportBody.cached, true);
assert.ok(Number.isFinite(reportBody.duration));
});
test('downloadChapter: 首章建库,后续章节追加到同一 EPUB', async () => {
freshRoot('append');
installImageHandler();
const source = sourceFor(['one.jpg']);
const first = await mangaDownload.downloadChapter(source, store, payload('chapter-1', '1'));
assert.strictEqual(first.created, true);
assert.strictEqual(store.list().length, 1);
const second = await mangaDownload.downloadChapter(source, store, payload('chapter-2', '2'));
assert.strictEqual(second.created, false);
assert.strictEqual(second.entry.id, first.entry.id);
assert.strictEqual(store.list().length, 1);
const epub = second.entry.files.find((file) => /\.epub$/i.test(file.path));
assert.ok(epub && epub.exists);
assert.deepStrictEqual(
mangaEpub.listChapters(fs.readFileSync(epub.path)).map((chapter) => chapter.chapterId),
['chapter-1', 'chapter-2']
);
});
test('downloadChapter: 同一漫画并发下载会串行合并,不产生重复书库条目', async () => {
freshRoot('concurrent');
h.setHandler(() => ({
ok: true,
status: 200,
headers: { get: () => null },
arrayBuffer: async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return Buffer.from([1, 2, 3]);
}
}));
const source = sourceFor(['one.jpg']);
const [first, second] = await Promise.all([
mangaDownload.downloadChapter(source, store, payload('chapter-1', '1')),
mangaDownload.downloadChapter(source, store, payload('chapter-2', '2'))
]);
assert.strictEqual(first.entry.id, second.entry.id);
assert.strictEqual(store.list().length, 1);
const epub = store.list()[0].files.find((file) => /\.epub$/i.test(file.path));
assert.deepStrictEqual(
mangaEpub.listChapters(fs.readFileSync(epub.path)).map((chapter) => chapter.chapterId),
['chapter-1', 'chapter-2']
);
});
test('downloadChapter: 已下载章节不会重复抓图', async () => {
freshRoot('dedupe');
installImageHandler();
const source = sourceFor(['one.jpg']);
await mangaDownload.downloadChapter(source, store, payload('chapter-1', '1'));
let calls = 0;
const countingSource = {
id: 'mangadex',
chapterImageUrls: async () => {
calls++;
return source.chapterImageUrls();
}
};
await assert.rejects(
mangaDownload.downloadChapter(countingSource, store, payload('chapter-1', '1')),
/该章节已下载/
);
assert.strictEqual(calls, 0);
});
test('downloadChapter: 已有条目只有非 EPUB 文件时新建 EPUB 而不误解析旧文件', async () => {
const root = freshRoot('non-epub');
installImageHandler();
const pdf = path.join(root, 'files', 'old.pdf');
fs.writeFileSync(pdf, '%PDF-test');
const entry = store.add({
title: '测试漫画',
sourceId: 'mangadex',
sourcePostId: 'manga-1',
files: [{ path: pdf, name: 'old.pdf', format: 'PDF' }]
});
const result = await mangaDownload.downloadChapter(
sourceFor(['one.jpg']),
store,
payload('chapter-1', '1')
);
assert.strictEqual(result.entry.id, entry.id);
assert.strictEqual(store.list().length, 1);
assert.strictEqual(result.entry.files.filter((file) => /\.epub$/i.test(file.path)).length, 1);
assert.strictEqual(result.entry.files.filter((file) => /\.pdf$/i.test(file.path)).length, 1);
});
test('downloadChapter: 已有普通 EPUB 时保留原文件并另建可合并的漫画 EPUB', async () => {
const root = freshRoot('plain-epub');
installImageHandler();
const plain = path.join(root, 'files', 'plain.epub');
fs.writeFileSync(plain, 'not a manga epub');
const entry = store.add({
title: '测试漫画',
sourceId: 'mangadex',
sourcePostId: 'manga-1',
files: [{ path: plain, name: 'plain.epub', format: 'EPUB' }]
});
const result = await mangaDownload.downloadChapter(
sourceFor(['one.jpg']),
store,
payload('chapter-1', '1')
);
assert.strictEqual(result.entry.id, entry.id);
const epubs = result.entry.files.filter((file) => /\.epub$/i.test(file.path));
assert.strictEqual(epubs.length, 2);
const generated = epubs.find((file) => file.path !== plain);
assert.strictEqual(mangaEpub.isMangaEpub(fs.readFileSync(generated.path), 'manga-1'), true);
});
test('extFromUrl: 忽略查询参数并为无扩展名地址回退到 jpg', () => {
assert.strictEqual(mangaDownload.extFromUrl('https://x.test/p.webp?token=1'), 'webp');
assert.strictEqual(mangaDownload.extFromUrl('https://x.test/image'), 'jpg');
});
+155
View File
@@ -0,0 +1,155 @@
const test = require('node:test');
const assert = require('node:assert');
const mangaEpub = require('../library/manga-epub');
const zip = require('../zip');
function img(byte, ext) {
return { ext: ext || 'jpg', data: Buffer.from([byte, byte, byte]) };
}
test('createMangaEpub: 产出的 zip 含标准 EPUB 骨架与章节旁路索引', () => {
const { bytes, chapterCount, pageCount } = mangaEpub.createMangaEpub({
title: '测试漫画',
mangaId: 'manga-1',
originalLanguage: 'ja',
chapterId: 'ch-1',
label: '第 1 话',
volume: '1',
chapter: '1',
translatedLanguage: 'zh',
group: '汉化组',
images: [img(1), img(2), img(3)]
});
assert.strictEqual(chapterCount, 1);
assert.strictEqual(pageCount, 3);
const entries = zip.readZip(bytes);
assert.ok(entries.has('mimetype'));
assert.strictEqual(entries.get('mimetype').toString('utf8'), 'application/epub+zip');
assert.ok(entries.has('META-INF/container.xml'));
assert.ok(entries.has('content.opf'));
assert.ok(entries.has('nav.xhtml'));
assert.ok(entries.has('images/ch-1/0001.jpg'));
assert.ok(entries.has('chapters/ch-1/p0001.xhtml'));
const meta = JSON.parse(entries.get('peoplelib/chapters.json').toString('utf8'));
assert.strictEqual(meta.mangaId, 'manga-1');
assert.strictEqual(meta.chapters.length, 1);
assert.strictEqual(meta.chapters[0].chapterId, 'ch-1');
assert.strictEqual(meta.chapters[0].pageCount, 3);
});
test('appendMangaChapter: 合并新章节,保留旧章节图片字节不变,按卷/话排序', () => {
const first = mangaEpub.createMangaEpub({
title: '测试漫画',
mangaId: 'manga-1',
originalLanguage: 'ja',
chapterId: 'ch-2',
label: '第 2 话',
volume: '1',
chapter: '2',
images: [img(10)]
});
const { bytes } = mangaEpub.appendMangaChapter(first.bytes, {
title: '测试漫画',
mangaId: 'manga-1',
originalLanguage: 'ja',
chapterId: 'ch-1',
label: '第 1 话',
volume: '1',
chapter: '1',
images: [img(20), img(21)]
});
const entries = zip.readZip(bytes);
assert.ok(entries.has('images/ch-1/0001.jpg'));
assert.ok(entries.has('images/ch-2/0001.jpg'));
// 旧章节图片字节原样保留,未被重新编码
assert.deepStrictEqual([...entries.get('images/ch-2/0001.jpg')], [10, 10, 10]);
const list = mangaEpub.listChapters(bytes);
assert.strictEqual(list.length, 2);
// 按 volume/chapter 排序:话 1 应排在话 2 前面,尽管是后追加的
assert.strictEqual(list[0].chapterId, 'ch-1');
assert.strictEqual(list[1].chapterId, 'ch-2');
});
test('appendMangaChapter: 追加同一章节 ID 时抛出可读错误', () => {
const first = mangaEpub.createMangaEpub({
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
chapterId: 'ch-1', label: '第 1 话', images: [img(1)]
});
assert.throws(
() => mangaEpub.appendMangaChapter(first.bytes, {
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
chapterId: 'ch-1', label: '重复章节', images: [img(2)]
}),
/该章节已存在/
);
});
test('appendMangaChapter: 追加到不同漫画的 EPUB 会被拒绝', () => {
const first = mangaEpub.createMangaEpub({
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
chapterId: 'ch-1', label: '第 1 话', images: [img(1)]
});
assert.throws(
() => mangaEpub.appendMangaChapter(first.bytes, {
title: '另一部漫画', mangaId: 'manga-2', originalLanguage: 'ja',
chapterId: 'ch-9', label: '第 9 话', images: [img(2)]
}),
/不是同一部漫画/
);
});
test('appendMangaChapter: 目标不是漫画 EPUB(缺少旁路索引)时给出可操作提示', () => {
const plain = zip.writeZip([{ name: 'mimetype', data: Buffer.from('application/epub+zip') }]);
assert.throws(
() => mangaEpub.appendMangaChapter(plain, {
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
chapterId: 'ch-1', label: '第 1 话', images: [img(1)]
}),
/请改为新建条目/
);
});
test('hasChapter: 命中已收录章节,未收录或非法数据都返回 false', () => {
const { bytes } = mangaEpub.createMangaEpub({
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
chapterId: 'ch-1', label: '第 1 话', images: [img(1)]
});
assert.strictEqual(mangaEpub.hasChapter(bytes, 'ch-1'), true);
assert.strictEqual(mangaEpub.hasChapter(bytes, 'ch-missing'), false);
assert.strictEqual(mangaEpub.hasChapter(Buffer.from('not a zip'), 'ch-1'), false);
});
test('compareChapters: 缺省卷号/话号排到最后', () => {
const withNum = { volume: '1', chapter: '1' };
const withoutNum = { volume: '', chapter: '' };
assert.ok(mangaEpub.compareChapters(withNum, withoutNum) < 0);
assert.ok(mangaEpub.compareChapters(withoutNum, withNum) > 0);
});
test('漫画 EPUB 用 sourceId 隔离不同站点的同名 ID', () => {
const created = mangaEpub.createMangaEpub({
title: '中文漫画',
sourceId: 'copymanga',
mangaId: 'same-id',
originalLanguage: 'zh',
chapterId: 'chapter-1',
label: '第 1 话',
images: [img(1)]
});
const parsed = mangaEpub.parseMangaEpub(created.bytes);
assert.strictEqual(parsed.sourceId, 'copymanga');
assert.strictEqual(mangaEpub.isMangaEpub(created.bytes, 'same-id', 'copymanga'), true);
assert.strictEqual(mangaEpub.isMangaEpub(created.bytes, 'same-id', 'mangadex'), false);
assert.throws(() => mangaEpub.appendMangaChapter(parsed, {
title: '另一来源',
sourceId: 'mangadex',
mangaId: 'same-id',
chapterId: 'chapter-2',
label: '第 2 话',
images: [img(2)]
}), /不是同一部漫画/);
});
+148
View File
@@ -0,0 +1,148 @@
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: '在线漫画',
initialChapterId: 'c1',
language: 'zh-hk',
quality: 'data'
});
assert.strictEqual(created.title, '在线漫画');
assert.strictEqual(created.initialChapterId, 'c1');
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);
});
+381 -1
View File
@@ -16,6 +16,24 @@ test('注册表:每个源都实现完整接口', () => {
}
assert.ok(s.name, `${s.id} 缺 name`);
}
assert.strictEqual(list.find((source) => source.id === 'zlib').downloadOnDemand, true);
assert.ok(
list.filter((source) => source.id !== 'zlib').every((source) => source.downloadOnDemand === false),
'只有会消耗下载额度的 Z-Library 应按点击解析'
);
});
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('注册表:开放教材与中英文维基文库已启用', () => {
@@ -455,7 +473,7 @@ test('wikisource: 搜索使用整数偏移并携带可识别 User-Agent', async
assert.strictEqual(result.items[0].subtitle, '中文');
assert.strictEqual(result.maxPage, 500, 'MediaWiki 搜索最多允许偏移到 10000 条');
assert.ok(request.url.includes('sroffset=20'), request.url);
assert.match(request.options.headers['User-Agent'], /PeopleLib\/2\.1\.0/);
assert.match(request.options.headers['User-Agent'], /PeopleLib\/2\.1\.3/);
});
test('wikisource: 浏览按 continuation 令牌翻页', async () => {
@@ -527,7 +545,9 @@ test('zlib: postId 缺 hash 时详情仍可用', async () => {
});
const d = await zlib.detail('123/');
assert.strictEqual(d.title, 'B');
assert.strictEqual(urls.length, 1, '打开详情不应顺带请求下载接口');
assert.ok(urls[0].includes('/eapi/book/123'), urls[0]);
assert.ok(!urls[0].includes('/file'), '打开详情消耗了下载额度: ' + urls[0]);
assert.ok(!urls[0].includes('/eapi/book/123/?'), '缺 hash 时不该留下尾斜杠: ' + urls[0]);
} finally {
auth.getSession = origSession;
@@ -540,6 +560,117 @@ test('zlib: 完全无效的 id 仍然拒绝', async () => {
await assert.rejects(zlib.detail('not-an-id'), /无效的 Z-Library ID/);
});
test('zlib: 自定义镜像规范化、去重并优先于内置列表', () => {
const zlib = h.freshRequire('sources/zlib.js');
const auth = require('../sources/zlib-auth');
const orig = {
getCustomMirrors: auth.getCustomMirrors,
setCustomMirrors: auth.setCustomMirrors,
getSession: auth.getSession,
clearSession: auth.clearSession
};
let custom = [];
auth.getCustomMirrors = () => custom.slice();
auth.setCustomMirrors = (list) => { custom = list.slice(); };
auth.getSession = () => null;
auth.clearSession = () => {};
try {
const result = zlib.setMirrors([
'https://mirror.example/path?ignored=1',
'https://mirror.example/',
'https://second.example'
]);
assert.deepStrictEqual(result.custom, [
'https://mirror.example',
'https://second.example'
]);
assert.deepStrictEqual(zlib.getMirrorConfig().custom, result.custom);
assert.ok(result.defaults.length >= 1, '内置镜像列表丢失');
} finally {
Object.assign(auth, orig);
}
});
test('zlib: 自定义镜像拒绝非 HTTPS、凭据与超量输入', () => {
const zlib = h.freshRequire('sources/zlib.js');
assert.throws(() => zlib.setMirrors(['http://mirror.example']), /必须使用 HTTPS/);
assert.throws(() => zlib.setMirrors(['https://user:pass@mirror.example']), /不能包含账号信息/);
assert.throws(
() => zlib.setMirrors(Array.from({ length: 21 }, (_, i) => `https://m${i}.example`)),
/最多只能保存 20 个镜像/
);
});
test('zlib: 移除当前会话的非内置镜像会清除旧会话', () => {
const zlib = h.freshRequire('sources/zlib.js');
const auth = require('../sources/zlib-auth');
const orig = {
getCustomMirrors: auth.getCustomMirrors,
setCustomMirrors: auth.setCustomMirrors,
getSession: auth.getSession,
clearSession: auth.clearSession
};
let custom = ['https://old.example'];
let cleared = 0;
auth.getCustomMirrors = () => custom.slice();
auth.setCustomMirrors = (list) => { custom = list.slice(); };
auth.getSession = () => ({ userId: '1', userKey: 'k', mirror: 'https://old.example' });
auth.clearSession = () => { cleared += 1; };
try {
zlib.setMirrors(['https://new.example']);
assert.strictEqual(cleared, 1, '被移除镜像上的会话仍被保留');
} finally {
Object.assign(auth, orig);
}
});
test('zlib: 自定义镜像优先于旧会话记录的内置镜像', async () => {
const zlib = h.freshRequire('sources/zlib.js');
const auth = require('../sources/zlib-auth');
const orig = {
getCustomMirrors: auth.getCustomMirrors,
getSession: auth.getSession,
setSession: auth.setSession
};
auth.getCustomMirrors = () => ['https://custom.example'];
auth.getSession = () => ({ userId: '1', userKey: 'k', mirror: 'https://1lib.sk' });
auth.setSession = () => {};
try {
h.resetCalls();
h.setHandler((url) => {
assert.ok(url.startsWith('https://custom.example/'), `先请求了旧会话镜像:${url}`);
return h.makeResponse({ body: { success: 1, books: [] } });
});
await zlib.list(1);
assert.strictEqual(h.getCalls().length, 1, '自定义镜像成功后仍请求了内置镜像');
} finally {
Object.assign(auth, orig);
}
});
test('zlib: 全部失败时保留自定义镜像的首要错误', async () => {
const zlib = h.freshRequire('sources/zlib.js');
const auth = require('../sources/zlib-auth');
const orig = {
getCustomMirrors: auth.getCustomMirrors,
getSession: auth.getSession
};
auth.getCustomMirrors = () => ['https://custom.example'];
auth.getSession = () => ({ userId: '1', userKey: 'k', mirror: 'https://1lib.sk' });
try {
h.setHandler((url) => {
if (url.startsWith('https://custom.example/')) throw new Error('请求超时,站点无响应');
throw new Error('该镜像返回了非预期内容');
});
await assert.rejects(
zlib.list(1),
/自定义镜像失败:请求超时,站点无响应;其余 \d+ 个镜像也未成功/
);
} finally {
Object.assign(auth, orig);
}
});
// 实测:会话过期时 /file 返回 400 + {"success":0,"error":"Please login"}。
// 若按 HTTP 状态码短路,真实原因会被吞掉,自动重登也不会触发。
test('zlib: 4xx+JSON 的会话过期能被识别并自动重新登录', async () => {
@@ -703,3 +834,252 @@ test('gutenberg: 解析格式与封面', async () => {
assert.strictEqual(r.items[0].cover, 'https://x/c.jpg');
assert.strictEqual(r.maxPage, 2);
});
// --- MangaDex ---
test('mangadex: 列表映射本地化标题、作者与封面,并传递安全分级', async () => {
h.resetCalls();
h.setHandler(h.routes([['api.mangadex.org/manga?', {
body: {
total: 41,
data: [{
id: 'manga-1',
attributes: {
title: { ja: '原题', en: 'English title' },
year: 2024
},
relationships: [
{ type: 'cover_art', attributes: { fileName: 'cover.jpg' } },
{ type: 'author', attributes: { name: '作者' } },
{ type: 'artist', attributes: { name: '画师' } }
]
}]
}
}]]));
const result = await sources.getSource('mangadex').list(2);
assert.strictEqual(result.page, 2);
assert.strictEqual(result.maxPage, 3);
assert.deepStrictEqual(result.items[0], {
postId: 'manga-1',
title: 'English title',
cover: 'https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg',
date: '2024',
url: 'https://mangadex.org/title/manga-1',
subtitle: '作者, 画师'
});
const request = new URL(h.getCalls().at(-1).url);
assert.strictEqual(request.searchParams.get('offset'), '20');
assert.deepStrictEqual(request.searchParams.getAll('contentRating[]'), ['safe', 'suggestive']);
assert.deepStrictEqual(request.searchParams.getAll('includes[]'), ['cover_art', 'author', 'artist']);
});
test('mangadex: 空搜索词直接返回空结果,不发起网络请求', async () => {
h.resetCalls();
const result = await sources.getSource('mangadex').search(' ', 3);
assert.deepStrictEqual(result, { items: [], maxPage: 1, page: 3 });
assert.strictEqual(h.getCalls().length, 0);
});
test('mangadex: 详情映射状态、分级、标签与原始语言', async () => {
h.setHandler(h.routes([['api.mangadex.org/manga/manga-1?', {
body: {
data: {
id: 'manga-1',
attributes: {
title: { zh: '中文标题' },
description: { zh: '简介' },
status: 'ongoing',
publicationDemographic: 'shounen',
originalLanguage: 'ja',
year: 2023,
tags: [{ attributes: { name: { en: 'Action' } } }]
},
relationships: [{ type: 'author', attributes: { name: '作者' } }]
}
}
}]]));
const result = await sources.getSource('mangadex').detail('manga-1');
assert.strictEqual(result.title, '中文标题');
assert.strictEqual(result.brief, '简介');
assert.deepStrictEqual(result.tags, ['状态:连载中', '分级:少年', '标签:Action']);
assert.strictEqual(result.originalLanguage, 'ja');
assert.deepStrictEqual(result.authors, ['作者']);
});
test('mangadex: 章节列表映射翻译组、站外与不可用状态', async () => {
h.resetCalls();
h.setHandler(h.routes([['/manga/manga-1/feed?', {
body: {
total: 101,
data: [{
id: 'chapter-1',
attributes: {
volume: '2',
chapter: '3.5',
title: '番外',
translatedLanguage: 'zh-hk',
pages: 18,
publishAt: '2026-01-02T00:00:00Z',
externalUrl: 'https://example.test/read',
isUnavailable: true
},
relationships: [{ type: 'scanlation_group', attributes: { name: '翻译组' } }]
}]
}
}]]));
const result = await sources.getSource('mangadex').chapters('manga-1', 1, { language: 'zh-hk' });
assert.strictEqual(result.maxPage, 2);
assert.deepStrictEqual(result.items[0], {
chapterId: 'chapter-1',
volume: '2',
chapter: '3.5',
title: '番外',
label: '第 2 卷 第 3.5 话 番外',
translatedLanguage: 'zh-hk',
pages: 18,
publishAt: '2026-01-02T00:00:00Z',
group: '翻译组',
external: true,
unavailable: true
});
const request = new URL(h.getCalls().at(-1).url);
assert.deepStrictEqual(request.searchParams.getAll('translatedLanguage[]'), ['zh-hk']);
});
test('mangadex: 原图与压缩图使用正确的 MangaDex@Home 路径', async () => {
const source = sources.getSource('mangadex');
const originalAtHome = source.atHome;
source.atHome = async () => ({
baseUrl: 'https://node.example.test',
chapter: {
hash: 'hash-1',
data: ['one.jpg'],
dataSaver: ['one-small.jpg']
}
});
try {
const original = await source.chapterImageUrls('chapter-1', 'data');
assert.deepStrictEqual(original.urls, ['https://node.example.test/data/hash-1/one.jpg']);
assert.strictEqual(original.mustReport, true);
const saver = await source.chapterImageUrls('chapter-1', 'dataSaver');
assert.deepStrictEqual(saver.urls, ['https://node.example.test/data-saver/hash-1/one-small.jpg']);
assert.strictEqual(saver.mustReport, true);
} finally {
source.atHome = originalAtHome;
}
});
test('mangadex: 官方图片域名无需上报,下载整部时提示先选章节', async () => {
const source = sources.getSource('mangadex');
const originalAtHome = source.atHome;
source.atHome = async () => ({
baseUrl: 'https://uploads.mangadex.org',
chapter: { hash: 'hash-1', data: ['one.png'], dataSaver: ['one.png'] }
});
try {
const result = await source.chapterImageUrls('chapter-1', 'data');
assert.strictEqual(result.mustReport, false);
} finally {
source.atHome = originalAtHome;
}
await assert.rejects(source.download('manga-1'), /选择要下载的具体章节/);
});
// --- 拷贝漫画 ---
test('copymanga: 列表与搜索使用移动端 API 的分页结构', async () => {
h.setHandler(h.routes([
['/api/v3/comics?', {
body: {
results: {
total: 43,
list: [{
name: '漫画甲',
path_word: 'comic-a',
cover: 'https://img/a.jpg',
author: [{ name: '作者甲' }],
datetime_updated: '2026-08-08'
}]
}
}
}],
['/api/v3/search/comic?', {
body: {
results: {
total: 1,
list: [{ name: '漫画乙', path_word: 'comic-b', author: [] }]
}
}
}]
]));
const source = sources.getSource('copymanga');
const listed = await source.list(2);
assert.strictEqual(listed.maxPage, 3);
assert.strictEqual(listed.items[0].subtitle, '作者甲');
const searched = await source.search('漫画', 1);
assert.strictEqual(searched.items[0].postId, 'comic-b');
});
test('copymanga: 详情、分组章节和章节图片按当前 v3 API 映射', async () => {
h.resetCalls();
h.setHandler(h.routes([
['/api/v3/comic2/comic-a?', {
body: {
results: {
comic: {
uuid: 'book-1',
name: '漫画甲',
path_word: 'comic-a',
cover: 'https://img/cover.jpg',
reclass: { value: 1, display: '漫画' },
region: { display: '日本' },
status: { display: '连载中' },
author: [{ name: '作者' }],
theme: [{ name: '冒险' }],
brief: '简介',
datetime_updated: '2026-08-08'
},
groups: { default: { path_word: 'default', count: 2, name: '默认' } }
}
}
}],
['/group/default/chapters?', {
body: {
results: {
list: [
{ uuid: 'c1', name: '第 1 话', ordered: 10, size: 12, datetime_created: '2026-01-01' },
{ uuid: 'c2', name: '第 2 话', ordered: 20, size: 13, datetime_created: '2026-01-02' }
]
}
}
}],
['/chapter/c1?', {
body: {
results: {
chapter: {
contents: [{ url: 'https://img/1.jpg' }, { url: 'https://img/2.webp' }]
}
}
}
}]
]));
const source = sources.getSource('copymanga');
const detail = await source.detail('comic-a');
assert.deepStrictEqual(detail.tags, ['状态:连载中', '地区:日本', '标签:冒险']);
const chapters = await source.chapters('comic-a', 1);
assert.deepStrictEqual(chapters.items.map((item) => item.chapterId), ['comic-a||c1', 'comic-a||c2']);
assert.strictEqual(chapters.items[0].chapter, '1');
await source.chapters('comic-a', 1);
assert.strictEqual(
h.getCalls().filter((call) => call.url.includes('/group/default/chapters?')).length,
1,
'重复翻页不应重新抓取完整章节目录'
);
const images = await source.chapterImageUrls('comic-a||c1');
assert.deepStrictEqual(images.urls, ['https://img/1.jpg', 'https://img/2.webp']);
});
+19
View File
@@ -613,6 +613,25 @@ test('书架 CRUD 强制唯一非空名称并返回深拷贝', () => {
assert.throws(() => store.updateShelf(other.id, { name: ' 文学 ' }), /已存在/);
});
test('书架列表返回每个分类当前包含的书籍数量', () => {
freshRoot('shelf-counts');
const research = store.addShelf('研究');
const archive = store.addShelf('归档');
const first = store.add({ title: 'A', shelfId: research.id });
store.add({ title: 'B', shelfId: research.id });
store.add({ title: '未分类' });
assert.deepStrictEqual(
store.listShelves().map((shelf) => [shelf.name, shelf.count]),
[['研究', 2], ['归档', 0]]
);
store.update(first.id, { shelfId: archive.id });
assert.deepStrictEqual(
store.listShelves().map((shelf) => [shelf.name, shelf.count]),
[['研究', 1], ['归档', 1]]
);
});
test('删除书架只清空条目 shelfId 且组织变更触发通知', () => {
freshRoot('shelf-remove');
let changes = 0;
+142
View File
@@ -165,6 +165,23 @@ test('书库页提供可管理标签目录和整理多选下拉', () => {
assert.doesNotMatch(library, /id="libraryBookTags" type="text"/);
});
test('全部书籍、未分类和每个书架都以内联括号显示实时数量', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const library = fs.readFileSync(libFile, 'utf8');
assert.match(
html,
/data-shelf="">[\s\S]*全部书籍[\s\S]*class="library-filter-count">0<\/span>/
);
assert.match(
html,
/data-shelf="__uncategorized__">[\s\S]*未分类[\s\S]*class="library-filter-count">0<\/span>/
);
assert.match(library, /items\.filter\(\(item\) => !item\.shelfId\)\.length/);
assert.match(library, /count\.textContent = `\$\{value\}`/);
assert.match(library, /count\.textContent = `\$\{shelf\.count \|\| 0\}`/);
assert.match(library, /filter\.append\(name, count\)/);
});
test('下载区接入全局任务中心且完成按钮使用高对比绿色底色', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
@@ -201,6 +218,103 @@ test('下载区接入全局任务中心且完成按钮使用高对比绿色底
assert.match(rule[1], /color:\s*#07130b/);
});
test('Z-Library 进入详情不消耗下载额度,点击下载后才解析并开始任务', () => {
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
const openDetail = browse.slice(
browse.indexOf('async function openDetail'),
browse.indexOf('function sourceNameOf')
);
assert.match(openDetail, /if \(sourceDownloadsOnDemand\(activeSourceId\)\)/);
assert.match(openDetail, /renderOnDemandDownload\(postId, activeSourceId, token\)/);
assert.match(openDetail, /else\s*\{\s*loadDownload\(postId, activeSourceId, token\)/);
const onDemand = browse.slice(
browse.indexOf('function renderOnDemandDownload'),
browse.indexOf('// 当前条目的元数据')
);
const clickAt = onDemand.indexOf('btn.onclick = async');
const resolveAt = onDemand.indexOf('window.api.sources.download', clickAt);
const startAt = onDemand.indexOf('download.click()', resolveAt);
assert.ok(clickAt >= 0 && resolveAt > clickAt, '下载地址在用户点击前被获取');
assert.ok(startAt > resolveAt, '解析下载地址后没有立即开始真实下载');
assert.match(onDemand, /每日免费下载额度有限,仅在点击后获取下载地址/);
});
test('Z-Library 账户设置提供可编辑的镜像站点列表', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
const accountAt = html.indexOf('Z-Library 账号');
const mirrorsAt = html.indexOf('id="zlibMirrorBtn"');
const nextGroupAt = html.indexOf('<div class="settings-group">', accountAt + 30);
assert.ok(accountAt >= 0 && mirrorsAt > accountAt && mirrorsAt < nextGroupAt,
'镜像设置没有放在 Z-Library 账户设置组内');
assert.match(app, /window\.api\.zlib\.getMirrors\(\)/);
assert.match(app, /window\.api\.zlib\.setMirrors\(lines\)/);
assert.match(app, /一行一个地址,必须是 HTTPS/);
assert.match(preload, /getMirrors:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('zlib:getMirrors'\)/);
assert.match(preload, /setMirrors:\s*\(list\)\s*=>\s*ipcRenderer\.invoke\('zlib:setMirrors', list\)/);
assert.match(main, /ipcMain\.handle\('zlib:getMirrors',[^\n]*wrap\(/);
assert.match(main, /ipcMain\.handle\('zlib:setMirrors',[^\n]*wrap\(/);
assert.match(css, /textarea\.modal-input\s*\{[^}]*height:\s*auto/);
});
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 mangaCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'manga-online.css'), '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.match(browse, /data-chapter-read=/);
assert.match(browse, /initialChapterId: chapter && chapter\.chapterId/);
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(script, /state\.meta\.initialChapterId/);
assert.match(mangaCss, /\.page-scroller\s*\{[^}]*overflow-anchor:\s*none/);
assert.match(preload, /mangaOnline:image/);
assert.doesNotMatch(preload, /library:|reader:bytes|shell:openPath/);
});
test('主窗口在设置旁提供持久化明暗主题切换', () => {
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
@@ -602,6 +716,23 @@ test('侧栏行内操作不占据布局,选中高亮与静态筛选项等宽',
assert.match(actionsRule[1], /position:\s*absolute/);
assert.doesNotMatch(css, /\.library-shelf-actions\s*\{\s*display:\s*flex;\s*flex:\s*none/);
assert.match(css, /\.library-shelf-row \.library-filter,\s*\n\.library-tag-row \.library-filter\s*\{[^}]*padding-right:\s*48px/);
// 分类数量紧跟名称显示为「分类(数量)」,不再像标签数量那样浮到右侧
assert.match(css, /\.library-shelf-row \.library-filter\s*\{[^}]*display:\s*flex/);
const shelfName = css.match(
/\.library-shelf-row \.library-filter > span:first-child\s*\{([^}]*)\}/
);
assert.ok(shelfName, '缺少长分类名称的独立省略样式');
assert.match(shelfName[1], /min-width:\s*0/);
assert.match(shelfName[1], /overflow:\s*hidden/);
assert.match(shelfName[1], /text-overflow:\s*ellipsis/);
assert.match(css, /#libraryTab \.library-filter\[data-shelf\] \.library-filter-count,\s*\n\.library-shelf-row \.library-filter-count\s*\{[^}]*flex:\s*none;[^}]*float:\s*none/);
const actionButtons = css.match(
/\.library-shelf-actions \.notes-icon-btn,\s*\n\.library-tag-actions \.notes-icon-btn\s*\{([^}]*)\}/
);
assert.ok(actionButtons, '缺少侧栏操作按钮样式');
assert.match(actionButtons[1], /background:\s*transparent/);
assert.match(actionButtons[1], /border-color:\s*transparent/);
assert.match(css, /\.library-shelf-actions \.notes-icon-btn:hover,[\s\S]*?background:\s*transparent;[\s\S]*?border-color:\s*transparent/);
// 书架与标签共用同一个列表容器类,行间距不会一边有一边没有
assert.match(css, /\.library-filter-list\s*\{[^}]*gap:\s*1px/);
assert.match(html, /id="libraryShelfList" class="library-filter-list"/);
@@ -645,6 +776,17 @@ test('书库多选提供全选、批量整理与批量移除', () => {
assert.match(library, /window\.api\.library\.updateMany\(patches\)/);
});
test('重新扫描发现文件缺失后提示清理书库条目', () => {
const library = fs.readFileSync(libFile, 'utf8');
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
assert.match(library, /const missing = Math\.max\(0, Number\(\(r\.data \|\| \{\}\)\.missing\)/);
assert.match(library, /confirmModal\(\s*'发现文件缺失'/);
assert.match(library, /对应分类和标签也会一并清理/);
assert.match(library, /笔记、书签、进度和标注会保留/);
assert.match(library, /window\.api\.library\.removeMissing\(\)/);
assert.match(preload, /removeMissing: \(\) => ipcRenderer\.invoke\('library:removeMissing'\)/);
});
test('卡片定位上下文不随多选状态消失', () => {
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
// 退出多选是同步移除 class,重绘要等 IPC;若定位上下文只在 select-mode 下建立,
+69
View File
@@ -0,0 +1,69 @@
const test = require('node:test');
const assert = require('node:assert');
const zip = require('../zip');
test('writeZip/readZip: 基本往返,文本与二进制内容不失真', () => {
const entries = [
{ name: 'mimetype', data: Buffer.from('application/epub+zip') },
{ name: 'a/b.txt', data: Buffer.from('hello 你好', 'utf8') },
{ name: 'c.bin', data: Buffer.from([0, 1, 2, 255, 254, 128]) }
];
const buf = zip.writeZip(entries);
const parsed = zip.readZip(buf);
assert.deepStrictEqual([...parsed.keys()], ['mimetype', 'a/b.txt', 'c.bin']);
assert.strictEqual(parsed.get('a/b.txt').toString('utf8'), 'hello 你好');
assert.deepStrictEqual([...parsed.get('c.bin')], [0, 1, 2, 255, 254, 128]);
});
test('writeZip/readZip: 大文件(跨多个 chunk)内容比特级一致', () => {
const big = Buffer.alloc(500000);
for (let i = 0; i < big.length; i++) big[i] = i % 256;
const buf = zip.writeZip([{ name: 'big.bin', data: big }]);
const parsed = zip.readZip(buf);
assert.strictEqual(Buffer.compare(parsed.get('big.bin'), big), 0);
});
test('readZip: 能解析第三方(JSZip)产出的 DEFLATE 压缩包', async () => {
const JSZip = require('../ui/vendor/jszip.min.js');
const jz = new JSZip();
jz.file('x.txt', 'DEFLATE 测试内容');
const buf = await jz.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
const parsed = zip.readZip(buf);
assert.strictEqual(parsed.get('x.txt').toString('utf8'), 'DEFLATE 测试内容');
});
test('writeZip: 产出的包能被第三方(JSZip)正确打开', async () => {
const JSZip = require('../ui/vendor/jszip.min.js');
const buf = zip.writeZip([
{ name: 'mimetype', data: Buffer.from('application/epub+zip') },
{ name: 'nested/dir/file.txt', data: Buffer.from('nested content') }
]);
const jz = await JSZip.loadAsync(buf);
assert.ok(jz.file('mimetype'));
const text = await jz.file('nested/dir/file.txt').async('string');
assert.strictEqual(text, 'nested content');
});
test('readZip: 空 ZIPEOCD 但无条目)不抛错,返回空 Map', () => {
const buf = zip.writeZip([]);
const parsed = zip.readZip(buf);
assert.strictEqual(parsed.size, 0);
});
test('readZip: 非 ZIP 数据抛出可读错误而不是崩溃', () => {
assert.throws(() => zip.readZip(Buffer.from('not a zip file')), /不是有效的 ZIP 文件/);
});
test('readZip: 不把未知压缩方式误当作 STORE 内容', () => {
const buf = zip.writeZip([{ name: 'x.bin', data: Buffer.from([1, 2, 3]) }]);
const central = buf.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
buf.writeUInt16LE(99, central + 10);
assert.throws(() => zip.readZip(buf), /压缩方式不受支持/);
});
test('readZip: 条目内容损坏时通过 CRC 拒绝继续读取', () => {
const buf = zip.writeZip([{ name: 'x.bin', data: Buffer.from([1, 2, 3]) }]);
const dataStart = 30 + Buffer.byteLength('x.bin');
buf[dataStart] ^= 0xff;
assert.throws(() => zip.readZip(buf), /条目校验失败/);
});
+18 -6
View File
@@ -32,18 +32,20 @@ function syncDirectory(dir) {
}
}
// 写入 dest,保留一份 .bak 以便下次读取时恢复。
// 失败时清理临时文件,并在目标已被改走时把备份换回去。
function writeJson(dest, value) {
function writeAtomicReplace(dest, data, encoding) {
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
writeSynced(temp, JSON.stringify(value, null, 2));
writeSynced(temp, data, encoding);
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) {
fs.renameSync(dest, backup);
try {
fs.linkSync(dest, backup);
} catch (e) {
fs.copyFileSync(dest, backup);
}
backedUp = true;
}
fs.renameSync(temp, dest);
@@ -55,11 +57,17 @@ function writeJson(dest, value) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
else if (backedUp && fs.existsSync(backup)) fs.unlinkSync(backup);
} catch (rollback) { /* 下次读取时从 .bak 恢复 */ }
throw e;
}
}
// 写入 dest,保留一份 .bak 以便写入失败或异常退出时恢复。
function writeJson(dest, value) {
writeAtomicReplace(dest, JSON.stringify(value, null, 2), 'utf8');
}
// 二进制落地(封面等)。已存在则不覆盖,由调用方决定语义。
function writeBytesExclusive(dest, bytes) {
const temp = `${dest}.tmp`;
@@ -75,4 +83,8 @@ function writeBytesExclusive(dest, bytes) {
syncDirectory(path.dirname(dest));
}
module.exports = { writeJson, writeSynced, syncDirectory, writeBytesExclusive };
function writeBytes(dest, bytes) {
writeAtomicReplace(dest, bytes, null);
}
module.exports = { writeJson, writeSynced, syncDirectory, writeBytesExclusive, writeBytes };
+284
View File
@@ -0,0 +1,284 @@
// MangaDex 章节下载编排:抓图 -> 组装/追加 EPUB -> 落库。
//
// 图片必须由本进程代理转发(渲染层永远不直连 mangadex 域名,参见 AGENTS 安全边界),
// 这里就是那层代理。@Home 节点的健康上报是强制义务:不上报,故障节点不会被剔除,
// 参见 https://api.mangadex.org/docs/04-chapter/retrieving-chapter/。
const fs = require('fs');
const path = require('path');
const { fetchWithProxy, responseBytes, UA } = require('../sources/http');
const atomic = require('../atomic-file');
const mangaEpub = require('./manga-epub');
const MAX_CONCURRENCY = 4;
const MAX_CHAPTER_RETRIES = 1; // 失败页重新申请 baseUrl 后只重试一次,避免无限重试卡死
const IMAGE_TIMEOUT_MS = 30000;
const REPORT_TIMEOUT_MS = 15000;
const MAX_PAGE_BYTES = 50 * 1024 * 1024;
const MAX_CHAPTER_BYTES = 512 * 1024 * 1024;
const mangaQueues = new Map();
function limiter(limit) {
let active = 0;
const waiting = [];
return async (fn) => {
if (active >= limit) await new Promise((resolve) => waiting.push(resolve));
else active++;
try {
return await fn();
} finally {
const next = waiting.shift();
if (next) next();
else active--;
}
};
}
const withImageSlot = limiter(8);
const withReportSlot = limiter(8);
function extFromUrl(url) {
const m = String(url).match(/\.([a-z0-9]+)(?:$|[?#])/i);
return m ? m[1].toLowerCase() : 'jpg';
}
async function fetchImage(url, extraHeaders, maxBytes = MAX_PAGE_BYTES, outerSignal) {
const started = Date.now();
const controller = new AbortController();
const abort = () => controller.abort();
if (outerSignal) {
if (outerSignal.aborted) controller.abort();
else outerSignal.addEventListener('abort', abort, { once: true });
}
const timer = setTimeout(abort, IMAGE_TIMEOUT_MS);
try {
const res = await fetchWithProxy(url, {
headers: { 'User-Agent': UA, ...(extraHeaders || {}) },
signal: controller.signal
});
if (!res.ok) {
if (res.body && typeof res.body.cancel === 'function') await res.body.cancel().catch(() => {});
return { ok: false, bytes: 0, duration: Date.now() - started };
}
const cached = /^HIT/i.test(res.headers.get('x-cache') || '');
const data = await responseBytes(res, maxBytes);
if (!data) return { ok: false, bytes: 0, duration: Date.now() - started };
return { ok: true, data, bytes: data.length, duration: Date.now() - started, cached };
} catch (e) {
return { ok: false, bytes: 0, duration: Date.now() - started };
} finally {
clearTimeout(timer);
if (outerSignal) outerSignal.removeEventListener('abort', abort);
}
}
async function fetchOnlineImage(url, headers, mustReport, maxBytes, signal) {
const result = await withImageSlot(() => fetchImage(url, headers, maxBytes, signal));
if (mustReport) await withReportSlot(() => reportOne(url, result));
if (!result.ok || !result.data) throw new Error('漫画图片加载失败,请重试');
return result.data;
}
// 上报失败不影响下载结果本身:健康检测允许丢失个别上报,不值得因此让整个下载失败。
async function reportOne(url, result) {
try {
const res = await fetchWithProxy('https://api.mangadex.network/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': UA },
signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),
body: JSON.stringify({
url,
success: !!result.ok,
bytes: result.bytes || 0,
duration: result.duration || 0,
cached: !!result.cached
})
});
if (res.body && typeof res.body.cancel === 'function') await res.body.cancel().catch(() => {});
} catch (e) { /* ignore */ }
}
async function downloadPages(
urls,
mustReport,
headers,
onProgress,
doneOffset,
total,
maxBytes = MAX_CHAPTER_BYTES
) {
const results = new Array(urls.length).fill(null);
const reports = [];
let cursor = 0;
let done = doneOffset || 0;
let downloadedBytes = 0;
let overLimit = false;
async function worker() {
for (;;) {
if (overLimit) return;
const index = cursor++;
if (index >= urls.length) return;
const result = await withImageSlot(() => fetchImage(urls[index], headers));
results[index] = result;
if (mustReport) reports.push(withReportSlot(() => reportOne(urls[index], result)));
if (result.ok) {
downloadedBytes += result.bytes;
if (downloadedBytes > maxBytes) {
overLimit = true;
continue;
}
done++;
if (onProgress) onProgress(done, total || urls.length);
}
}
}
const workers = Array.from({ length: Math.min(MAX_CONCURRENCY, urls.length) }, worker);
await Promise.all(workers);
await Promise.all(reports);
if (overLimit) throw new Error('单章漫画图片超过 512 MB,已停止下载');
return results;
}
// 返回按页码顺序排列的 [{ ext, data }],供 manga-epub 组装。
async function fetchChapterImages(mangaSource, chapterId, quality, onProgress) {
let { urls, mustReport, headers } = await mangaSource.chapterImageUrls(chapterId, quality);
const total = urls.length;
let results = await downloadPages(urls, mustReport, headers, onProgress, 0, total);
let failedIndexes = results.map((r, i) => (r.ok ? -1 : i)).filter((i) => i >= 0);
for (let attempt = 0; attempt < MAX_CHAPTER_RETRIES && failedIndexes.length; attempt++) {
// 同一 baseUrl 反复失败大概率是节点故障,必须重新申请(可能换一台节点)再试
const fresh = await mangaSource.chapterImageUrls(chapterId, quality);
urls = fresh.urls;
mustReport = fresh.mustReport;
headers = fresh.headers;
const retryUrls = failedIndexes.map((i) => urls[i]);
const retryResults = await downloadPages(
retryUrls,
mustReport,
headers,
onProgress,
total - failedIndexes.length,
total,
MAX_CHAPTER_BYTES - results.reduce((sum, result) => (
sum + (result && result.ok ? result.bytes : 0)
), 0)
);
failedIndexes.forEach((originalIndex, k) => { results[originalIndex] = retryResults[k]; });
failedIndexes = results.map((r, i) => (r.ok ? -1 : i)).filter((i) => i >= 0);
}
if (failedIndexes.length) {
throw new Error(`章节下载失败:${failedIndexes.length}/${total} 张图片无法获取,请重试`);
}
return results.map((r, i) => ({ ext: extFromUrl(urls[i]), data: r.data }));
}
// payload: { mangaId, title, cover, authors, date, brief, url, originalLanguage,
// chapterId, label, volume, chapter, translatedLanguage, group, quality }
function findTarget(library, sourceId, mangaId, chapterId) {
const existing = library.findBySource(sourceId, mangaId);
if (existing) {
for (const file of (existing.files || []).filter((f) => f.exists && /\.epub$/i.test(f.path || f.name || ''))) {
const bytes = fs.readFileSync(file.path);
let parsed;
try {
parsed = mangaEpub.parseMangaEpub(bytes);
} catch (e) {
continue;
}
if (parsed.sourceId !== sourceId || parsed.mangaId !== mangaId) continue;
if (parsed.chapters.some((chapter) => chapter.chapterId === chapterId)) {
throw new Error('该章节已下载');
}
return { existing, existingFile: file, existingParsed: parsed };
}
}
return { existing, existingFile: null, existingParsed: null };
}
async function saveChapter(library, sourceId, payload, images, target) {
const mangaId = String(payload && payload.mangaId || '').trim();
const chapterId = String(payload && payload.chapterId || '').trim();
const { existing, existingFile, existingParsed } = target;
const chapterFields = {
chapterId,
label: payload.label || '未命名章节',
volume: payload.volume || '',
chapter: payload.chapter || '',
translatedLanguage: payload.translatedLanguage || '',
group: payload.group || ''
};
if (existing && existingFile && existingParsed) {
const { bytes: nextBytes } = mangaEpub.appendMangaChapter(existingParsed, {
title: existing.title,
sourceId,
mangaId,
originalLanguage: payload.originalLanguage || 'ja',
images,
...chapterFields
});
atomic.writeBytes(existingFile.path, nextBytes);
const updated = library.attachFile(existing.id, existingFile.path);
return { entry: updated, chapterLabel: chapterFields.label, created: false };
}
const { bytes } = mangaEpub.createMangaEpub({
title: (existing && existing.title) || payload.title || '未命名漫画',
sourceId,
mangaId,
originalLanguage: payload.originalLanguage || 'ja',
images,
...chapterFields
});
const fileTitle = (existing && existing.title) || payload.title || '未命名漫画';
const absPath = library.allocFilePath(`${fileTitle}.epub`);
atomic.writeBytesExclusive(absPath, bytes);
// 已有条目但原文件缺失(用户手动删过文件):把新文件挂回同一条目,
// 不能再调 library.add(),否则同一个 sourceId+sourcePostId 会出现两条记录,
// findBySource 只能返回其中一条,另一条从此变成孤儿。
if (existing) {
const updated = library.attachFile(existing.id, absPath);
return { entry: updated, chapterLabel: chapterFields.label, created: false };
}
const created = library.add({
title: payload.title || '未命名漫画',
authors: payload.authors || [],
cover: payload.cover || '',
date: payload.date || '',
brief: payload.brief || '',
url: payload.url || '',
sourceId,
sourcePostId: mangaId,
files: [{ path: absPath, name: path.basename(absPath), format: 'EPUB' }]
});
return { entry: created, chapterLabel: chapterFields.label, created: true };
}
function downloadChapter(mangaSource, library, payload, onProgress) {
const sourceId = String(mangaSource && mangaSource.id || '').trim();
const mangaId = String(payload && payload.mangaId || '').trim();
const chapterId = String(payload && payload.chapterId || '').trim();
if (!sourceId) return Promise.reject(new Error('缺少漫画源 ID'));
if (!mangaId) return Promise.reject(new Error('缺少漫画 ID'));
if (!chapterId) return Promise.reject(new Error('缺少章节 ID'));
const quality = payload.quality === 'data' ? 'data' : 'dataSaver';
const queueKey = `${sourceId}:${mangaId}`;
const previous = mangaQueues.get(queueKey) || Promise.resolve();
const current = previous
.catch(() => {})
.then(async () => {
const target = findTarget(library, sourceId, mangaId, chapterId);
const images = await fetchChapterImages(mangaSource, chapterId, quality, onProgress);
return saveChapter(library, sourceId, payload, images, target);
});
mangaQueues.set(queueKey, current);
return current.finally(() => {
if (mangaQueues.get(queueKey) === current) mangaQueues.delete(queueKey);
});
}
module.exports = { fetchChapterImages, fetchOnlineImage, downloadChapter, extFromUrl };
+226
View File
@@ -0,0 +1,226 @@
// 把漫画章节的图片序列组装成 EPUB(每页一张全屏图片),交给现有 epub 阅读器渲染。
// 不新增专用的分页漫画阅读器:epub-adapter 已经会把图片转成 data URL 内联展示,
// 复用它是最小的实现面(做法参照 text-adapter 把 txt/md 转 EPUB 复用同一渲染路径)。
//
// 章节追加(同一部漫画的多个章节合并进一条书库条目)靠 peoplelib/chapters.json
// 这个非标准的旁路文件记录每章的文件清单:追加新章节时不需要重新下载/重新编码
// 已有章节的图片,直接从旧 zip 里搬运原始字节,只重建 opf/nav/清单三个小文件。
const { writeZip, readZip } = require('../zip');
const XHTML_NS = 'http://www.w3.org/1999/xhtml';
const CONTAINER_XML = '<?xml version="1.0" encoding="UTF-8"?>'
+ '<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
+ '<rootfiles><rootfile full-path="content.opf" media-type="application/oebps-package+xml"/></rootfiles>'
+ '</container>';
function xmlText(value) {
return String(value == null ? '' : value)
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function xmlAttr(value) {
return xmlText(value).replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
function extMime(ext) {
const e = String(ext || '').toLowerCase();
if (e === 'jpg' || e === 'jpeg') return 'image/jpeg';
if (e === 'png') return 'image/png';
if (e === 'gif') return 'image/gif';
if (e === 'webp') return 'image/webp';
return 'application/octet-stream';
}
function manifestId(path) {
return `f_${path.replace(/[^A-Za-z0-9]+/g, '_')}`;
}
function pageXhtml(imgHref, label) {
return `<?xml version="1.0" encoding="UTF-8"?><html xmlns="${XHTML_NS}"><head><title>${xmlText(label)}</title>`
+ '<style>html,body{margin:0;padding:0;background:#000}img{display:block;width:100%;height:auto}</style></head>'
+ `<body><img src="${xmlAttr(imgHref)}" alt="${xmlAttr(label)}"/></body></html>`;
}
// 章节内文件都在 chapters/<id>/ 与 images/<id>/ 下,二者深度相同,
// 页面到图片的相对路径固定是「上两级再进图片目录」。
function buildChapterFiles(chapterId, images) {
const files = [];
images.forEach((img, i) => {
const n = String(i + 1).padStart(4, '0');
const imgPath = `images/${chapterId}/${n}.${img.ext}`;
const pagePath = `chapters/${chapterId}/p${n}.xhtml`;
const mediaType = extMime(img.ext);
files.push({ path: imgPath, data: img.data, mediaType, kind: 'image' });
files.push({
path: pagePath,
data: Buffer.from(pageXhtml(`../../${imgPath}`, `${i + 1}`), 'utf8'),
mediaType: 'application/xhtml+xml',
kind: 'page'
});
});
return files;
}
// 卷号/话号都可能缺省(单行本、番外常常没有卷号);缺省的排到最后,
// 匹配"先追加已编号章节、新章节持续追更"这一最常见的使用顺序。
function chapterSortKey(c) {
const vol = c.volume === '' || c.volume == null ? NaN : parseFloat(c.volume);
const num = c.chapter === '' || c.chapter == null ? NaN : parseFloat(c.chapter);
return [Number.isFinite(vol) ? vol : Infinity, Number.isFinite(num) ? num : Infinity];
}
function compareChapters(a, b) {
const ka = chapterSortKey(a);
const kb = chapterSortKey(b);
if (ka[0] !== kb[0]) return ka[0] - kb[0];
if (ka[1] !== kb[1]) return ka[1] - kb[1];
return String(a.chapterId).localeCompare(String(b.chapterId));
}
function buildNav(chapters) {
const items = chapters.map((c) => {
const firstPage = c.files.find((f) => f.kind === 'page');
return `<li><a href="${xmlAttr(firstPage.path)}">${xmlText(c.label)}</a></li>`;
}).join('');
return `<?xml version="1.0" encoding="UTF-8"?><html xmlns="${XHTML_NS}" xmlns:epub="http://www.idpf.org/2007/ops">`
+ `<head><title>目录</title></head><body><nav epub:type="toc"><ol>${items}</ol></nav></body></html>`;
}
function buildOpf(title, sourceId, mangaId, originalLanguage, manifestItems, spineIds) {
const modified = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
return '<?xml version="1.0" encoding="UTF-8"?>'
+ '<package version="3.0" unique-identifier="book-id" xmlns="http://www.idpf.org/2007/opf">'
+ '<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">'
+ `<dc:identifier id="book-id">peoplelib-${xmlAttr(sourceId)}-${xmlAttr(mangaId)}</dc:identifier>`
+ `<dc:title>${xmlText(title)}</dc:title>`
+ `<dc:language>${xmlAttr(originalLanguage || 'ja')}</dc:language>`
+ `<meta property="dcterms:modified">${modified}</meta>`
+ '</metadata>'
+ `<manifest>${manifestItems.join('')}</manifest>`
+ `<spine>${spineIds.map((id) => `<itemref idref="${id}"/>`).join('')}</spine>`
+ '</package>';
}
function assembleEpub({ title, sourceId, mangaId, originalLanguage, chapters }) {
const sorted = chapters.slice().sort(compareChapters);
const manifestItems = [];
const spineIds = [];
const entries = [
{ name: 'mimetype', data: Buffer.from('application/epub+zip') },
{ name: 'META-INF/container.xml', data: Buffer.from(CONTAINER_XML, 'utf8') }
];
for (const chapter of sorted) {
for (const file of chapter.files) {
entries.push({ name: file.path, data: file.data });
const id = manifestId(file.path);
manifestItems.push(`<item id="${id}" href="${xmlAttr(file.path)}" media-type="${file.mediaType}"/>`);
if (file.kind === 'page') spineIds.push(id);
}
}
manifestItems.push('<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>');
const chaptersMeta = sorted.map((c) => ({
chapterId: c.chapterId,
label: c.label,
volume: c.volume || '',
chapter: c.chapter || '',
translatedLanguage: c.translatedLanguage || '',
group: c.group || '',
pageCount: c.files.filter((f) => f.kind === 'page').length,
files: c.files.map((f) => ({ path: f.path, mediaType: f.mediaType, kind: f.kind }))
}));
entries.push({ name: 'content.opf', data: Buffer.from(buildOpf(title, sourceId, mangaId, originalLanguage, manifestItems, spineIds), 'utf8') });
entries.push({ name: 'nav.xhtml', data: Buffer.from(buildNav(sorted), 'utf8') });
entries.push({
name: 'peoplelib/chapters.json',
data: Buffer.from(JSON.stringify({ sourceId, mangaId, chapters: chaptersMeta }), 'utf8')
});
return { bytes: writeZip(entries), chapterCount: sorted.length, pageCount: spineIds.length };
}
function readExistingChapters(epubBytes) {
const zip = readZip(epubBytes);
const metaBuf = zip.get('peoplelib/chapters.json');
if (!metaBuf) throw new Error('该文件不是可续传的漫画 EPUB,请改为新建条目');
let meta;
try { meta = JSON.parse(metaBuf.toString('utf8')); } catch (e) { throw new Error('漫画 EPUB 的章节索引已损坏'); }
const chapters = (meta.chapters || []).map((c) => ({
...c,
files: (c.files || []).map((f) => {
const data = zip.get(f.path);
if (!data) throw new Error(`漫画 EPUB 内容缺失: ${f.path}`);
return { ...f, data };
})
}));
return { sourceId: meta.sourceId || 'mangadex', mangaId: meta.mangaId, chapters };
}
// images: [{ ext, data: Buffer }],按页码顺序传入
function createMangaEpub({ title, sourceId = 'mangadex', mangaId, originalLanguage, chapterId, label, volume, chapter, translatedLanguage, group, images }) {
const files = buildChapterFiles(chapterId, images);
return assembleEpub({
title,
sourceId,
mangaId,
originalLanguage,
chapters: [{ chapterId, label, volume, chapter, translatedLanguage, group, files }]
});
}
function appendMangaChapter(existingInput, { title, sourceId = 'mangadex', mangaId, originalLanguage, chapterId, label, volume, chapter, translatedLanguage, group, images }) {
const existing = Buffer.isBuffer(existingInput)
? readExistingChapters(existingInput)
: existingInput;
if (existing.sourceId !== sourceId || existing.mangaId !== mangaId) {
throw new Error('目标文件不是同一部漫画,无法合并章节');
}
if (existing.chapters.some((c) => c.chapterId === chapterId)) {
throw new Error('该章节已存在于此书中');
}
const files = buildChapterFiles(chapterId, images);
const merged = existing.chapters.concat([{ chapterId, label, volume, chapter, translatedLanguage, group, files }]);
return assembleEpub({ title, sourceId, mangaId, originalLanguage, chapters: merged });
}
function hasChapter(epubBytes, chapterId) {
try {
return readExistingChapters(epubBytes).chapters.some((c) => c.chapterId === chapterId);
} catch (e) {
return false;
}
}
function isMangaEpub(epubBytes, mangaId, sourceId = 'mangadex') {
try {
const parsed = readExistingChapters(epubBytes);
return parsed.sourceId === sourceId && parsed.mangaId === mangaId;
} catch (e) {
return false;
}
}
function listChapters(epubBytes) {
try {
return readExistingChapters(epubBytes).chapters.map((c) => ({
chapterId: c.chapterId, label: c.label, volume: c.volume, chapter: c.chapter
}));
} catch (e) {
return [];
}
}
module.exports = {
createMangaEpub,
appendMangaChapter,
parseMangaEpub: readExistingChapters,
hasChapter,
isMangaEpub,
listChapters,
compareChapters
};
+40 -34
View File
@@ -14,7 +14,7 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const atomic = require('../atomic-file');
const { UA: DL_UA, fetchWithProxy } = require('../sources/http');
const { UA: DL_UA, fetchWithProxy, responseBytes } = require('../sources/http');
const SCHEMA_VERSION = 4;
const MAX_TAGS = 50;
@@ -330,37 +330,6 @@ function imageExt(bytes) {
return '';
}
async function responseBytes(res, maxBytes) {
const declared = Number(res.headers && res.headers.get && res.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
try { if (res.body && res.body.cancel) await res.body.cancel(); } catch (e) { /* ignore */ }
return null;
}
if (!res.body || typeof res.body.getReader !== 'function') {
const bytes = Buffer.from(await res.arrayBuffer());
return bytes.length <= maxBytes ? bytes : null;
}
const reader = res.body.getReader();
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
size += chunk.length;
if (size > maxBytes) {
await reader.cancel();
return null;
}
chunks.push(chunk);
}
} finally {
try { reader.releaseLock(); } catch (e) { /* ignore */ }
}
return Buffer.concat(chunks, size);
}
async function cacheCover(id, url, baseDir) {
let temp = '';
const controller = new AbortController();
@@ -494,7 +463,12 @@ function findBySource(sourceId, sourcePostId) {
function listShelves() {
load();
return shelves.map((shelf) => ({ ...shelf }));
const counts = new Map();
for (const item of items) {
if (!item.shelfId) continue;
counts.set(item.shelfId, (counts.get(item.shelfId) || 0) + 1);
}
return shelves.map((shelf) => ({ ...shelf, count: counts.get(shelf.id) || 0 }));
}
function listTags() {
@@ -978,6 +952,38 @@ function removeMany(ids, deleteFiles) {
return { removed: doomed.length };
}
function removeMissing() {
load();
const doomed = items.filter((item) => {
const files = item.files || [];
return files.length > 0 && files.every((file) => !fs.existsSync(toAbsolute(file.path)));
});
if (!doomed.length) return { removed: 0, shelvesRemoved: 0, tagsRemoved: 0 };
const ids = new Set(doomed.map((item) => item.id));
const candidateShelves = new Set(doomed.map((item) => item.shelfId).filter(Boolean));
const candidateTags = new Set(doomed.flatMap((item) => normalizeTags(item.tags).map(tagKey)));
const nextItems = items.filter((item) => !ids.has(item.id));
const usedShelves = new Set(nextItems.map((item) => item.shelfId).filter(Boolean));
const usedTags = new Set(nextItems.flatMap((item) => normalizeTags(item.tags).map(tagKey)));
const nextShelves = shelves.filter((shelf) => (
!candidateShelves.has(shelf.id) || usedShelves.has(shelf.id)
));
const nextTags = tags.filter((tag) => (
!candidateTags.has(tagKey(tag.name)) || usedTags.has(tagKey(tag.name))
));
const shelvesRemoved = shelves.length - nextShelves.length;
const tagsRemoved = tags.length - nextTags.length;
commit(nextItems, true, nextShelves, nextTags);
for (const item of doomed) removeCoverFile(item.id);
return {
removed: doomed.length,
shelvesRemoved,
tagsRemoved
};
}
function remove(id, deleteFiles) {
load();
const it = items.find((x) => x.id === id);
@@ -1311,7 +1317,7 @@ function importLegacy(legacyDir) {
module.exports = {
init, getRoot, filesDir, allocFilePath, sanitize,
list, get, findBySource, listShelves, listTags,
add, importLocal, update, updateMany, remove, removeMany, attachFile,
add, importLocal, update, updateMany, remove, removeMany, removeMissing, attachFile,
addShelf, updateShelf, removeShelf,
addTag, updateTag, removeTag,
scan, migrateTo, finalizeMigration, rollbackMigration, importLegacy,
+229
View File
@@ -0,0 +1,229 @@
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),
initialChapterId: cleanText(input && input.initialChapterId, 500),
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(),
initialChapterId: session.initialChapterId,
language: session.language,
quality: session.quality
};
}
function meta(ownerId, id) {
return metaOf(owned(ownerId, id));
}
function abortManifest(manifest) {
for (const controller of manifest.controllers || []) controller.abort();
manifest.controllers.clear();
}
async function chapters(ownerId, id, page) {
const session = owned(ownerId, id);
const currentPage = Math.max(1, Math.floor(Number(page) || 1));
const cached = session.chapterPages.get(currentPage);
if (cached) return cached;
const result = await session.source.chapters(
session.mangaId,
currentPage,
{ language: session.language }
);
const items = (Array.isArray(result && result.items) ? result.items : [])
.filter((chapter) => chapter && chapter.chapterId && !chapter.external && !chapter.unavailable)
.map((chapter) => ({
chapterId: cleanText(chapter.chapterId, 500),
label: cleanText(chapter.label, 300) || '未命名章节',
volume: cleanText(chapter.volume, 80),
chapter: cleanText(chapter.chapter, 80),
pages: Math.max(0, Math.floor(Number(chapter.pages) || 0)),
group: cleanText(chapter.group, 200)
}));
for (const chapter of items) session.allowedChapterIds.add(chapter.chapterId);
const value = {
items,
page: currentPage,
maxPage: Math.max(currentPage, Math.floor(Number(result && result.maxPage) || 1))
};
session.chapterPages.set(currentPage, value);
return value;
}
async function chapterManifest(ownerId, id, chapterId) {
const session = owned(ownerId, id);
const normalizedId = cleanText(chapterId, 500);
if (!session.allowedChapterIds.has(normalizedId)) throw new Error('该章节不属于当前在线漫画会话');
const generation = ++session.manifestGeneration;
for (const manifest of session.manifests.values()) abortManifest(manifest);
const info = await session.source.chapterImageUrls(normalizedId, session.quality);
if (generation !== session.manifestGeneration) throw new Error('章节加载已取消');
const urls = Array.isArray(info && info.urls) ? info.urls.filter(Boolean) : [];
if (!urls.length) throw new Error('该章节没有可阅读的图片');
const manifestId = sessionId();
session.manifests.clear();
session.manifests.set(manifestId, {
chapterId: normalizedId,
urls,
headers: info.headers || {},
mustReport: !!info.mustReport,
version: 0,
refreshPromise: null,
controllers: new Set()
});
return { manifestId, pages: urls.length };
}
function mimeType(bytes) {
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'image/jpeg';
}
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
return 'image/png';
}
if (bytes.length >= 6 && /^GIF8[79]a$/.test(bytes.subarray(0, 6).toString('ascii'))) {
return 'image/gif';
}
if (bytes.length >= 12
&& bytes.subarray(0, 4).toString('ascii') === 'RIFF'
&& bytes.subarray(8, 12).toString('ascii') === 'WEBP') {
return 'image/webp';
}
throw new Error('在线漫画返回了不支持的图片格式');
}
async function image(ownerId, id, manifestId, index) {
const session = owned(ownerId, id);
const manifest = session.manifests.get(String(manifestId || ''));
const pageIndex = Math.floor(Number(index));
if (!manifest || !Number.isSafeInteger(pageIndex)
|| pageIndex < 0 || pageIndex >= manifest.urls.length) {
throw new Error('在线漫画图片请求无效');
}
const controller = new AbortController();
manifest.controllers.add(controller);
const version = manifest.version;
let bytes;
try {
bytes = await mangaDownload.fetchOnlineImage(
manifest.urls[pageIndex],
manifest.headers,
manifest.mustReport,
MAX_IMAGE_BYTES,
controller.signal
);
} catch (error) {
if (controller.signal.aborted) throw new Error('图片加载已取消');
if (manifest.version === version) {
if (!manifest.refreshPromise) {
manifest.refreshPromise = session.source
.chapterImageUrls(manifest.chapterId, session.quality)
.then((fresh) => {
const urls = Array.isArray(fresh && fresh.urls) ? fresh.urls.filter(Boolean) : [];
if (!urls[pageIndex]) throw error;
manifest.urls = urls;
manifest.headers = fresh.headers || {};
manifest.mustReport = !!fresh.mustReport;
manifest.version++;
})
.finally(() => { manifest.refreshPromise = null; });
}
await manifest.refreshPromise;
}
bytes = await mangaDownload.fetchOnlineImage(
manifest.urls[pageIndex],
manifest.headers,
manifest.mustReport,
MAX_IMAGE_BYTES,
controller.signal
);
} finally {
manifest.controllers.delete(controller);
}
if (bytes.length > MAX_IMAGE_BYTES) throw new Error('单张漫画图片超过 30 MB,已停止加载');
return { bytes, mimeType: mimeType(bytes) };
}
function close(ownerId, id) {
const session = owned(ownerId, id);
for (const manifest of session.manifests.values()) abortManifest(manifest);
sessions.delete(session.id);
return true;
}
function closeOwner(ownerId) {
for (const [id, session] of sessions) {
if (session.ownerId !== ownerId) continue;
for (const manifest of session.manifests.values()) abortManifest(manifest);
sessions.delete(id);
}
}
function reset() {
for (const session of sessions.values()) {
for (const manifest of session.manifests.values()) abortManifest(manifest);
}
sessions.clear();
}
module.exports = { create, meta, chapters, chapterManifest, image, close, closeOwner, reset };
+73
View File
@@ -0,0 +1,73 @@
const path = require('path');
const { pathToFileURL } = require('url');
const { BrowserWindow } = require('electron');
let mangaWindow = null;
function alive(win) {
return !!win && !win.isDestroyed();
}
function get() {
if (alive(mangaWindow)) return mangaWindow;
mangaWindow = null;
return null;
}
function create(rootDir, uiTheme) {
const existing = get();
if (existing) existing.destroy();
const win = new BrowserWindow({
width: 1180,
height: 900,
minWidth: 680,
minHeight: 520,
frame: false,
backgroundColor: '#111318',
icon: path.join(
rootDir,
'icons',
'dist',
uiTheme === 'light' ? 'book-ai-light.ico' : 'book-ai-dark.ico'
),
title: '在线漫画',
webPreferences: {
preload: path.join(rootDir, 'manga-online-preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: false
}
});
mangaWindow = win;
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
win.webContents.on('will-navigate', (event, url) => {
const expected = pathToFileURL(path.join(rootDir, 'src', 'ui', 'manga-online.html')).href;
if (!String(url).startsWith(expected)) event.preventDefault();
});
win.on('closed', () => {
if (mangaWindow === win) mangaWindow = null;
});
return win;
}
function load(rootDir, sessionId) {
const win = get();
if (!win) throw new Error('在线漫画窗口不可用');
win.loadFile(path.join(rootDir, 'src', 'ui', 'manga-online.html'), {
query: { sessionId: String(sessionId) }
});
return win;
}
function fromWebContents(webContents) {
const win = get();
return win && win.webContents.id === webContents.id;
}
function all() {
const win = get();
return win ? [win] : [];
}
module.exports = { create, load, get, fromWebContents, all };
+228
View File
@@ -0,0 +1,228 @@
const { fetchJson, clampPage } = require('./http');
const { tryMirrors } = require('./mirror');
const mangaDownload = require('../library/manga-download');
const MIRRORS = [
'https://api.2024manga.com',
'https://api.mangacopy.com',
'https://api.copy-manga.com'
];
const PAGE_SIZE = 21;
const PLATFORM = '3';
const CACHE_TTL = 5 * 60 * 1000;
const detailCache = new Map();
const chapterCache = new Map();
const API_HEADERS = {
source: 'com.manga2020.app',
version: '2024.4.28',
Referer: 'https://www.mangacopy.com/'
};
function query(params) {
const value = new URLSearchParams();
for (const [key, item] of Object.entries(params)) {
if (item != null) value.set(key, item);
}
return value.toString();
}
async function api(path) {
return tryMirrors('copymanga', MIRRORS, (base) => (
fetchJson(`${base}${path}`, { headers: API_HEADERS, retries: 0, timeout: 20000 })
));
}
function detailUrl(slug) {
return `https://www.mangacopy.com/comic/${encodeURIComponent(slug)}`;
}
function names(value) {
return (Array.isArray(value) ? value : []).map((item) => item && item.name).filter(Boolean);
}
function toItem(item) {
return {
postId: item.path_word,
title: item.name || '(无标题)',
cover: item.cover || '',
date: item.datetime_updated || '',
url: detailUrl(item.path_word),
subtitle: names(item.author).join(', ')
};
}
function chapterNumber(chapter) {
const match = String(chapter.name || '').match(/(\d+(?:\.\d+)?)/);
if (match) return match[1];
return chapter.ordered ? String(Number(chapter.ordered) / 10) : '';
}
async function cached(cache, key, maxEntries, load) {
const existing = cache.get(key);
if (existing && existing.expiresAt > Date.now()) return existing.promise;
const promise = Promise.resolve().then(load);
cache.set(key, { promise, expiresAt: Date.now() + CACHE_TTL });
while (cache.size > maxEntries) cache.delete(cache.keys().next().value);
try {
return await promise;
} catch (error) {
cache.delete(key);
throw error;
}
}
function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
for (;;) {
const index = cursor++;
if (index >= items.length) return;
results[index] = await fn(items[index], index);
}
}
return Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
.then(() => results);
}
function book(slug) {
return cached(detailCache, slug, 50, async () => {
const json = await api(`/api/v3/comic2/${encodeURIComponent(slug)}?platform=${PLATFORM}`);
const results = json && json.results || {};
if (!results.comic || !results.comic.path_word) throw new Error('拷贝漫画未返回漫画详情');
return results;
});
}
function allChapters(slug) {
return cached(chapterCache, slug, 30, async () => {
const info = await book(slug);
const version = info.comic.reclass == null ? '2' : '';
const groups = Object.values(info.groups || {});
const requests = groups.flatMap((group) => {
const count = Math.max(1, Number(group.count) || 1);
const pages = Math.ceil(count / 100);
return Array.from({ length: pages }, (_, index) => ({ group, index }));
});
const chunks = await mapLimit(requests, 4, async ({ group, index }) => {
const qs = query({ limit: 100, offset: index * 100, platform: PLATFORM });
const json = await api(
`/api/v3/comic/${encodeURIComponent(slug)}/group/${encodeURIComponent(group.path_word)}/chapters?${qs}`
);
const list = json && json.results && json.results.list || [];
return list.map((chapter) => ({
chapterId: `${slug}|${version}|${chapter.uuid}`,
volume: '',
chapter: chapterNumber(chapter),
title: chapter.name || '',
label: chapter.name || '未命名章节',
translatedLanguage: 'zh',
pages: chapter.size || 0,
publishAt: chapter.datetime_created || '',
group: group.name || '',
external: false,
unavailable: false
}));
});
return chunks.flat();
});
}
module.exports = {
id: 'copymanga',
name: '拷贝漫画(实验)',
category: 'manga',
experimental: true,
supportsSearch: true,
chapterBased: true,
async list(page) {
page = clampPage(page);
const qs = query({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
ordering: '-datetime_updated',
platform: PLATFORM
});
const json = await api(`/api/v3/comics?${qs}`);
const data = json && json.results || {};
return {
items: (data.list || []).map(toItem).filter((item) => item.postId),
maxPage: Math.max(1, Math.ceil((Number(data.total) || 0) / PAGE_SIZE)),
page
};
},
async search(keyword, page) {
page = clampPage(page);
const value = String(keyword || '').trim();
if (!value) return { items: [], maxPage: 1, page };
const qs = query({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
q: value,
q_type: '',
platform: PLATFORM
});
const json = await api(`/api/v3/search/comic?${qs}`);
const data = json && json.results || {};
return {
items: (data.list || []).map(toItem).filter((item) => item.postId),
maxPage: Math.max(1, Math.ceil((Number(data.total) || 0) / PAGE_SIZE)),
page
};
},
async detail(postId) {
const data = await book(postId);
const comic = data.comic;
const tags = names(comic.theme).map((name) => `标签:${name}`);
if (comic.region && comic.region.display) tags.unshift(`地区:${comic.region.display}`);
if (comic.status && comic.status.display) tags.unshift(`状态:${comic.status.display}`);
const url = detailUrl(postId);
return {
postId,
title: comic.name || '(无标题)',
cover: comic.cover || '',
authors: names(comic.author),
date: comic.datetime_updated || '',
tags,
brief: comic.brief || '',
url,
links: [{ name: '拷贝漫画页面', url }],
originalLanguage: 'zh'
};
},
async download() {
throw new Error('拷贝漫画请先在章节列表中选择要下载的具体章节');
},
async chapters(postId, page) {
page = clampPage(page);
const items = await allChapters(postId);
const maxPage = Math.max(1, Math.ceil(items.length / 100));
return { items: items.slice((page - 1) * 100, page * 100), maxPage, page };
},
async chapterImageUrls(chapterId) {
const [slug, version, uuid] = String(chapterId || '').split('|');
if (!slug || !uuid || !['', '2'].includes(version)) throw new Error('拷贝漫画章节 ID 无效');
const json = await api(
`/api/v3/comic/${encodeURIComponent(slug)}/chapter${version}/${encodeURIComponent(uuid)}?platform=${PLATFORM}`
);
const contents = json && json.results && json.results.chapter && json.results.chapter.contents;
const urls = (contents || []).map((item) => item && item.url).filter(Boolean);
if (!urls.length) throw new Error('拷贝漫画未返回章节图片');
return {
urls,
mustReport: false,
quality: 'data',
headers: { Referer: 'https://www.mangacopy.com/' }
};
},
async downloadChapter(library, payload, onProgress) {
return mangaDownload.downloadChapter(this, library, payload, onProgress);
}
};
+37 -2
View File
@@ -1,4 +1,4 @@
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 PeopleLib/2.1.0 (+https://github.com/lofyer/peoplelib)';
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 PeopleLib/2.1.3 (+https://github.com/lofyer/peoplelib)';
const { fetch: undiciFetch, ProxyAgent } = require('undici');
// 代理配置:默认直连(空字符串)。一旦设置,所有 HTTP 请求统一走该代理。
@@ -38,6 +38,37 @@ function fetchWithElectron(url, options = {}) {
return fetchWithProxy(url, options);
}
async function responseBytes(res, maxBytes) {
const declared = Number(res.headers && res.headers.get && res.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
try { if (res.body && res.body.cancel) await res.body.cancel(); } catch (e) { /* ignore */ }
return null;
}
if (!res.body || typeof res.body.getReader !== 'function') {
const bytes = Buffer.from(await res.arrayBuffer());
return bytes.length <= maxBytes ? bytes : null;
}
const reader = res.body.getReader();
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
size += chunk.length;
if (size > maxBytes) {
await reader.cancel();
return null;
}
chunks.push(chunk);
}
} finally {
try { reader.releaseLock(); } catch (e) { /* ignore */ }
}
return Buffer.concat(chunks, size);
}
// 简易 cookie jar: Map<domain, Map<name, value>>
const cookieJar = new Map();
@@ -218,4 +249,8 @@ async function withRetry(fn, { tries = 2, delay = 1000 } = {}) {
}
}
module.exports = { UA, fetchRaw, fetchText, fetchJson, decodeEntities, stripTags, clampPage, tooShort, isRetryable, withRetry, getCookies, setCookies, clearCookies, setProxy, getProxy, fetchWithProxy };
module.exports = {
UA, fetchRaw, fetchText, fetchJson, responseBytes, decodeEntities, stripTags,
clampPage, tooShort, isRetryable, withRetry, getCookies, setCookies, clearCookies,
setProxy, getProxy, fetchWithProxy
};
+33 -2
View File
@@ -14,6 +14,27 @@ const openstax = require('./openstax');
const opentextbook = require('./opentextbook');
const wikisourceZh = require('./wikisource-zh');
const wikisourceEn = require('./wikisource-en');
const mangadex = require('./mangadex');
const copymanga = require('./copymanga');
const CATEGORY_BY_ID = {
arxiv: 'academic',
doaj: 'academic',
pmc: 'academic',
biorxiv: 'academic',
semanticscholar: 'academic',
scihub: 'academic',
gutenberg: 'books',
openlibrary: 'books',
standardebooks: 'books',
libgen: 'books',
zlib: 'books',
openstax: 'open',
opentextbook: 'open',
'wikisource-zh': 'open',
'wikisource-en': 'open',
motw: 'archive'
};
const sources = [
arxiv,
@@ -31,12 +52,22 @@ const sources = [
libgen,
zlib,
scihub,
motw
motw,
mangadex,
copymanga
];
const byId = new Map(sources.map((s) => [s.id, s]));
function listSources() {
return sources.map((s) => ({ id: s.id, name: s.name, supportsSearch: s.supportsSearch !== false }));
return sources.map((s) => ({
id: s.id,
name: s.name,
category: s.category || CATEGORY_BY_ID[s.id] || 'other',
experimental: s.experimental === true,
supportsSearch: s.supportsSearch !== false,
downloadOnDemand: s.downloadOnDemand === true,
chapterBased: s.chapterBased === true
}));
}
function getSource(id) {
+248
View File
@@ -0,0 +1,248 @@
// MangaDex 数据源:公开漫画聚合站,官方文档化 REST API(无需登录即可只读浏览)。
// 与其它源的关键差异:条目本身不是可下载的单文件,而是一部漫画下的多个章节,
// 每个章节又是几十张图片。list/search/detail 沿用通用接口语义(对象是"一部漫画"),
// download() 按接口约定必须存在但没有意义,交给 chapters()/atHome() 支撑的专用
// 下载流程(组装漫画 EPUB,见 main.js 与 src/library/manga-epub.js)。
//
// 图片必须由服务端代理转发:MangaDex 明确禁止渲染层热链其图片域名
// https://api.mangadex.org/docs/2-limitations/),这与本仓库“渲染层不直接
// 访问外部资源”的既有边界天然吻合。
//
// MangaDex@Home 的图片分发要求调用方对每张图片上报成功/失败
// POST https://api.mangadex.network/report),否则健康检测无法剔除故障节点;
// 这一步在实际下载编排里完成(main.js),本模块只负责取地址。
const { fetchJson, clampPage } = require('./http');
const mangaDownload = require('../library/manga-download');
const BASE = 'https://api.mangadex.org';
const PAGE_SIZE = 20;
const CHAPTER_PAGE_SIZE = 100;
const MAX_OFFSET_TOTAL = 10000; // 接口硬限制:offset + size 不能超过 10000
// 标题/标签是 LocalizedString{ en: '...', ja: '...' } 形式),按偏好语言取值。
const PREFERRED_LANGS = ['zh', 'zh-hk', 'en', 'ja-ro', 'ja'];
function pickLocalized(obj) {
if (!obj || typeof obj !== 'object') return '';
for (const lang of PREFERRED_LANGS) {
if (obj[lang]) return obj[lang];
}
const first = Object.values(obj).find(Boolean);
return first || '';
}
function pickTitle(attrs) {
const direct = pickLocalized(attrs && attrs.title);
if (direct) return direct;
for (const alt of (attrs && attrs.altTitles) || []) {
const t = pickLocalized(alt);
if (t) return t;
}
return '(无标题)';
}
function buildQuery(params) {
const q = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value == null) continue;
if (Array.isArray(value)) value.forEach((item) => q.append(key, item));
else q.append(key, value);
}
return q.toString();
}
function findRelationship(relationships, type) {
return (relationships || []).find((r) => r.type === type);
}
function coverUrl(mangaId, relationships) {
const cover = findRelationship(relationships, 'cover_art');
const fileName = cover && cover.attributes && cover.attributes.fileName;
if (!fileName) return '';
return `https://uploads.mangadex.org/covers/${mangaId}/${fileName}.512.jpg`;
}
function authorNames(relationships) {
return [...new Set((relationships || [])
.filter((r) => r.type === 'author' || r.type === 'artist')
.map((r) => r.attributes && r.attributes.name)
.filter(Boolean))];
}
function mangaUrl(id) { return `https://mangadex.org/title/${id}`; }
function toItem(m) {
const attrs = m.attributes || {};
return {
postId: m.id,
title: pickTitle(attrs),
cover: coverUrl(m.id, m.relationships),
date: attrs.year ? String(attrs.year) : '',
url: mangaUrl(m.id),
subtitle: authorNames(m.relationships).slice(0, 3).join(', ')
};
}
const STATUS_LABEL = { ongoing: '连载中', completed: '已完结', hiatus: '暂停', cancelled: '已取消' };
const DEMOGRAPHIC_LABEL = { shounen: '少年', shoujo: '少女', josei: '女性向', seinen: '青年' };
function chapterLabel(attrs) {
const parts = [];
if (attrs.volume) parts.push(`${attrs.volume}`);
parts.push(attrs.chapter ? `${attrs.chapter}` : '单话');
if (attrs.title) parts.push(attrs.title);
return parts.join(' ') || '未命名章节';
}
function toChapterItem(c) {
const attrs = c.attributes || {};
const group = findRelationship(c.relationships, 'scanlation_group');
return {
chapterId: c.id,
volume: attrs.volume || '',
chapter: attrs.chapter || '',
title: attrs.title || '',
label: chapterLabel(attrs),
translatedLanguage: attrs.translatedLanguage || '',
pages: attrs.pages || 0,
publishAt: attrs.publishAt || '',
group: (group && group.attributes && group.attributes.name) || '',
external: !!attrs.externalUrl,
unavailable: !!attrs.isUnavailable
};
}
function clampedMaxPage(total, pageSize) {
return Math.max(1, Math.ceil(Math.min(total, MAX_OFFSET_TOTAL) / pageSize));
}
async function fetchMangaList(query, page) {
page = clampPage(page);
const offset = (page - 1) * PAGE_SIZE;
const qs = buildQuery({
...query,
limit: PAGE_SIZE,
offset,
'includes[]': ['cover_art', 'author', 'artist']
});
const j = await fetchJson(`${BASE}/manga?${qs}`);
const total = j.total || 0;
return { items: (j.data || []).map(toItem), maxPage: clampedMaxPage(total, PAGE_SIZE), page };
}
module.exports = {
id: 'mangadex',
name: 'MangaDex 漫画',
category: 'manga',
supportsSearch: true,
// 与 zlib 的 downloadOnDemand 语义不同:这里不是"点击才解析",而是条目本身
// 就没有单一下载文件,必须先选具体章节。sources/index.js 与 browse.js 用这个
// 标记切换到章节列表面板,而不是通用的下载框。
chapterBased: true,
// 默认只看安全/暗示性内容,不含情色/色情分级:面向通用书库场景的保守默认值,
// 与年龄分级过滤无关的高级选项超出本次范围。
async list(page) {
return fetchMangaList({
'order[followedCount]': 'desc',
'contentRating[]': ['safe', 'suggestive']
}, page);
},
async search(keyword, page) {
const title = String(keyword || '').trim();
if (!title) return { items: [], maxPage: 1, page: clampPage(page) };
return fetchMangaList({
title,
'contentRating[]': ['safe', 'suggestive']
}, page);
},
async detail(postId) {
const qs = buildQuery({ 'includes[]': ['cover_art', 'author', 'artist'] });
const j = await fetchJson(`${BASE}/manga/${encodeURIComponent(postId)}?${qs}`);
const m = j.data;
if (!m) throw new Error('未找到该漫画');
const attrs = m.attributes || {};
const tags = [];
if (attrs.status) tags.push(`状态:${STATUS_LABEL[attrs.status] || attrs.status}`);
if (attrs.publicationDemographic) {
tags.push(`分级:${DEMOGRAPHIC_LABEL[attrs.publicationDemographic] || attrs.publicationDemographic}`);
}
for (const tag of attrs.tags || []) {
const name = pickLocalized(tag.attributes && tag.attributes.name);
if (name) tags.push(`标签:${name}`);
}
return {
postId: m.id,
title: pickTitle(attrs),
cover: coverUrl(m.id, m.relationships),
authors: authorNames(m.relationships),
date: attrs.year ? String(attrs.year) : '',
tags,
brief: pickLocalized(attrs.description),
url: mangaUrl(m.id),
links: [{ name: 'MangaDex 页', url: mangaUrl(m.id) }],
// 供下载编排复用,避免为拼 EPUB 元数据再多打一次详情请求
originalLanguage: attrs.originalLanguage || 'ja'
};
},
// 按接口约定必须存在,但漫画没有"整部下载"的单一文件;
// 真正的下载走 chapters() 选出具体章节后调用 atHome()。
async download() {
throw new Error('MangaDex 请先在章节列表中选择要下载的具体章节');
},
// 不按语言过滤:不同语言的翻译组各自独立,筛选逻辑交给调用方按 translatedLanguage 分组展示,
// 避免对用户能读什么语言做隐性假设。
async chapters(mangaId, page, options) {
page = clampPage(page);
const offset = (page - 1) * CHAPTER_PAGE_SIZE;
const language = options && ['zh', 'zh-hk', 'all'].includes(options.language)
? options.language
: 'zh';
const qs = buildQuery({
limit: CHAPTER_PAGE_SIZE,
offset,
'order[volume]': 'asc',
'order[chapter]': 'asc',
'includes[]': ['scanlation_group'],
'translatedLanguage[]': language === 'all' ? null : [language]
});
const j = await fetchJson(`${BASE}/manga/${encodeURIComponent(mangaId)}/feed?${qs}`);
const total = j.total || 0;
return {
items: (j.data || []).map(toChapterItem),
maxPage: clampedMaxPage(total, CHAPTER_PAGE_SIZE),
page
};
},
// baseUrl 只保证 15 分钟有效,调用方不能缓存它去拼后续的图片地址。
async atHome(chapterId, forcePort443 = false) {
const qs = forcePort443 ? '?forcePort443=true' : '';
const j = await fetchJson(`${BASE}/at-home/server/${encodeURIComponent(chapterId)}${qs}`);
if (!j || !j.baseUrl || !j.chapter || !j.chapter.hash) throw new Error('获取章节图片地址失败');
return j;
},
// quality: 'data'(原画质)| 'dataSaver'(压缩,默认)
async chapterImageUrls(chapterId, quality) {
const q = quality === 'data' ? 'data' : 'dataSaver';
const pathSegment = q === 'data' ? 'data' : 'data-saver';
const info = await this.atHome(chapterId);
const files = q === 'data' ? info.chapter.data : info.chapter.dataSaver;
if (!Array.isArray(files) || !files.length) throw new Error('该章节没有可下载的图片');
const urls = files.map((name) => `${info.baseUrl}/${pathSegment}/${info.chapter.hash}/${name}`);
// 官方主域名(mangadex.org)不需要健康上报;只有转发到 @Home 志愿节点时才需要,
// 用 baseUrl 是否落在主域名判断,不能靠是否有端口号等启发式。
const mustReport = !/(^|\.)mangadex\.org(:|\/|$)/i.test(new URL(info.baseUrl).hostname);
return { urls, mustReport, quality: q };
},
async downloadChapter(library, payload, onProgress) {
return mangaDownload.downloadChapter(this, library, payload, onProgress);
}
};
+25 -1
View File
@@ -123,11 +123,32 @@ function write(creds) {
}
// 清除全部(含凭据)——用于"退出登录"
// 自定义镜像是站点配置不是凭据,退出登录后要保留,否则用户每次重新登录
// 都得重新填一遍地址,而默认域名可能全都已经失效。
function clear() {
sessionCreds = null;
const mirrors = getCustomMirrors();
for (const fp of [getFilePath(), getCredPath(), `${getFilePath()}.tmp`, `${getCredPath()}.tmp`]) {
try { fs.unlinkSync(fp); } catch (e) { /* ignore */ }
}
if (mirrors.length) {
try { atomicWrite(getFilePath(), JSON.stringify({ customMirrors: mirrors }, null, 2)); }
catch (e) { /* 保留失败不影响退出登录 */ }
}
}
function getCustomMirrors() {
const meta = readMeta();
return (meta && Array.isArray(meta.customMirrors)) ? meta.customMirrors.slice() : [];
}
// 只动镜像字段:与 setSession 同理,不重写密文,避免一次读取失败清空凭据
function setCustomMirrors(list) {
const meta = readMeta() || {};
const next = { ...meta };
if (Array.isArray(list) && list.length) next.customMirrors = list.slice();
else delete next.customMirrors;
atomicWrite(getFilePath(), JSON.stringify(next, null, 2));
}
// 只清除会话令牌,保留邮箱密码以便自动重新登录
@@ -158,4 +179,7 @@ function setSession(userId, userKey, mirror) {
atomicWrite(getFilePath(), JSON.stringify(next, null, 2));
}
module.exports = { init, read, write, clear, clearSession, hasCreds, getSession, setSession };
module.exports = {
init, read, write, clear, clearSession, hasCreds, getSession, setSession,
getCustomMirrors, setCustomMirrors
};
+67 -6
View File
@@ -43,13 +43,40 @@ function requestHeaders(mirror, includeForm = false) {
}
function getMirrors() {
const custom = (auth.read() || {}).customMirrors;
if (Array.isArray(custom) && custom.length) {
const custom = auth.getCustomMirrors();
if (custom.length) {
return custom.concat(DEFAULT_MIRRORS.filter((m) => !custom.includes(m)));
}
return DEFAULT_MIRRORS.slice();
}
const MAX_CUSTOM_MIRRORS = 20;
// 镜像地址来自用户输入,会被直接拼进请求 URL,因此必须按 origin 归一:
// 带路径、查询串或凭据的地址会让后续 apiUrl() 拼出错误甚至泄露凭据的 URL。
function normalizeMirror(value) {
const raw = String(value || '').trim();
if (!raw) return '';
let url;
try { url = new URL(raw); } catch (e) { throw new Error(`镜像地址无效:${raw}`); }
if (url.protocol !== 'https:') throw new Error(`镜像必须使用 HTTPS${raw}`);
if (url.username || url.password) throw new Error(`镜像地址不能包含账号信息:${raw}`);
return url.origin;
}
function normalizeMirrors(list) {
if (!Array.isArray(list)) throw new Error('镜像列表格式无效');
if (list.length > MAX_CUSTOM_MIRRORS) {
throw new Error(`最多只能保存 ${MAX_CUSTOM_MIRRORS} 个镜像`);
}
const out = [];
for (const item of list) {
const origin = normalizeMirror(item);
if (origin && !out.includes(origin)) out.push(origin);
}
return out;
}
function apiUrl(base, path, params = {}) {
const u = new URL(base + path);
for (const [k, v] of Object.entries(params)) {
@@ -188,12 +215,17 @@ async function callOn(mirror, path, params, session, method) {
}
async function attempt(session, path, params, method) {
const custom = auth.getCustomMirrors();
const mirrors = getMirrors();
const ordered = session.mirror
// 用户显式配置镜像后必须严格优先于旧会话镜像,否则界面写着“自定义优先”,
// 实际却仍先等待上次成功但现在已失效的默认域名。
const ordered = custom.length
? mirrors
: session.mirror
? [session.mirror, ...mirrors.filter((m) => m !== session.mirror)]
: mirrors;
let lastErr;
const errors = [];
for (const m of ordered) {
try {
const j = await callOn(m, path, params, session, method);
@@ -201,10 +233,18 @@ async function attempt(session, path, params, method) {
return { ok: true, data: j };
} catch (e) {
if (e.code === 'AUTH_STALE') return { ok: false, stale: true, error: e };
lastErr = e;
errors.push(e);
}
}
return { ok: false, stale: false, error: lastErr };
if (errors.length <= 1) {
return { ok: false, stale: false, error: errors[0] };
}
const first = errors[0];
const label = custom.length ? '自定义镜像失败' : '首选镜像失败';
const summary = new Error(
`${label}${(first && first.message) || String(first)};其余 ${errors.length - 1} 个镜像也未成功`
);
return { ok: false, stale: false, error: summary };
}
async function apiCall(path, params = {}, method = 'GET') {
@@ -270,6 +310,7 @@ module.exports = {
id: 'zlib',
name: 'Z-Library',
supportsSearch: true,
downloadOnDemand: true,
// 无关键词时展示热门书目
async list(page) {
@@ -378,5 +419,25 @@ module.exports = {
return auth.hasCreds();
},
// 默认域名变动频繁且会整批失效,用户需要能自己补上可用地址而不必等新版本
getMirrorConfig() {
return { custom: auth.getCustomMirrors(), defaults: DEFAULT_MIRRORS.slice() };
},
setMirrors(list) {
const next = normalizeMirrors(list);
const previous = auth.getCustomMirrors();
auth.setCustomMirrors(next);
// 换镜像后旧会话绑定的域名可能已不在列表里,留着会一直优先命中失效地址
const session = auth.getSession();
if (session && session.mirror && !getMirrors().includes(session.mirror)) {
auth.clearSession();
}
for (const mirror of previous) {
if (!next.includes(mirror)) clearCookies(mirror);
}
return { custom: next, defaults: DEFAULT_MIRRORS.slice() };
},
setLoginTransport
};
+52
View File
@@ -124,6 +124,38 @@ $('zlibLogoutBtn').onclick = async () => {
refreshZlibStatus();
async function refreshZlibMirrors() {
const r = await window.api.zlib.getMirrors();
const custom = (r.ok && r.data && r.data.custom) || [];
$('zlibMirrorStatus').textContent = custom.length
? `已自定义 ${custom.length} 个镜像,优先于内置列表`
: '使用内置镜像列表';
}
$('zlibMirrorBtn').onclick = async () => {
const current = await window.api.zlib.getMirrors();
if (!current.ok) return;
const custom = (current.data && current.data.custom) || [];
const defaults = (current.data && current.data.defaults) || [];
const r = await openModal('Z-Library 镜像站点', `
<p style="margin-bottom:8px;">一行一个地址,必须是 HTTPS。自定义镜像会排在内置列表前面优先尝试;留空则只用内置列表。</p>
<div style="display:flex;flex-direction:column;gap:8px;">
<textarea id="zlibMirrorList" class="modal-input" rows="6" spellcheck="false"
placeholder="https://example.org">${escapeHtml(custom.join('\n'))}</textarea>
<div class="settings-item-desc">内置镜像:${escapeHtml(defaults.join('、'))}</div>
<div id="zlibMirrorErr" class="note-form-error"></div>
</div>
`, async () => {
const lines = $('zlibMirrorList').value.split('\n').map((s) => s.trim()).filter(Boolean);
const res = await window.api.zlib.setMirrors(lines);
if (!res.ok) { $('zlibMirrorErr').textContent = res.error || '保存失败'; return false; }
return true;
});
if (r) { refreshZlibMirrors(); refreshZlibStatus(); }
};
refreshZlibMirrors();
async function refreshLibraryDir() {
const r = await window.api.library.getDir();
if (r.ok && r.data) $('libDirPath').textContent = r.data.dir + (r.data.isDefault ? '(默认)' : '');
@@ -172,8 +204,28 @@ async function refreshAskSave() {
}
$('askSaveChk').onchange = () => window.api.settings.set('askSavePath', $('askSaveChk').checked);
async function refreshMangadexQuality() {
const r = await window.api.settings.get('mangadex.imageQuality', 'dataSaver');
$('mangadexQualitySelect').value = (r.ok && r.data === 'data') ? 'data' : 'dataSaver';
}
$('mangadexQualitySelect').onchange = () => (
window.api.settings.set('mangadex.imageQuality', $('mangadexQualitySelect').value)
);
async function refreshMangadexLanguage() {
const r = await window.api.settings.get('mangadex.chapterLanguage', 'zh');
$('mangadexLanguageSelect').value = r.ok && ['zh', 'zh-hk', 'all'].includes(r.data)
? r.data
: 'zh';
}
$('mangadexLanguageSelect').onchange = () => (
window.api.settings.set('mangadex.chapterLanguage', $('mangadexLanguageSelect').value)
);
refreshLibraryDir();
refreshAskSave();
refreshMangadexQuality();
refreshMangadexLanguage();
async function refreshProxy() {
const r = await window.api.proxy.get();
+36 -2
View File
@@ -69,8 +69,12 @@
<button id="addShelfBtn" class="notes-icon-btn" title="新建书架" aria-label="新建书架">+</button>
</div>
<div class="library-filter-list">
<button class="library-filter active" data-shelf="">全部书籍</button>
<button class="library-filter" data-shelf="__uncategorized__">未分类</button>
<button class="library-filter active" data-shelf="">
<span>全部书籍</span><span class="library-filter-count">0</span>
</button>
<button class="library-filter" data-shelf="__uncategorized__">
<span>未分类</span><span class="library-filter-count">0</span>
</button>
<div id="libraryShelfList" class="library-filter-list"></div>
</div>
<div class="library-sidebar-section">
@@ -191,6 +195,29 @@
<div id="sourceList" class="source-list"></div>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">MangaDex 章节语言</div>
<div class="settings-item-desc">默认只显示简体中文章节,也可切换到繁体中文或全部语言</div>
</div>
<select id="mangadexLanguageSelect" class="source-select">
<option value="zh">简体中文</option>
<option value="zh-hk">繁体中文</option>
<option value="all">全部语言</option>
</select>
</div>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">漫画图片画质</div>
<div class="settings-item-desc">压缩画质体积小、下载快;原画质更清晰但占用更多空间与流量</div>
</div>
<select id="mangadexQualitySelect" class="source-select">
<option value="dataSaver">压缩画质</option>
<option value="data">原画质</option>
</select>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
<div class="settings-item-info">
@@ -304,6 +331,13 @@
<button id="zlibLoginBtn" class="tb-btn">登录</button>
<button id="zlibLogoutBtn" class="tb-btn ghost hidden">退出</button>
</div>
<div class="settings-item">
<div class="settings-item-info">
<div class="settings-item-label">镜像站点</div>
<div class="settings-item-desc" id="zlibMirrorStatus">使用内置镜像列表</div>
</div>
<button id="zlibMirrorBtn" class="tb-btn">设置</button>
</div>
</div>
<div class="settings-group">
<div class="settings-item">
+277
View File
@@ -0,0 +1,277 @@
: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;
overflow-anchor: none;
overscroll-behavior: contain;
background: var(--bg);
}
.reader-status {
max-width: 720px;
margin: 28px auto;
padding: 16px;
color: var(--muted);
text-align: center;
}
.reader-status.error { color: #e36b66; }
.page-stack {
width: min(var(--manga-page-width, 900px), 100%);
margin: 0 auto;
background: #0b0c0f;
}
.manga-page {
position: relative;
width: 100%;
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: #8f98a6;
background: #11141a;
border-bottom: 1px solid #252a33;
}
.manga-page::before {
content: attr(data-placeholder);
padding: 30px;
text-align: center;
}
.manga-page.loaded { min-height: 0; }
.manga-page.loaded::before { display: none; }
.manga-page.error { min-height: 160px; color: #e36b66; cursor: pointer; }
.manga-page img {
display: block;
width: 100%;
height: auto;
}
.chapter-end {
min-width: 220px;
min-height: 42px;
display: block;
margin: 24px auto 44px;
padding: 0 24px;
}
@media (max-width: 850px) {
.width-control { display: none; }
.quality-label { display: none; }
.toc-pane {
position: absolute;
z-index: 5;
top: 92px;
bottom: 0;
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.28);
}
}
+61
View File
@@ -0,0 +1,61 @@
<!doctype html>
<html lang="zh-CN" data-ui-theme="dark">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self'" />
<title>在线漫画</title>
<link rel="stylesheet" href="manga-online.css" />
</head>
<body>
<header class="titlebar">
<div class="brand">
<img class="brand-logo brand-logo-dark" src="../../icons/dist/dark/icon-32.png" alt="" />
<img class="brand-logo brand-logo-light" src="../../icons/dist/light/icon-32.png" alt="" />
<span>在线漫画</span>
<span id="bookTitle" class="brand-sub">正在打开…</span>
</div>
<div class="titlebar-spacer"></div>
<div class="titlebar-controls">
<button id="themeBtn" class="win-btn" title="切换主题" aria-label="切换主题"></button>
<button id="minBtn" class="win-btn" title="最小化"></button>
<button id="maxBtn" class="win-btn" title="最大化"></button>
<button id="closeBtn" class="win-btn win-close" title="关闭">×</button>
</div>
</header>
<div class="toolbar">
<button id="tocToggleBtn" class="tool-btn">目录</button>
<button id="prevChapterBtn" class="tool-btn">上一章</button>
<span id="chapterTitle" class="chapter-title">尚未加载章节</span>
<button id="nextChapterBtn" class="tool-btn">下一章</button>
<label class="width-control">
页面宽度
<input id="pageWidthRange" type="range" min="480" max="1400" step="40" value="900" />
<span id="pageWidthLabel">900px</span>
</label>
<span id="qualityLabel" class="quality-label"></span>
</div>
<div class="reader-layout">
<aside id="tocPane" class="toc-pane">
<div class="toc-head">
<div>
<strong id="tocTitle">章节目录</strong>
<div id="tocMeta" class="toc-meta"></div>
</div>
<button id="tocCloseBtn" class="icon-btn" title="收起目录">×</button>
</div>
<div id="chapterList" class="chapter-list"></div>
<button id="loadMoreBtn" class="load-more hidden">加载更多章节</button>
</aside>
<main id="pageScroller" class="page-scroller">
<div id="readerStatus" class="reader-status">正在读取漫画信息…</div>
<div id="pageStack" class="page-stack"></div>
<button id="chapterEndNextBtn" class="chapter-end hidden">继续阅读下一章</button>
</main>
</div>
<script src="manga-online.js"></script>
</body>
</html>
+322
View File
@@ -0,0 +1,322 @@
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;
}
let initialIndex = state.meta.initialChapterId
? state.chapters.findIndex((chapter) => chapter.chapterId === state.meta.initialChapterId)
: 0;
while (initialIndex < 0 && state.chapterPage < state.chapterMaxPage) {
if (!await loadChapterPage(state.chapterPage + 1)) return;
initialIndex = state.chapters.findIndex(
(chapter) => chapter.chapterId === state.meta.initialChapterId
);
}
openChapter(initialIndex < 0 ? 0 : initialIndex);
}
$('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));
+37 -1
View File
@@ -485,6 +485,7 @@ body {
.meta-row a { color: var(--accent); text-decoration: none; word-break: break-all; }
.meta-row a:hover { text-decoration: underline; }
.add-lib-btn { margin-top: 10px; }
.online-read-btn { margin: 10px 8px 0 0; }
.add-lib-hint { margin-top: 6px; font-size: 12px; color: var(--text-dim); }
.section-title { font-size: 14px; font-weight: 700; color: var(--accent-bright); margin: 18px 0 10px; }
@@ -494,6 +495,10 @@ body {
max-height: 220px; overflow-y: auto;
}
/* 章节列表(MangaDex 等按章下载的源) */
.chapter-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.chapter-page-info { color: var(--text-dim); font-size: 12px; margin-right: auto; }
/* 下载区 */
.download-box { display: flex; flex-direction: column; gap: 10px; }
.dl-loading, .dl-error { color: var(--text-dim); font-size: 13px; padding: 10px 0; }
@@ -714,6 +719,16 @@ body {
.library-tag-row { position: relative; display: flex; align-items: center; }
.library-shelf-row .library-filter,
.library-tag-row .library-filter { flex: 1; padding-right: 48px; }
.library-shelf-row .library-filter {
display: flex;
align-items: center;
}
.library-shelf-row .library-filter > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-shelf-actions,
.library-tag-actions {
position: absolute;
@@ -727,8 +742,24 @@ body {
.library-tag-row:hover .library-tag-actions,
.library-tag-row:focus-within .library-tag-actions { opacity: 1; pointer-events: auto; }
.library-shelf-actions .notes-icon-btn,
.library-tag-actions .notes-icon-btn { width: 22px; height: 22px; font-size: 12px; }
.library-tag-actions .notes-icon-btn {
width: 22px;
height: 22px;
background: transparent;
border-color: transparent;
font-size: 12px;
}
.library-shelf-actions .notes-icon-btn:hover,
.library-tag-actions .notes-icon-btn:hover {
background: transparent;
border-color: transparent;
}
.library-filter-count { float: right; color: var(--text-dim); }
#libraryTab .library-filter[data-shelf] .library-filter-count,
.library-shelf-row .library-filter-count {
flex: none;
float: none;
}
.library-content { min-width: 0; }
.library-search {
display: flex;
@@ -1510,6 +1541,11 @@ body {
}
.modal-body input[type="text"]:focus,
.modal-input:focus { border-color: var(--accent); }
/* .modal-input 写死了单行高度,多行输入必须覆盖,否则 textarea 被压成一行 */
textarea.modal-input {
height: auto; min-height: 92px; padding: 8px 12px;
line-height: 1.6; resize: vertical; font-family: inherit;
}
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; }
.modal-actions > .tb-btn { width: 72px; height: 32px; padding: 0; }
+277 -9
View File
@@ -1,6 +1,14 @@
const Browse = (() => {
const AGG_ID = '__all__';
const AGG_LIMIT = 5;
const SOURCE_CATEGORIES = [
['academic', '学术论文'],
['books', '电子书'],
['open', '开放教材与文库'],
['archive', '档案与特藏'],
['manga', '漫画'],
['other', '其他']
];
const state = {
sourceId: null,
@@ -16,7 +24,10 @@ const Browse = (() => {
detailToken: 0,
activeSourceId: null,
currentPostId: null,
currentDetail: null
currentDetail: null,
chapterToken: 0,
chapterPage: 1,
chapterMaxPage: 1
};
let grid, statusBar, pager, gridView, detailView, detailContent, mainEl, sourceSelect, searchInput;
@@ -77,8 +88,15 @@ const Browse = (() => {
state.sourceList = list;
const aggOpt = list.filter((s) => s.supportsSearch).length > 1
? `<option value="${AGG_ID}" data-search="1">🔎 聚合搜索(全部源)</option>` : '';
sourceSelect.innerHTML = aggOpt + list.map((s) =>
`<option value="${escapeHtml(s.id)}" data-search="${s.supportsSearch ? '1' : '0'}">${escapeHtml(s.name)}</option>`).join('');
const grouped = SOURCE_CATEGORIES.map(([id, label]) => {
const entries = list.filter((source) => source.category === id);
if (!entries.length) return '';
const options = entries.map((source) => (
`<option value="${escapeHtml(source.id)}" data-search="${source.supportsSearch ? '1' : '0'}">${escapeHtml(source.name)}</option>`
)).join('');
return `<optgroup label="${label}">${options}</optgroup>`;
}).join('');
sourceSelect.innerHTML = aggOpt + grouped;
if (!list.length) {
state.sourceId = null;
grid.innerHTML = '<div class="empty">未启用任何数据源,请在设置中开启</div>';
@@ -313,8 +331,16 @@ const Browse = (() => {
return;
}
state.currentDetail = res.data;
renderDetail(res.data);
loadDownload(postId, activeSourceId, token);
const chapterBased = sourceIsChapterBased(activeSourceId);
renderDetail(res.data, chapterBased);
if (chapterBased) {
state.chapterPage = 1;
loadChapters(postId, token, 1);
} else if (sourceDownloadsOnDemand(activeSourceId)) {
renderOnDemandDownload(postId, activeSourceId, token);
} else {
loadDownload(postId, activeSourceId, token);
}
refreshAddButton();
}
@@ -323,7 +349,17 @@ const Browse = (() => {
return s ? s.name : id;
}
function renderDetail(d) {
function sourceDownloadsOnDemand(id) {
const source = state.sourceList.find((item) => item.id === id);
return !!(source && source.downloadOnDemand);
}
function sourceIsChapterBased(id) {
const source = state.sourceList.find((item) => item.id === id);
return !!(source && source.chapterBased);
}
function renderDetail(d, chapterBased) {
const tagsHtml = (d.tags || []).map((t) => {
const idx = t.indexOf('');
if (idx > 0) return `<div class="meta-row"><b>${escapeHtml(t.slice(0, idx))}</b>${escapeHtml(t.slice(idx + 1))}</div>`;
@@ -332,6 +368,15 @@ const Browse = (() => {
const authorsHtml = (d.authors && d.authors.length)
? `<div class="detail-authors">${escapeHtml(d.authors.join(', '))}</div>` : '';
const briefHtml = d.brief ? `<div class="section-title">简介 / 摘要</div><div class="brief-panel">${escapeHtml(d.brief)}</div>` : '';
const sectionTitle = chapterBased ? '章节列表' : '下载 / 全文';
const sectionBody = chapterBased
? `<div class="chapter-toolbar">
<span class="chapter-page-info" id="chapterPageInfo"></span>
<button class="tb-btn ghost sm" id="chapterPrevBtn">上一页</button>
<button class="tb-btn ghost sm" id="chapterNextBtn">下一页</button>
</div>
<div class="dl-files" id="chapterList"><div class="dl-loading">加载中...</div></div>`
: '<div class="download-box" id="downloadBox"><div class="dl-loading">下载信息获取中...</div></div>';
detailContent.innerHTML = `
<div class="detail-head">
@@ -343,17 +388,209 @@ const Browse = (() => {
${d.date ? `<div class="meta-row"><b>日期</b>${escapeHtml(d.date)}</div>` : ''}
${tagsHtml}
${d.url ? `<div class="meta-row"><b>原始链接</b><a href="#" id="detailUrlLink">${escapeHtml(d.url)}</a></div>` : ''}
${chapterBased ? '<button class="tb-btn online-read-btn" id="onlineReadBtn">在线阅读整部漫画</button>' : ''}
<button class="tb-btn add-lib-btn" id="addLibBtn">加入我的书库</button>
</div>
</div>
<div class="section-title">下载 / 全文</div>
<div class="download-box" id="downloadBox"><div class="dl-loading">下载信息获取中...</div></div>
<div class="section-title">${sectionTitle}</div>
${sectionBody}
${briefHtml}
`;
$('addLibBtn').onclick = addToLibrary;
const onlineReadBtn = $('onlineReadBtn');
if (onlineReadBtn) onlineReadBtn.onclick = () => readOnline();
const urlLink = $('detailUrlLink');
if (urlLink) urlLink.onclick = (e) => { e.preventDefault(); window.api.openExternal(d.url); };
if (chapterBased) {
$('chapterPrevBtn').onclick = () => {
if (state.chapterPage > 1) {
loadChapters(state.currentPostId, state.detailToken, state.chapterPage - 1);
}
};
$('chapterNextBtn').onclick = () => {
if (state.chapterPage < state.chapterMaxPage) {
loadChapters(state.currentPostId, state.detailToken, state.chapterPage + 1);
}
};
}
}
async function readOnline(chapter, chapterButton) {
const button = chapterButton || $('onlineReadBtn');
if (!button || !state.currentDetail) return;
const oldText = button.textContent;
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,
initialChapterId: chapter && chapter.chapterId,
language,
quality
});
button.disabled = false;
button.textContent = result.ok
? (chapter ? '已打开' : '在线阅读器已打开')
: oldText;
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="copy-btn" data-chapter-read="${escapeHtml(c.chapterId)}" ${disabled ? 'disabled' : ''}>阅读</button>
<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('.copy-btn[data-chapter-read]').forEach((btn) => {
const chapter = items.find((c) => c.chapterId === btn.dataset.chapterRead);
btn.onclick = () => readOnline(chapter, btn);
});
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() {
@@ -391,7 +628,10 @@ const Browse = (() => {
$('dlRetry').onclick = () => loadDownload(postId, sourceId, token);
return;
}
const d = res.data;
renderDownloadResult(box, res.data);
}
function renderDownloadResult(box, d) {
let html = '';
const files = d.files || [];
if (files.length) {
@@ -412,6 +652,34 @@ const Browse = (() => {
box.querySelectorAll('[data-open]').forEach((a) => { a.onclick = (e) => { e.preventDefault(); window.api.openExternal(a.dataset.open); }; });
}
function renderOnDemandDownload(postId, sourceId, token) {
const box = $('downloadBox');
if (!box) return;
box.innerHTML = `<div class="dl-files"><div class="dl-file-row">
<span class="dl-file-name">每日免费下载额度有限,仅在点击后获取下载地址</span>
<button class="dl-btn" id="onDemandDownloadBtn">下载</button>
</div></div>`;
const btn = $('onDemandDownloadBtn');
btn.onclick = async () => {
const oldError = box.querySelector('.dl-error');
if (oldError) oldError.remove();
btn.disabled = true;
btn.textContent = '获取中...';
const res = await window.api.sources.download(sourceId, postId);
if (!box.isConnected || token !== state.detailToken) return;
if (!res.ok) {
btn.disabled = false;
btn.textContent = '重试';
btn.title = res.error || '获取下载地址失败';
box.insertAdjacentHTML('beforeend', `<div class="dl-error">获取失败:${escapeHtml(res.error)}</div>`);
return;
}
renderDownloadResult(box, res.data);
const download = box.querySelector('.dl-btn[data-file]');
if (download) download.click();
};
}
// 当前条目的元数据,供下载时自动建库用
function entryMeta() {
const d = state.currentDetail || {};
+49 -6
View File
@@ -84,9 +84,39 @@ const Library = (() => {
btn.disabled = true;
btn.textContent = '扫描中...';
const r = await window.api.library.scan();
btn.textContent = r.ok && r.data && r.data.added ? `新增 ${r.data.added} 本 ✓` : '已是最新 ✓';
if (!r || !r.ok) {
btn.textContent = '扫描失败';
await confirmModal('扫描失败', (r && r.error) || '未知错误');
setTimeout(() => { btn.textContent = '重新扫描'; btn.disabled = false; }, 2000);
return;
}
btn.textContent = r.data && r.data.added ? `新增 ${r.data.added} 本 ✓` : '已是最新 ✓';
dirty = true;
await refresh(true);
const missing = Math.max(0, Number((r.data || {}).missing) || 0);
if (missing) {
const clean = await confirmModal(
'发现文件缺失',
`扫描发现 ${missing} 条书库内容的文件均已缺失,是否从书库中清理这些条目?不再被其他书籍使用的对应分类和标签也会一并清理;笔记、书签、进度和标注会保留。`
);
if (clean) {
const removed = await window.api.library.removeMissing();
if (!removed || !removed.ok) {
await confirmModal('清理失败', (removed && removed.error) || '未知错误');
} else {
const count = Math.max(0, Number((removed.data || {}).removed) || 0);
const shelvesRemoved = Math.max(0, Number((removed.data || {}).shelvesRemoved) || 0);
const tagsRemoved = Math.max(0, Number((removed.data || {}).tagsRemoved) || 0);
const extras = [
shelvesRemoved ? `${shelvesRemoved} 个分类` : '',
tagsRemoved ? `${tagsRemoved} 个标签` : ''
].filter(Boolean);
btn.textContent = `已清理 ${count}${extras.length ? `${extras.join('、')}` : ''}`;
dirty = true;
await refresh(true);
}
}
}
setTimeout(() => { btn.textContent = '重新扫描'; btn.disabled = false; }, 2000);
}
@@ -342,10 +372,10 @@ const Library = (() => {
if (selectedShelf && selectedShelf !== '__uncategorized__'
&& !shelves.some((shelf) => shelf.id === selectedShelf)) selectedShelf = '';
if (selectedTag && !libraryTags.some((tag) => tag.name === selectedTag)) selectedTag = '';
renderOrganizationSidebar();
const allItems = res.data.slice();
renderOrganizationSidebar(allItems);
const noteCounts = noteCountsOf(noteCountResult);
const annotationCounts = noteCountsOf(annotationCountResult);
const allItems = res.data.slice();
const scopedItems = allItems.filter((item) => {
if (selectedShelf === '__uncategorized__' && item.shelfId) return false;
if (selectedShelf && selectedShelf !== '__uncategorized__' && item.shelfId !== selectedShelf) return false;
@@ -394,12 +424,20 @@ const Library = (() => {
refresh(true);
}
function renderOrganizationSidebar() {
function renderOrganizationSidebar(items) {
document.querySelectorAll('#libraryTab .library-filter[data-shelf]').forEach((button) => {
const shelfId = button.dataset.shelf || '';
button.classList.toggle(
'active',
!selectedTag && (button.dataset.shelf || '') === selectedShelf
!selectedTag && shelfId === selectedShelf
);
const count = button.querySelector('.library-filter-count');
if (count) {
const value = shelfId === '__uncategorized__'
? items.filter((item) => !item.shelfId).length
: items.length;
count.textContent = `${value}`;
}
});
const shelfList = $('libraryShelfList');
shelfList.textContent = '';
@@ -410,8 +448,13 @@ const Library = (() => {
filter.type = 'button';
filter.className = 'library-filter';
filter.classList.toggle('active', !selectedTag && selectedShelf === shelf.id);
filter.textContent = shelf.name;
filter.title = shelf.name;
const name = document.createElement('span');
name.textContent = shelf.name;
const count = document.createElement('span');
count.className = 'library-filter-count';
count.textContent = `${shelf.count || 0}`;
filter.append(name, count);
filter.onclick = () => selectShelf(shelf.id);
const actions = document.createElement('div');
+146
View File
@@ -0,0 +1,146 @@
// 最小 ZIP 读写:写入仅支持 STORE(无压缩)条目。
//
// 用途:主进程侧组装/追加漫画 EPUB(EPUB 本质是 ZIP 容器)。图片本身已经是
// JPEG/PNG,压缩收益很低,STORE 把复杂度降到最低——不需要引入 deflate 编解码,
// Node 内置 zlib 从 v22.15 起自带 crc32Electron 43 打包的 Node 24 满足要求。
//
// 读取端兼容 STORE 与 DEFLATE(用 zlib.inflateRawSync 解压),但写入端只产出
// STORE,因为唯一的写入场景(漫画 EPUB)由本模块自己创建,不需要往复压缩。
const zlib = require('zlib');
const LOCAL_FILE_SIG = 0x04034b50;
const CENTRAL_DIR_SIG = 0x02014b50;
const END_OF_CENTRAL_DIR_SIG = 0x06054b50;
const METHOD_STORE = 0;
const METHOD_DEFLATE = 8;
function dosDateTime(date) {
const d = date || new Date();
const time = ((d.getHours() & 0x1f) << 11) | ((d.getMinutes() & 0x3f) << 5) | ((d.getSeconds() >> 1) & 0x1f);
const dosDate = (((d.getFullYear() - 1980) & 0x7f) << 9) | (((d.getMonth() + 1) & 0xf) << 5) | (d.getDate() & 0x1f);
return { time, dosDate };
}
// 写入端只产出 STORE 条目:entries 为 [{ name, data(Buffer) }]。
function writeZip(entries) {
const { time, dosDate } = dosDateTime(new Date());
const localParts = [];
const centralParts = [];
let offset = 0;
for (const entry of entries) {
const nameBuf = Buffer.from(entry.name, 'utf8');
const data = entry.data;
const crc = zlib.crc32(data) >>> 0;
const size = data.length;
const local = Buffer.alloc(30);
local.writeUInt32LE(LOCAL_FILE_SIG, 0);
local.writeUInt16LE(20, 4); // version needed
local.writeUInt16LE(0x0800, 6); // 通用标志位:文件名 UTF-8
local.writeUInt16LE(METHOD_STORE, 8);
local.writeUInt16LE(time, 10);
local.writeUInt16LE(dosDate, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(size, 18);
local.writeUInt32LE(size, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28);
localParts.push(local, nameBuf, data);
const central = Buffer.alloc(46);
central.writeUInt32LE(CENTRAL_DIR_SIG, 0);
central.writeUInt16LE(20, 4); // version made by
central.writeUInt16LE(20, 6); // version needed
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(METHOD_STORE, 10);
central.writeUInt16LE(time, 12);
central.writeUInt16LE(dosDate, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(size, 20);
central.writeUInt32LE(size, 24);
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt16LE(0, 30); // extra length
central.writeUInt16LE(0, 32); // comment length
central.writeUInt16LE(0, 34); // disk number
central.writeUInt16LE(0, 36); // internal attrs
central.writeUInt32LE(0, 38); // external attrs
central.writeUInt32LE(offset, 42);
centralParts.push(central, nameBuf);
offset += local.length + nameBuf.length + data.length;
}
const centralStart = offset;
const centralBuf = Buffer.concat(centralParts);
const end = Buffer.alloc(22);
end.writeUInt32LE(END_OF_CENTRAL_DIR_SIG, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralBuf.length, 12);
end.writeUInt32LE(centralStart, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...localParts, centralBuf, end]);
}
// 从尾部往前找 EOCD:注释字段长度可变,不能假设它贴在文件末尾固定偏移。
function findEndOfCentralDir(buf) {
const minLen = 22;
if (buf.length < minLen) throw new Error('不是有效的 ZIP 文件');
const maxComment = Math.min(buf.length - minLen, 0xffff);
for (let i = 0; i <= maxComment; i++) {
const pos = buf.length - minLen - i;
if (buf.readUInt32LE(pos) === END_OF_CENTRAL_DIR_SIG) return pos;
}
throw new Error('不是有效的 ZIP 文件:找不到目录结尾标记');
}
// 读取端兼容 STORE 与 DEFLATE,返回 Map<name, Buffer>。
function readZip(buf) {
const eocdPos = findEndOfCentralDir(buf);
const total = buf.readUInt16LE(eocdPos + 10);
let centralOffset = buf.readUInt32LE(eocdPos + 16);
const out = new Map();
for (let i = 0; i < total; i++) {
if (buf.readUInt32LE(centralOffset) !== CENTRAL_DIR_SIG) {
throw new Error('ZIP 中央目录已损坏');
}
const method = buf.readUInt16LE(centralOffset + 10);
const expectedCrc = buf.readUInt32LE(centralOffset + 16);
const compSize = buf.readUInt32LE(centralOffset + 20);
const size = buf.readUInt32LE(centralOffset + 24);
const nameLen = buf.readUInt16LE(centralOffset + 28);
const extraLen = buf.readUInt16LE(centralOffset + 30);
const commentLen = buf.readUInt16LE(centralOffset + 32);
const localOffset = buf.readUInt32LE(centralOffset + 42);
const name = buf.toString('utf8', centralOffset + 46, centralOffset + 46 + nameLen);
const localNameLen = buf.readUInt16LE(localOffset + 26);
const localExtraLen = buf.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
const raw = buf.subarray(dataStart, dataStart + compSize);
if (!name.endsWith('/')) {
if (method !== METHOD_STORE && method !== METHOD_DEFLATE) {
throw new Error(`ZIP 压缩方式不受支持: ${method}`);
}
const data = method === METHOD_DEFLATE ? zlib.inflateRawSync(raw) : raw;
if (data.length !== size || (zlib.crc32(data) >>> 0) !== expectedCrc) {
throw new Error(`ZIP 条目校验失败: ${name}`);
}
out.set(name, data);
}
centralOffset += 46 + nameLen + extraLen + commentLen;
}
return out;
}
module.exports = { writeZip, readZip };