From 87dcc307e6069ae7ba1f8d3ef966d26674d9419f Mon Sep 17 00:00:00 2001 From: lofyer Date: Sat, 8 Aug 2026 19:32:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=E6=BC=AB=E7=94=BB?= =?UTF-8?q?=E6=BA=90=E5=B9=B6=E6=94=AF=E6=8C=81=E5=9C=A8=E7=BA=BF=E9=98=85?= =?UTF-8?q?=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.js | 71 ++++- manga-online-preload.js | 23 ++ preload.js | 37 ++- src/_test/atomic-file.test.js | 33 +++ src/_test/electron/startup.integration.js | 99 ++++++- src/_test/main.test.js | 26 ++ src/_test/manga-download.test.js | 281 +++++++++++++++++++ src/_test/manga-epub.test.js | 155 +++++++++++ src/_test/manga-online.test.js | 146 ++++++++++ src/_test/sources.test.js | 262 ++++++++++++++++++ src/_test/ui.test.js | 48 ++++ src/_test/zip.test.js | 69 +++++ src/atomic-file.js | 24 +- src/library/manga-download.js | 284 ++++++++++++++++++++ src/library/manga-epub.js | 226 ++++++++++++++++ src/library/store.js | 33 +-- src/manga-online/session.js | 227 ++++++++++++++++ src/manga-online/window.js | 73 +++++ src/sources/copymanga.js | 228 ++++++++++++++++ src/sources/http.js | 37 ++- src/sources/index.js | 30 ++- src/sources/mangadex.js | 248 +++++++++++++++++ src/ui/app.js | 20 ++ src/ui/index.html | 23 ++ src/ui/manga-online.css | 276 +++++++++++++++++++ src/ui/manga-online.html | 61 +++++ src/ui/manga-online.js | 313 ++++++++++++++++++++++ src/ui/style.css | 5 + src/ui/views/browse.js | 235 +++++++++++++++- src/zip.js | 146 ++++++++++ 30 files changed, 3681 insertions(+), 58 deletions(-) create mode 100644 manga-online-preload.js create mode 100644 src/_test/manga-download.test.js create mode 100644 src/_test/manga-epub.test.js create mode 100644 src/_test/manga-online.test.js create mode 100644 src/_test/zip.test.js create mode 100644 src/library/manga-download.js create mode 100644 src/library/manga-epub.js create mode 100644 src/manga-online/session.js create mode 100644 src/manga-online/window.js create mode 100644 src/sources/copymanga.js create mode 100644 src/sources/mangadex.js create mode 100644 src/ui/manga-online.css create mode 100644 src/ui/manga-online.html create mode 100644 src/ui/manga-online.js create mode 100644 src/zip.js diff --git a/main.js b/main.js index 086cb73..d64df07 100644 --- a/main.js +++ b/main.js @@ -108,6 +108,8 @@ const annotations = require('./src/reader/annotations'); const noteAssets = require('./src/reader/note-assets'); const readerWindow = require('./src/reader/window'); const noteWindow = require('./src/reader/note-window'); +const mangaOnlineWindow = require('./src/manga-online/window'); +const mangaOnlineSessions = require('./src/manga-online/session'); const rangeSessions = require('./src/reader/range-sessions'); const aiConfig = require('./src/reader/ai-config'); const aiClient = require('./src/reader/ai-client'); @@ -276,7 +278,7 @@ noteWindow.setChangeListener(notifyNoteWindowsChanged); function applyWindowIcons(theme) { currentUiTheme = theme === 'light' ? 'light' : 'dark'; const icon = iconForTheme(currentUiTheme); - const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()]; + const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all(), ...mangaOnlineWindow.all()]; for (const win of windows) { if (!win || win.isDestroyed()) continue; try { win.setIcon(icon); } catch (e) { /* 平台不支持动态图标时保留创建时图标 */ } @@ -284,7 +286,7 @@ function applyWindowIcons(theme) { } function notifyUiThemeChanged() { - const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()]; + const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all(), ...mangaOnlineWindow.all()]; for (const win of windows) { if (win && !win.isDestroyed()) { win.webContents.send('ui:themeChanged', currentUiTheme); @@ -314,6 +316,7 @@ app.on('before-quit', () => { for (const download of downloadSessions.values()) discardDownloadSession(download); coverGenerator.close(); rangeSessions.closeAll().catch(() => {}); + mangaOnlineSessions.reset(); }); // fn 同步抛出时也必须变成 { ok:false },否则 invoke 直接 reject, @@ -332,6 +335,70 @@ ipcMain.handle('source:search', (_e, sourceId, keyword, page) => wrap(() => sour ipcMain.handle('source:detail', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).detail(postId))); ipcMain.handle('source:download', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).download(postId))); +// 按章节下载的源不复用 download:file;后者只处理单个直链文件。 +ipcMain.handle('source:chapters', (_e, sourceId, postId, page, options) => wrap(() => { + const source = sources.getSource(sourceId); + if (typeof source.chapters !== 'function') throw new Error('该数据源不支持章节列表'); + return source.chapters(postId, page, options || {}); +})); +ipcMain.handle('source:readOnline', (event, sourceId, input) => wrap(() => { + if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) { + throw new Error('只有主窗口可以打开在线漫画'); + } + const source = sources.getSource(sourceId); + const win = mangaOnlineWindow.create(__dirname, currentUiTheme); + const ownerId = win.webContents.id; + win.webContents.once('destroyed', () => mangaOnlineSessions.closeOwner(ownerId)); + try { + const value = mangaOnlineSessions.create(ownerId, source, input || {}); + mangaOnlineWindow.load(__dirname, value.sessionId); + return value; + } catch (error) { + win.destroy(); + throw error; + } +})); +ipcMain.handle('source:downloadChapter', async (event, sourceId, payload, requestId) => { + const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : ''; + const sendProgress = (done, total) => { + if (!progressId || event.sender.isDestroyed()) return; + event.sender.send('source:chapterProgress', { requestId: progressId, done, total }); + }; + return wrap(async () => { + const source = sources.getSource(sourceId); + if (typeof source.downloadChapter !== 'function') throw new Error('该数据源不支持章节下载'); + // library.add()/attachFile() 内部已经在 commit() 里触发变更通知,这里不需要再发一次 + const result = await source.downloadChapter(library, payload || {}, sendProgress); + return { entryId: result.entry ? result.entry.id : null, chapterLabel: result.chapterLabel, created: result.created }; + }); +}); + +function requireMangaOnlineSender(event) { + if (!mangaOnlineWindow.fromWebContents(event.sender)) throw new Error('只有在线漫画阅读器可以读取内容'); + return event.sender.id; +} + +ipcMain.handle('mangaOnline:meta', (event, sessionId) => wrap(() => ( + mangaOnlineSessions.meta(requireMangaOnlineSender(event), sessionId) +))); +ipcMain.handle('mangaOnline:chapters', (event, sessionId, page) => wrap(() => ( + mangaOnlineSessions.chapters(requireMangaOnlineSender(event), sessionId, page) +))); +ipcMain.handle('mangaOnline:chapterManifest', (event, sessionId, chapterId) => wrap(() => ( + mangaOnlineSessions.chapterManifest(requireMangaOnlineSender(event), sessionId, chapterId) +))); +ipcMain.handle('mangaOnline:image', (event, sessionId, manifestId, index) => wrap(() => ( + mangaOnlineSessions.image( + requireMangaOnlineSender(event), + sessionId, + manifestId, + index + ) +))); +ipcMain.handle('mangaOnline:close', (event, sessionId) => wrap(() => ( + mangaOnlineSessions.close(requireMangaOnlineSender(event), sessionId) +))); + // 代理配置:全局生效,影响所有数据源的 HTTP 请求与文件下载 ipcMain.handle('proxy:get', () => wrap(() => getProxy())); ipcMain.handle('proxy:set', (_e, url) => { diff --git a/manga-online-preload.js b/manga-online-preload.js new file mode 100644 index 0000000..133e1cc --- /dev/null +++ b/manga-online-preload.js @@ -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') +}); diff --git a/preload.js b/preload.js index 176bfce..70b07a7 100644 --- a/preload.js +++ b/preload.js @@ -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'), diff --git a/src/_test/atomic-file.test.js b/src/_test/atomic-file.test.js index 67a5939..55503df 100644 --- a/src/_test/atomic-file.test.js +++ b/src/_test/atomic-file.test.js @@ -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); +}); diff --git a/src/_test/electron/startup.integration.js b/src/_test/electron/startup.integration.js index bedd0f5..f086940 100644 --- a/src/_test/electron/startup.integration.js +++ b/src/_test/electron/startup.integration.js @@ -1,4 +1,5 @@ const fs = require('fs'); +const http = require('http'); const os = require('os'); const path = require('path'); const { app, BrowserWindow } = require('electron'); @@ -9,6 +10,7 @@ app.setPath('appData', TMP); process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true'; const results = []; +let imageServer = null; function check(name, pass, detail) { results.push([pass ? 'OK' : 'FAIL', name, detail || '']); } @@ -36,6 +38,31 @@ function printSummary() { } app.whenReady().then(async () => { + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64' + ); + imageServer = http.createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': png.length }); + response.end(png); + }); + await new Promise((resolve) => imageServer.listen(0, '127.0.0.1', resolve)); + const imageUrl = `http://127.0.0.1:${imageServer.address().port}/page.png`; + const mangaDex = require(path.join(ROOT, 'src', 'sources', 'mangadex')); + mangaDex.chapters = async () => ({ + items: [ + { chapterId: 'online-c1', label: '在线第 1 话', pages: 1 }, + { chapterId: 'online-c2', label: '在线第 2 话', pages: 1 } + ], + page: 1, + maxPage: 1 + }); + mangaDex.chapterImageUrls = async () => ({ + urls: [imageUrl], + mustReport: false, + quality: 'dataSaver' + }); + const library = require(path.join(ROOT, 'src', 'library', 'store')); let scanStartedAt = 0; library.scan = () => { @@ -63,23 +90,91 @@ app.whenReady().then(async () => { checked: input.checked, name: input.parentElement.querySelector('span').textContent })); - return rows.length >= 16 ? rows : null; + return rows.length >= 18 ? rows : null; })()`)); - const expectedSources = ['openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en']; + const expectedSources = [ + 'openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en', + 'mangadex', 'copymanga' + ]; check('新增开放书源出现在设置页且新安装默认启用', expectedSources.every((id) => sourceRows.some((row) => row.id === id && row.checked)), sourceRows.filter((row) => expectedSources.includes(row.id)).map((row) => `${row.id}:${row.name}`).join(', ')); + const sourceGroups = await waitUntil(() => win.webContents.executeJavaScript(`(() => { + const labels = Array.from(document.querySelectorAll('#sourceSelect optgroup')).map((group) => group.label); + return labels.includes('漫画') ? labels : null; + })()`)); + check('检索书源下拉框按内容类型分组', + ['学术论文', '电子书', '开放教材与文库', '档案与特藏', '漫画'] + .every((label) => sourceGroups.includes(label)), + sourceGroups.join(', ')); + + const mangaLanguage = await win.webContents.executeJavaScript( + `document.getElementById('mangadexLanguageSelect').value` + ); + check('MangaDex 新安装默认只显示简体中文章节', mangaLanguage === 'zh', mangaLanguage); + await waitUntil(() => scanStartedAt > 0, 7000); check('启动维护在首屏完成后延迟执行', scanStartedAt - startedAt >= 1400, `${scanStartedAt - startedAt}ms`); + const beforeOnline = await win.webContents.executeJavaScript( + `window.api.library.list().then((result) => result.data.length)` + ); + const openedOnline = await win.webContents.executeJavaScript(`window.api.sources.readOnline('mangadex', { + mangaId: 'online-manga', + title: '在线阅读夹具', + authors: ['测试作者'], + language: 'zh', + quality: 'dataSaver' + })`); + check('漫画详情可以打开不入库的在线阅读器', openedOnline && openedOnline.ok, + openedOnline && openedOnline.error); + const onlineWindow = await waitUntil(() => BrowserWindow.getAllWindows().find( + (item) => item !== win && /manga-online\.html/.test(item.webContents.getURL()) + )); + let firstChapter = false; + try { + firstChapter = await waitUntil(() => onlineWindow.webContents.executeJavaScript( + `document.querySelectorAll('.chapter-item').length === 2 + && document.querySelector('.manga-page img') + && document.querySelector('.manga-page img').naturalWidth > 0` + )); + } catch (error) { /* 由下面的诊断状态报告 */ } + const onlineState = await onlineWindow.webContents.executeJavaScript(`(() => ({ + status: document.getElementById('readerStatus').textContent, + statusHidden: document.getElementById('readerStatus').classList.contains('hidden'), + chapters: document.querySelectorAll('.chapter-item').length, + pages: document.querySelectorAll('.manga-page').length, + pageState: document.querySelector('.manga-page')?.dataset.state || '', + pageText: document.querySelector('.manga-page')?.dataset.placeholder || '' + }))()`); + check('在线阅读器读取整部章节目录并通过主进程显示图片', + !!firstChapter, JSON.stringify(onlineState)); + let secondChapter = false; + if (firstChapter) { + await onlineWindow.webContents.executeJavaScript(`document.getElementById('nextChapterBtn').click()`); + secondChapter = await waitUntil(() => onlineWindow.webContents.executeJavaScript(`(() => { + const image = document.querySelector('.manga-page img'); + return document.getElementById('chapterTitle').textContent === '在线第 2 话' + && image && image.naturalWidth > 0; + })()`)); + } + check('在线阅读器可连续切换到下一章', secondChapter); + const afterOnline = await win.webContents.executeJavaScript( + `window.api.library.list().then((result) => result.data.length)` + ); + check('在线阅读不会创建书库条目', afterOnline === beforeOnline, + `${beforeOnline} → ${afterOnline}`); + for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) window.destroy(); } + await new Promise((resolve) => imageServer.close(resolve)); const failed = printSummary(); app.exit(failed ? 1 : 0); }).catch((error) => { + if (imageServer) imageServer.close(); console.error('异常:', error); check('启动验证未发生异常', false, error.message || String(error)); for (const window of BrowserWindow.getAllWindows()) { diff --git a/src/_test/main.test.js b/src/_test/main.test.js index 9d5269f..f355ba4 100644 --- a/src/_test/main.test.js +++ b/src/_test/main.test.js @@ -43,6 +43,32 @@ test('所有 IPC handler 都通过 thunk 调用 wrap', () => { assert.deepStrictEqual(bare, [], '存在绕过 wrap 的 handler: ' + bare); }); +test('章节制数据源 IPC 通过源能力下载并按请求隔离进度', () => { + const start = mainSrc.indexOf("ipcMain.handle('source:chapters'"); + const end = mainSrc.indexOf('// 代理配置', start); + const segment = mainSrc.slice(start, end); + assert.ok(start >= 0 && end > start, '缺少章节制数据源 IPC'); + assert.match(segment, /source\.chapters\(postId, page, options \|\| \{\}\)/); + assert.match(segment, /source\.downloadChapter\(library, payload \|\| \{\}, sendProgress\)/); + assert.match(segment, /requestId:\s*progressId/); + assert.match(segment, /event\.sender\.isDestroyed\(\)/); + assert.match(segment, /event\.sender\.send\('source:chapterProgress'/); +}); + +test('在线漫画使用独立窗口、sender 绑定会话和主进程图片代理', () => { + const start = mainSrc.indexOf("ipcMain.handle('source:readOnline'"); + const end = mainSrc.indexOf('// 代理配置', start); + const segment = mainSrc.slice(start, end); + assert.ok(start >= 0 && end > start, '缺少在线漫画 IPC'); + assert.match(segment, /event\.sender\.id !== mainWindow\.webContents\.id/); + assert.match(segment, /mangaOnlineWindow\.create\(__dirname, currentUiTheme\)/); + assert.match(segment, /mangaOnlineSessions\.create\(ownerId, source, input \|\| \{\}\)/); + assert.match(segment, /mangaOnlineWindow\.fromWebContents\(event\.sender\)/); + for (const channel of ['meta', 'chapters', 'chapterManifest', 'image', 'close']) { + assert.match(segment, new RegExp(`ipcMain\\.handle\\('mangaOnline:${channel}'`)); + } +}); + test('版本比较:预发布版本低于同号正式版', () => { assert.strictEqual(compareVersion('1.1.0', '1.1.0-beta'), 1); assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0'), -1); diff --git a/src/_test/manga-download.test.js b/src/_test/manga-download.test.js new file mode 100644 index 0000000..43ebf12 --- /dev/null +++ b/src/_test/manga-download.test.js @@ -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'); +}); diff --git a/src/_test/manga-epub.test.js b/src/_test/manga-epub.test.js new file mode 100644 index 0000000..9afcad8 --- /dev/null +++ b/src/_test/manga-epub.test.js @@ -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)] + }), /不是同一部漫画/); +}); \ No newline at end of file diff --git a/src/_test/manga-online.test.js b/src/_test/manga-online.test.js new file mode 100644 index 0000000..1444587 --- /dev/null +++ b/src/_test/manga-online.test.js @@ -0,0 +1,146 @@ +const assert = require('node:assert'); +const { test, afterEach } = require('node:test'); +const mangaDownload = require('../library/manga-download'); +const sessions = require('../manga-online/session'); +const { responseBytes } = require('../sources/http'); + +const originalFetchOnlineImage = mangaDownload.fetchOnlineImage; + +afterEach(() => { + sessions.reset(); + mangaDownload.fetchOnlineImage = originalFetchOnlineImage; +}); + +function source(overrides) { + return { + id: 'fixture-manga', + chapters: async (_mangaId, page, options) => ({ + page, + maxPage: 2, + items: [ + { chapterId: 'c1', label: '第 1 话', pages: 2, group: options.language }, + { chapterId: 'external', label: '站外章节', external: true }, + { chapterId: 'missing', label: '不可用章节', unavailable: true } + ] + }), + chapterImageUrls: async () => ({ + urls: ['https://image.test/1.png', 'https://image.test/2.png'], + headers: { Referer: 'https://reader.test/' }, + mustReport: false + }), + ...overrides + }; +} + +test('在线漫画会话与窗口 sender 绑定,并只授权目录里可用的章节', async () => { + const created = sessions.create(11, source(), { + mangaId: 'manga-1', + title: '在线漫画', + language: 'zh-hk', + quality: 'data' + }); + assert.strictEqual(created.title, '在线漫画'); + assert.strictEqual(created.quality, 'data'); + await assert.rejects( + Promise.resolve().then(() => sessions.meta(12, created.sessionId)), + /会话无效/ + ); + + const chapters = await sessions.chapters(11, created.sessionId, 1); + assert.deepStrictEqual(chapters.items.map((item) => item.chapterId), ['c1']); + assert.strictEqual(chapters.items[0].group, 'zh-hk'); + await assert.rejects( + sessions.chapterManifest(11, created.sessionId, 'external'), + /不属于当前在线漫画会话/ + ); + const manifest = await sessions.chapterManifest(11, created.sessionId, 'c1'); + assert.strictEqual(manifest.pages, 2); +}); + +test('在线图片由主进程代理,节点失败时刷新地址后重试并校验图片格式', async () => { + let sourceCalls = 0; + let imageCalls = 0; + const mangaSource = source({ + chapterImageUrls: async () => { + sourceCalls++; + return { + urls: [ + `https://image.test/${sourceCalls}/1.png`, + `https://image.test/${sourceCalls}/2.png` + ], + headers: { Referer: 'https://reader.test/' }, + mustReport: true + }; + } + }); + mangaDownload.fetchOnlineImage = async (url, headers, mustReport) => { + imageCalls++; + assert.strictEqual(headers.Referer, 'https://reader.test/'); + assert.strictEqual(mustReport, true); + if (/\/1\//.test(url)) throw new Error('节点失败'); + assert.match(url, /\/2\/[12]\.png$/); + return Buffer.from('89504e470d0a1a0a00000000', 'hex'); + }; + + const created = sessions.create(21, mangaSource, { mangaId: 'manga-2' }); + await sessions.chapters(21, created.sessionId, 1); + const manifest = await sessions.chapterManifest(21, created.sessionId, 'c1'); + const images = await Promise.all([ + sessions.image(21, created.sessionId, manifest.manifestId, 0), + sessions.image(21, created.sessionId, manifest.manifestId, 1) + ]); + assert.ok(images.every((image) => image.mimeType === 'image/png')); + assert.strictEqual(imageCalls, 4); + assert.strictEqual(sourceCalls, 2); +}); + +test('关闭窗口会回收其全部在线漫画会话', () => { + const first = sessions.create(31, source(), { mangaId: 'one' }); + const second = sessions.create(31, source(), { mangaId: 'two' }); + sessions.closeOwner(31); + assert.throws(() => sessions.meta(31, first.sessionId), /会话无效/); + assert.throws(() => sessions.meta(31, second.sessionId), /会话无效/); +}); + +test('快速切章时较慢的旧清单不会覆盖当前章节', async () => { + let releaseFirst; + const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); + const mangaSource = source({ + chapters: async () => ({ + items: [ + { chapterId: 'c1', label: '第 1 话' }, + { chapterId: 'c2', label: '第 2 话' } + ], + page: 1, + maxPage: 1 + }), + chapterImageUrls: async (chapterId) => { + if (chapterId === 'c1') await firstGate; + return { urls: [`https://image.test/${chapterId}.png`], mustReport: false }; + } + }); + const created = sessions.create(41, mangaSource, { mangaId: 'switching' }); + await sessions.chapters(41, created.sessionId, 1); + const first = sessions.chapterManifest(41, created.sessionId, 'c1'); + const second = await sessions.chapterManifest(41, created.sessionId, 'c2'); + releaseFirst(); + await assert.rejects(first, /章节加载已取消/); + assert.strictEqual(second.pages, 1); +}); + +test('有界响应读取会在分块图片超限时立即取消', async () => { + let pulls = 0; + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + pulls++; + controller.enqueue(new Uint8Array(4)); + if (pulls >= 10) controller.close(); + }, + cancel() { cancelled = true; } + }); + const result = await responseBytes(new Response(body), 6); + assert.strictEqual(result, null); + assert.strictEqual(cancelled, true); + assert.ok(pulls < 10); +}); diff --git a/src/_test/sources.test.js b/src/_test/sources.test.js index e8905b3..df6839a 100644 --- a/src/_test/sources.test.js +++ b/src/_test/sources.test.js @@ -23,6 +23,19 @@ test('注册表:每个源都实现完整接口', () => { ); }); +test('注册表:两个可用漫画源都提供章节制下载能力与分类', () => { + const list = sources.listSources(); + for (const id of ['mangadex', 'copymanga']) { + const entry = list.find((source) => source.id === id); + assert.strictEqual(entry.chapterBased, true); + assert.strictEqual(entry.category, 'manga'); + const source = sources.getSource(id); + assert.strictEqual(typeof source.chapters, 'function'); + assert.strictEqual(typeof source.downloadChapter, 'function'); + } + assert.strictEqual(list.find((source) => source.id === 'copymanga').experimental, true); +}); + test('注册表:开放教材与中英文维基文库已启用', () => { const ids = sources.listSources().map((source) => source.id); for (const id of ['openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en']) { @@ -710,3 +723,252 @@ test('gutenberg: 解析格式与封面', async () => { assert.strictEqual(r.items[0].cover, 'https://x/c.jpg'); assert.strictEqual(r.maxPage, 2); }); + +// --- MangaDex --- + +test('mangadex: 列表映射本地化标题、作者与封面,并传递安全分级', async () => { + h.resetCalls(); + h.setHandler(h.routes([['api.mangadex.org/manga?', { + body: { + total: 41, + data: [{ + id: 'manga-1', + attributes: { + title: { ja: '原题', en: 'English title' }, + year: 2024 + }, + relationships: [ + { type: 'cover_art', attributes: { fileName: 'cover.jpg' } }, + { type: 'author', attributes: { name: '作者' } }, + { type: 'artist', attributes: { name: '画师' } } + ] + }] + } + }]])); + + const result = await sources.getSource('mangadex').list(2); + assert.strictEqual(result.page, 2); + assert.strictEqual(result.maxPage, 3); + assert.deepStrictEqual(result.items[0], { + postId: 'manga-1', + title: 'English title', + cover: 'https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg', + date: '2024', + url: 'https://mangadex.org/title/manga-1', + subtitle: '作者, 画师' + }); + + const request = new URL(h.getCalls().at(-1).url); + assert.strictEqual(request.searchParams.get('offset'), '20'); + assert.deepStrictEqual(request.searchParams.getAll('contentRating[]'), ['safe', 'suggestive']); + assert.deepStrictEqual(request.searchParams.getAll('includes[]'), ['cover_art', 'author', 'artist']); +}); + +test('mangadex: 空搜索词直接返回空结果,不发起网络请求', async () => { + h.resetCalls(); + const result = await sources.getSource('mangadex').search(' ', 3); + assert.deepStrictEqual(result, { items: [], maxPage: 1, page: 3 }); + assert.strictEqual(h.getCalls().length, 0); +}); + +test('mangadex: 详情映射状态、分级、标签与原始语言', async () => { + h.setHandler(h.routes([['api.mangadex.org/manga/manga-1?', { + body: { + data: { + id: 'manga-1', + attributes: { + title: { zh: '中文标题' }, + description: { zh: '简介' }, + status: 'ongoing', + publicationDemographic: 'shounen', + originalLanguage: 'ja', + year: 2023, + tags: [{ attributes: { name: { en: 'Action' } } }] + }, + relationships: [{ type: 'author', attributes: { name: '作者' } }] + } + } + }]])); + + const result = await sources.getSource('mangadex').detail('manga-1'); + assert.strictEqual(result.title, '中文标题'); + assert.strictEqual(result.brief, '简介'); + assert.deepStrictEqual(result.tags, ['状态:连载中', '分级:少年', '标签:Action']); + assert.strictEqual(result.originalLanguage, 'ja'); + assert.deepStrictEqual(result.authors, ['作者']); +}); + +test('mangadex: 章节列表映射翻译组、站外与不可用状态', async () => { + h.resetCalls(); + h.setHandler(h.routes([['/manga/manga-1/feed?', { + body: { + total: 101, + data: [{ + id: 'chapter-1', + attributes: { + volume: '2', + chapter: '3.5', + title: '番外', + translatedLanguage: 'zh-hk', + pages: 18, + publishAt: '2026-01-02T00:00:00Z', + externalUrl: 'https://example.test/read', + isUnavailable: true + }, + relationships: [{ type: 'scanlation_group', attributes: { name: '翻译组' } }] + }] + } + }]])); + + const result = await sources.getSource('mangadex').chapters('manga-1', 1, { language: 'zh-hk' }); + assert.strictEqual(result.maxPage, 2); + assert.deepStrictEqual(result.items[0], { + chapterId: 'chapter-1', + volume: '2', + chapter: '3.5', + title: '番外', + label: '第 2 卷 第 3.5 话 番外', + translatedLanguage: 'zh-hk', + pages: 18, + publishAt: '2026-01-02T00:00:00Z', + group: '翻译组', + external: true, + unavailable: true + }); + const request = new URL(h.getCalls().at(-1).url); + assert.deepStrictEqual(request.searchParams.getAll('translatedLanguage[]'), ['zh-hk']); +}); + +test('mangadex: 原图与压缩图使用正确的 MangaDex@Home 路径', async () => { + const source = sources.getSource('mangadex'); + const originalAtHome = source.atHome; + source.atHome = async () => ({ + baseUrl: 'https://node.example.test', + chapter: { + hash: 'hash-1', + data: ['one.jpg'], + dataSaver: ['one-small.jpg'] + } + }); + try { + const original = await source.chapterImageUrls('chapter-1', 'data'); + assert.deepStrictEqual(original.urls, ['https://node.example.test/data/hash-1/one.jpg']); + assert.strictEqual(original.mustReport, true); + + const saver = await source.chapterImageUrls('chapter-1', 'dataSaver'); + assert.deepStrictEqual(saver.urls, ['https://node.example.test/data-saver/hash-1/one-small.jpg']); + assert.strictEqual(saver.mustReport, true); + } finally { + source.atHome = originalAtHome; + } +}); + +test('mangadex: 官方图片域名无需上报,下载整部时提示先选章节', async () => { + const source = sources.getSource('mangadex'); + const originalAtHome = source.atHome; + source.atHome = async () => ({ + baseUrl: 'https://uploads.mangadex.org', + chapter: { hash: 'hash-1', data: ['one.png'], dataSaver: ['one.png'] } + }); + try { + const result = await source.chapterImageUrls('chapter-1', 'data'); + assert.strictEqual(result.mustReport, false); + } finally { + source.atHome = originalAtHome; + } + await assert.rejects(source.download('manga-1'), /选择要下载的具体章节/); +}); + +// --- 拷贝漫画 --- + +test('copymanga: 列表与搜索使用移动端 API 的分页结构', async () => { + h.setHandler(h.routes([ + ['/api/v3/comics?', { + body: { + results: { + total: 43, + list: [{ + name: '漫画甲', + path_word: 'comic-a', + cover: 'https://img/a.jpg', + author: [{ name: '作者甲' }], + datetime_updated: '2026-08-08' + }] + } + } + }], + ['/api/v3/search/comic?', { + body: { + results: { + total: 1, + list: [{ name: '漫画乙', path_word: 'comic-b', author: [] }] + } + } + }] + ])); + const source = sources.getSource('copymanga'); + const listed = await source.list(2); + assert.strictEqual(listed.maxPage, 3); + assert.strictEqual(listed.items[0].subtitle, '作者甲'); + const searched = await source.search('漫画', 1); + assert.strictEqual(searched.items[0].postId, 'comic-b'); +}); + +test('copymanga: 详情、分组章节和章节图片按当前 v3 API 映射', async () => { + h.resetCalls(); + h.setHandler(h.routes([ + ['/api/v3/comic2/comic-a?', { + body: { + results: { + comic: { + uuid: 'book-1', + name: '漫画甲', + path_word: 'comic-a', + cover: 'https://img/cover.jpg', + reclass: { value: 1, display: '漫画' }, + region: { display: '日本' }, + status: { display: '连载中' }, + author: [{ name: '作者' }], + theme: [{ name: '冒险' }], + brief: '简介', + datetime_updated: '2026-08-08' + }, + groups: { default: { path_word: 'default', count: 2, name: '默认' } } + } + } + }], + ['/group/default/chapters?', { + body: { + results: { + list: [ + { uuid: 'c1', name: '第 1 话', ordered: 10, size: 12, datetime_created: '2026-01-01' }, + { uuid: 'c2', name: '第 2 话', ordered: 20, size: 13, datetime_created: '2026-01-02' } + ] + } + } + }], + ['/chapter/c1?', { + body: { + results: { + chapter: { + contents: [{ url: 'https://img/1.jpg' }, { url: 'https://img/2.webp' }] + } + } + } + }] + ])); + const source = sources.getSource('copymanga'); + const detail = await source.detail('comic-a'); + assert.deepStrictEqual(detail.tags, ['状态:连载中', '地区:日本', '标签:冒险']); + const chapters = await source.chapters('comic-a', 1); + assert.deepStrictEqual(chapters.items.map((item) => item.chapterId), ['comic-a||c1', 'comic-a||c2']); + assert.strictEqual(chapters.items[0].chapter, '1'); + await source.chapters('comic-a', 1); + assert.strictEqual( + h.getCalls().filter((call) => call.url.includes('/group/default/chapters?')).length, + 1, + '重复翻页不应重新抓取完整章节目录' + ); + const images = await source.chapterImageUrls('comic-a||c1'); + assert.deepStrictEqual(images.urls, ['https://img/1.jpg', 'https://img/2.webp']); +}); diff --git a/src/_test/ui.test.js b/src/_test/ui.test.js index 5c4ddf5..8520d7d 100644 --- a/src/_test/ui.test.js +++ b/src/_test/ui.test.js @@ -240,6 +240,54 @@ test('Z-Library 进入详情不消耗下载额度,点击下载后才解析并 assert.match(onDemand, /每日免费下载额度有限,仅在点击后获取下载地址/); }); +test('漫画源按分类显示,MangaDex 可筛选中文章节并设置图片画质', () => { + const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8'); + const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8'); + const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8'); + const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8'); + const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8'); + + assert.match(html, /id="mangadexQualitySelect"[\s\S]*value="dataSaver"[\s\S]*value="data"/); + assert.match(html, /id="mangadexLanguageSelect"[\s\S]*value="zh"[\s\S]*value="zh-hk"[\s\S]*value="all"/); + assert.match(app, /settings\.get\('mangadex\.imageQuality', 'dataSaver'\)/); + assert.match(app, /settings\.set\('mangadex\.imageQuality'/); + assert.match(app, /settings\.get\('mangadex\.chapterLanguage', 'zh'\)/); + assert.match(app, /settings\.set\('mangadex\.chapterLanguage'/); + assert.match(browse, /\['academic', '学术论文'\]/); + assert.match(browse, /\['manga', '漫画'\]/); + assert.match(browse, //); + assert.match(browse, /sourceIsChapterBased/); + assert.match(browse, /window\.api\.sources\.chapters\(sourceId, mangaId, page, options\)/); + assert.match(browse, /window\.api\.sources\.downloadChapter\(sourceId, payload/); + assert.match(browse, /settings\.get\('mangadex\.chapterLanguage', 'zh'\)/); + assert.match(browse, /settings\.get\('mangadex\.imageQuality', 'dataSaver'\)/); + assert.match(browse, /createDownloadProgress\(row\)/); + assert.match(preload, /chapters:\s*\(sourceId, postId, page, options\)[\s\S]*source:chapters/); + assert.match(preload, /downloadChapter:\s*\(sourceId, payload, onProgress\).*runChapterDownload/); + assert.match(preload, /invokeWithProgress\(\s*'source:chapterProgress',\s*'source:downloadChapter'/); + assert.match(preload, /removeListener\(progressChannel, listener\)/); + assert.match(css, /\.chapter-toolbar\s*\{/); +}); + +test('漫画详情提供不入库的整部在线阅读器,并通过独立 preload 懒加载图片', () => { + const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'manga-online.html'), 'utf8'); + const script = fs.readFileSync(path.join(__dirname, '..', 'ui', 'manga-online.js'), 'utf8'); + const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8'); + const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'manga-online-preload.js'), 'utf8'); + + assert.match(browse, /id="onlineReadBtn">在线阅读整部漫画/); + assert.match(browse, /window\.api\.sources\.readOnline\(state\.activeSourceId/); + assert.doesNotMatch(browse.slice(browse.indexOf('async function readOnline'), browse.indexOf('async function loadChapters')), /library\.add/); + assert.match(html, /Content-Security-Policy" content="default-src 'self'; img-src 'self' blob: data:/); + assert.match(html, /id="chapterList"/); + assert.match(html, /id="pageStack"/); + assert.match(script, /new IntersectionObserver/); + assert.match(script, /URL\.createObjectURL\(new Blob/); + assert.match(script, /ensureNextChapter/); + assert.match(preload, /mangaOnline:image/); + assert.doesNotMatch(preload, /library:|reader:bytes|shell:openPath/); +}); + test('主窗口在设置旁提供持久化明暗主题切换', () => { const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8'); const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8'); diff --git a/src/_test/zip.test.js b/src/_test/zip.test.js new file mode 100644 index 0000000..cbde03f --- /dev/null +++ b/src/_test/zip.test.js @@ -0,0 +1,69 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const zip = require('../zip'); + +test('writeZip/readZip: 基本往返,文本与二进制内容不失真', () => { + const entries = [ + { name: 'mimetype', data: Buffer.from('application/epub+zip') }, + { name: 'a/b.txt', data: Buffer.from('hello 你好', 'utf8') }, + { name: 'c.bin', data: Buffer.from([0, 1, 2, 255, 254, 128]) } + ]; + const buf = zip.writeZip(entries); + const parsed = zip.readZip(buf); + assert.deepStrictEqual([...parsed.keys()], ['mimetype', 'a/b.txt', 'c.bin']); + assert.strictEqual(parsed.get('a/b.txt').toString('utf8'), 'hello 你好'); + assert.deepStrictEqual([...parsed.get('c.bin')], [0, 1, 2, 255, 254, 128]); +}); + +test('writeZip/readZip: 大文件(跨多个 chunk)内容比特级一致', () => { + const big = Buffer.alloc(500000); + for (let i = 0; i < big.length; i++) big[i] = i % 256; + const buf = zip.writeZip([{ name: 'big.bin', data: big }]); + const parsed = zip.readZip(buf); + assert.strictEqual(Buffer.compare(parsed.get('big.bin'), big), 0); +}); + +test('readZip: 能解析第三方(JSZip)产出的 DEFLATE 压缩包', async () => { + const JSZip = require('../ui/vendor/jszip.min.js'); + const jz = new JSZip(); + jz.file('x.txt', 'DEFLATE 测试内容'); + const buf = await jz.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); + const parsed = zip.readZip(buf); + assert.strictEqual(parsed.get('x.txt').toString('utf8'), 'DEFLATE 测试内容'); +}); + +test('writeZip: 产出的包能被第三方(JSZip)正确打开', async () => { + const JSZip = require('../ui/vendor/jszip.min.js'); + const buf = zip.writeZip([ + { name: 'mimetype', data: Buffer.from('application/epub+zip') }, + { name: 'nested/dir/file.txt', data: Buffer.from('nested content') } + ]); + const jz = await JSZip.loadAsync(buf); + assert.ok(jz.file('mimetype')); + const text = await jz.file('nested/dir/file.txt').async('string'); + assert.strictEqual(text, 'nested content'); +}); + +test('readZip: 空 ZIP(EOCD 但无条目)不抛错,返回空 Map', () => { + const buf = zip.writeZip([]); + const parsed = zip.readZip(buf); + assert.strictEqual(parsed.size, 0); +}); + +test('readZip: 非 ZIP 数据抛出可读错误而不是崩溃', () => { + assert.throws(() => zip.readZip(Buffer.from('not a zip file')), /不是有效的 ZIP 文件/); +}); + +test('readZip: 不把未知压缩方式误当作 STORE 内容', () => { + const buf = zip.writeZip([{ name: 'x.bin', data: Buffer.from([1, 2, 3]) }]); + const central = buf.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])); + buf.writeUInt16LE(99, central + 10); + assert.throws(() => zip.readZip(buf), /压缩方式不受支持/); +}); + +test('readZip: 条目内容损坏时通过 CRC 拒绝继续读取', () => { + const buf = zip.writeZip([{ name: 'x.bin', data: Buffer.from([1, 2, 3]) }]); + const dataStart = 30 + Buffer.byteLength('x.bin'); + buf[dataStart] ^= 0xff; + assert.throws(() => zip.readZip(buf), /条目校验失败/); +}); diff --git a/src/atomic-file.js b/src/atomic-file.js index 328f795..459c047 100644 --- a/src/atomic-file.js +++ b/src/atomic-file.js @@ -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 }; diff --git a/src/library/manga-download.js b/src/library/manga-download.js new file mode 100644 index 0000000..6292ca9 --- /dev/null +++ b/src/library/manga-download.js @@ -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 }; diff --git a/src/library/manga-epub.js b/src/library/manga-epub.js new file mode 100644 index 0000000..086edbd --- /dev/null +++ b/src/library/manga-epub.js @@ -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 = '' + + '' + + '' + + ''; + +function xmlText(value) { + return String(value == null ? '' : value) + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '') + .replace(/&/g, '&').replace(//g, '>'); +} + +function xmlAttr(value) { + return xmlText(value).replace(/"/g, '"').replace(/'/g, '''); +} + +function extMime(ext) { + const e = String(ext || '').toLowerCase(); + if (e === 'jpg' || e === 'jpeg') return 'image/jpeg'; + if (e === 'png') return 'image/png'; + if (e === 'gif') return 'image/gif'; + if (e === 'webp') return 'image/webp'; + return 'application/octet-stream'; +} + +function manifestId(path) { + return `f_${path.replace(/[^A-Za-z0-9]+/g, '_')}`; +} + +function pageXhtml(imgHref, label) { + return `${xmlText(label)}` + + '' + + `${xmlAttr(label)}`; +} + +// 章节内文件都在 chapters// 与 images// 下,二者深度相同, +// 页面到图片的相对路径固定是「上两级再进图片目录」。 +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 `
  • ${xmlText(c.label)}
  • `; + }).join(''); + return `` + + `目录`; +} + +function buildOpf(title, sourceId, mangaId, originalLanguage, manifestItems, spineIds) { + const modified = new Date().toISOString().replace(/\.\d+Z$/, 'Z'); + return '' + + '' + + '' + + `peoplelib-${xmlAttr(sourceId)}-${xmlAttr(mangaId)}` + + `${xmlText(title)}` + + `${xmlAttr(originalLanguage || 'ja')}` + + `${modified}` + + '' + + `${manifestItems.join('')}` + + `${spineIds.map((id) => ``).join('')}` + + ''; +} + +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(``); + if (file.kind === 'page') spineIds.push(id); + } + } + manifestItems.push(''); + + 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 +}; diff --git a/src/library/store.js b/src/library/store.js index 8ca4b6a..2735b5c 100644 --- a/src/library/store.js +++ b/src/library/store.js @@ -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(); diff --git a/src/manga-online/session.js b/src/manga-online/session.js new file mode 100644 index 0000000..0f184fd --- /dev/null +++ b/src/manga-online/session.js @@ -0,0 +1,227 @@ +const crypto = require('crypto'); +const mangaDownload = require('../library/manga-download'); + +const MAX_SESSIONS = 8; +const MAX_IMAGE_BYTES = 30 * 1024 * 1024; +const sessions = new Map(); + +function cleanText(value, max) { + return String(value || '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, max); +} + +function sessionId() { + return crypto.randomBytes(18).toString('base64url'); +} + +function create(ownerId, source, input) { + if (!source || !source.id || typeof source.chapters !== 'function' + || typeof source.chapterImageUrls !== 'function') { + throw new Error('该数据源不支持在线漫画阅读'); + } + const mangaId = cleanText(input && input.mangaId, 300); + if (!mangaId) throw new Error('缺少漫画 ID'); + const id = sessionId(); + const language = input && ['zh', 'zh-hk', 'all'].includes(input.language) + ? input.language + : 'zh'; + const quality = input && input.quality === 'data' ? 'data' : 'dataSaver'; + const session = { + id, + ownerId, + source, + sourceId: source.id, + mangaId, + title: cleanText(input && input.title, 300) || '未命名漫画', + cover: cleanText(input && input.cover, 2000), + authors: (Array.isArray(input && input.authors) ? input.authors : []) + .map((author) => cleanText(author, 120)) + .filter(Boolean) + .slice(0, 20), + language, + quality, + chapterPages: new Map(), + allowedChapterIds: new Set(), + manifests: new Map(), + manifestGeneration: 0, + createdAt: Date.now() + }; + sessions.set(id, session); + while (sessions.size > MAX_SESSIONS) sessions.delete(sessions.keys().next().value); + return metaOf(session); +} + +function owned(ownerId, id) { + const session = sessions.get(String(id || '')); + if (!session || session.ownerId !== ownerId) throw new Error('在线漫画会话无效或已关闭'); + return session; +} + +function metaOf(session) { + return { + sessionId: session.id, + sourceId: session.sourceId, + mangaId: session.mangaId, + title: session.title, + cover: session.cover, + authors: session.authors.slice(), + language: session.language, + quality: session.quality + }; +} + +function meta(ownerId, id) { + return metaOf(owned(ownerId, id)); +} + +function abortManifest(manifest) { + for (const controller of manifest.controllers || []) controller.abort(); + manifest.controllers.clear(); +} + +async function chapters(ownerId, id, page) { + const session = owned(ownerId, id); + const currentPage = Math.max(1, Math.floor(Number(page) || 1)); + const cached = session.chapterPages.get(currentPage); + if (cached) return cached; + const result = await session.source.chapters( + session.mangaId, + currentPage, + { language: session.language } + ); + const items = (Array.isArray(result && result.items) ? result.items : []) + .filter((chapter) => chapter && chapter.chapterId && !chapter.external && !chapter.unavailable) + .map((chapter) => ({ + chapterId: cleanText(chapter.chapterId, 500), + label: cleanText(chapter.label, 300) || '未命名章节', + volume: cleanText(chapter.volume, 80), + chapter: cleanText(chapter.chapter, 80), + pages: Math.max(0, Math.floor(Number(chapter.pages) || 0)), + group: cleanText(chapter.group, 200) + })); + for (const chapter of items) session.allowedChapterIds.add(chapter.chapterId); + const value = { + items, + page: currentPage, + maxPage: Math.max(currentPage, Math.floor(Number(result && result.maxPage) || 1)) + }; + session.chapterPages.set(currentPage, value); + return value; +} + +async function chapterManifest(ownerId, id, chapterId) { + const session = owned(ownerId, id); + const normalizedId = cleanText(chapterId, 500); + if (!session.allowedChapterIds.has(normalizedId)) throw new Error('该章节不属于当前在线漫画会话'); + const generation = ++session.manifestGeneration; + for (const manifest of session.manifests.values()) abortManifest(manifest); + const info = await session.source.chapterImageUrls(normalizedId, session.quality); + if (generation !== session.manifestGeneration) throw new Error('章节加载已取消'); + const urls = Array.isArray(info && info.urls) ? info.urls.filter(Boolean) : []; + if (!urls.length) throw new Error('该章节没有可阅读的图片'); + const manifestId = sessionId(); + session.manifests.clear(); + session.manifests.set(manifestId, { + chapterId: normalizedId, + urls, + headers: info.headers || {}, + mustReport: !!info.mustReport, + version: 0, + refreshPromise: null, + controllers: new Set() + }); + return { manifestId, pages: urls.length }; +} + +function mimeType(bytes) { + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg'; + } + if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) { + return 'image/png'; + } + if (bytes.length >= 6 && /^GIF8[79]a$/.test(bytes.subarray(0, 6).toString('ascii'))) { + return 'image/gif'; + } + if (bytes.length >= 12 + && bytes.subarray(0, 4).toString('ascii') === 'RIFF' + && bytes.subarray(8, 12).toString('ascii') === 'WEBP') { + return 'image/webp'; + } + throw new Error('在线漫画返回了不支持的图片格式'); +} + +async function image(ownerId, id, manifestId, index) { + const session = owned(ownerId, id); + const manifest = session.manifests.get(String(manifestId || '')); + const pageIndex = Math.floor(Number(index)); + if (!manifest || !Number.isSafeInteger(pageIndex) + || pageIndex < 0 || pageIndex >= manifest.urls.length) { + throw new Error('在线漫画图片请求无效'); + } + const controller = new AbortController(); + manifest.controllers.add(controller); + const version = manifest.version; + let bytes; + try { + bytes = await mangaDownload.fetchOnlineImage( + manifest.urls[pageIndex], + manifest.headers, + manifest.mustReport, + MAX_IMAGE_BYTES, + controller.signal + ); + } catch (error) { + if (controller.signal.aborted) throw new Error('图片加载已取消'); + if (manifest.version === version) { + if (!manifest.refreshPromise) { + manifest.refreshPromise = session.source + .chapterImageUrls(manifest.chapterId, session.quality) + .then((fresh) => { + const urls = Array.isArray(fresh && fresh.urls) ? fresh.urls.filter(Boolean) : []; + if (!urls[pageIndex]) throw error; + manifest.urls = urls; + manifest.headers = fresh.headers || {}; + manifest.mustReport = !!fresh.mustReport; + manifest.version++; + }) + .finally(() => { manifest.refreshPromise = null; }); + } + await manifest.refreshPromise; + } + bytes = await mangaDownload.fetchOnlineImage( + manifest.urls[pageIndex], + manifest.headers, + manifest.mustReport, + MAX_IMAGE_BYTES, + controller.signal + ); + } finally { + manifest.controllers.delete(controller); + } + if (bytes.length > MAX_IMAGE_BYTES) throw new Error('单张漫画图片超过 30 MB,已停止加载'); + return { bytes, mimeType: mimeType(bytes) }; +} + +function close(ownerId, id) { + const session = owned(ownerId, id); + for (const manifest of session.manifests.values()) abortManifest(manifest); + sessions.delete(session.id); + return true; +} + +function closeOwner(ownerId) { + for (const [id, session] of sessions) { + if (session.ownerId !== ownerId) continue; + for (const manifest of session.manifests.values()) abortManifest(manifest); + sessions.delete(id); + } +} + +function reset() { + for (const session of sessions.values()) { + for (const manifest of session.manifests.values()) abortManifest(manifest); + } + sessions.clear(); +} + +module.exports = { create, meta, chapters, chapterManifest, image, close, closeOwner, reset }; diff --git a/src/manga-online/window.js b/src/manga-online/window.js new file mode 100644 index 0000000..c864e91 --- /dev/null +++ b/src/manga-online/window.js @@ -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 }; diff --git a/src/sources/copymanga.js b/src/sources/copymanga.js new file mode 100644 index 0000000..52fcb6c --- /dev/null +++ b/src/sources/copymanga.js @@ -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); + } +}; diff --git a/src/sources/http.js b/src/sources/http.js index ff7cbd6..15b57d7 100644 --- a/src/sources/http.js +++ b/src/sources/http.js @@ -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> 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 +}; diff --git a/src/sources/index.js b/src/sources/index.js index 12f19a7..67a6890 100644 --- a/src/sources/index.js +++ b/src/sources/index.js @@ -14,6 +14,27 @@ const openstax = require('./openstax'); const opentextbook = require('./opentextbook'); const wikisourceZh = require('./wikisource-zh'); const wikisourceEn = require('./wikisource-en'); +const mangadex = require('./mangadex'); +const copymanga = require('./copymanga'); + +const CATEGORY_BY_ID = { + arxiv: 'academic', + doaj: 'academic', + pmc: 'academic', + biorxiv: 'academic', + semanticscholar: 'academic', + scihub: 'academic', + gutenberg: 'books', + openlibrary: 'books', + standardebooks: 'books', + libgen: 'books', + zlib: 'books', + openstax: 'open', + opentextbook: 'open', + 'wikisource-zh': 'open', + 'wikisource-en': 'open', + motw: 'archive' +}; const sources = [ arxiv, @@ -31,7 +52,9 @@ const sources = [ libgen, zlib, scihub, - motw + motw, + mangadex, + copymanga ]; const byId = new Map(sources.map((s) => [s.id, s])); @@ -39,8 +62,11 @@ function listSources() { return sources.map((s) => ({ id: s.id, name: s.name, + category: s.category || CATEGORY_BY_ID[s.id] || 'other', + experimental: s.experimental === true, supportsSearch: s.supportsSearch !== false, - downloadOnDemand: s.downloadOnDemand === true + downloadOnDemand: s.downloadOnDemand === true, + chapterBased: s.chapterBased === true })); } diff --git a/src/sources/mangadex.js b/src/sources/mangadex.js new file mode 100644 index 0000000..617952c --- /dev/null +++ b/src/sources/mangadex.js @@ -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); + } +}; diff --git a/src/ui/app.js b/src/ui/app.js index e675ff1..d914e43 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -172,8 +172,28 @@ async function refreshAskSave() { } $('askSaveChk').onchange = () => window.api.settings.set('askSavePath', $('askSaveChk').checked); +async function refreshMangadexQuality() { + const r = await window.api.settings.get('mangadex.imageQuality', 'dataSaver'); + $('mangadexQualitySelect').value = (r.ok && r.data === 'data') ? 'data' : 'dataSaver'; +} +$('mangadexQualitySelect').onchange = () => ( + window.api.settings.set('mangadex.imageQuality', $('mangadexQualitySelect').value) +); + +async function refreshMangadexLanguage() { + const r = await window.api.settings.get('mangadex.chapterLanguage', 'zh'); + $('mangadexLanguageSelect').value = r.ok && ['zh', 'zh-hk', 'all'].includes(r.data) + ? r.data + : 'zh'; +} +$('mangadexLanguageSelect').onchange = () => ( + window.api.settings.set('mangadex.chapterLanguage', $('mangadexLanguageSelect').value) +); + refreshLibraryDir(); refreshAskSave(); +refreshMangadexQuality(); +refreshMangadexLanguage(); async function refreshProxy() { const r = await window.api.proxy.get(); diff --git a/src/ui/index.html b/src/ui/index.html index 8373d81..1cc1296 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -195,6 +195,29 @@
    +
    +
    +
    +
    MangaDex 章节语言
    +
    默认只显示简体中文章节,也可切换到繁体中文或全部语言
    +
    + +
    +
    +
    +
    漫画图片画质
    +
    压缩画质体积小、下载快;原画质更清晰但占用更多空间与流量
    +
    + +
    +
    diff --git a/src/ui/manga-online.css b/src/ui/manga-online.css new file mode 100644 index 0000000..a1d7ddb --- /dev/null +++ b/src/ui/manga-online.css @@ -0,0 +1,276 @@ +:root { + --bg: #111318; + --panel: #171a20; + --panel-strong: #1c2027; + --line: #2b313b; + --text: #e3e7ee; + --muted: #929cab; + --accent: #6ea8fe; + --hover: rgba(255, 255, 255, 0.07); + --title-start: #171b26; + --title-end: #12141c; +} + +:root[data-ui-theme="light"] { + --bg: #eef2f7; + --panel: #ffffff; + --panel-strong: #f7f9fc; + --line: #d5dde8; + --text: #1f2937; + --muted: #667386; + --accent: #2563eb; + --hover: rgba(15, 23, 42, 0.07); + --title-start: #ffffff; + --title-end: #f1f4f8; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + width: 100%; + height: 100%; + overflow: hidden; + color: var(--text); + background: var(--bg); + font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif; +} + +body { + display: flex; + flex-direction: column; +} + +button, input { font: inherit; } +.hidden { display: none !important; } + +.titlebar { + height: 44px; + flex: 0 0 44px; + display: flex; + align-items: center; + padding-left: 14px; + border-bottom: 1px solid var(--line); + background: linear-gradient(135deg, var(--title-start), var(--title-end)); + -webkit-app-region: drag; +} + +.brand { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + color: var(--accent); + font-size: 15px; + font-weight: 700; +} + +.brand-logo { width: 25px; height: 25px; border-radius: 6px; } +.brand-logo-light { display: none; } +:root[data-ui-theme="light"] .brand-logo-dark { display: none; } +:root[data-ui-theme="light"] .brand-logo-light { display: block; } + +.brand-sub { + min-width: 0; + max-width: 56vw; + overflow: hidden; + color: var(--muted); + font-size: 12px; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.titlebar-spacer { flex: 1; } +.titlebar-controls { height: 100%; display: flex; -webkit-app-region: no-drag; } + +.win-btn { + width: 46px; + border: 0; + color: var(--text); + background: transparent; + cursor: pointer; +} + +.win-btn:hover { background: var(--hover); } +.win-close:hover { color: #fff; background: #c42b1c; } + +.toolbar { + min-height: 48px; + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + border-bottom: 1px solid var(--line); + background: var(--panel); +} + +.tool-btn, .load-more, .chapter-end, .icon-btn { + border: 1px solid var(--line); + border-radius: 7px; + color: var(--text); + background: var(--panel-strong); + cursor: pointer; +} + +.tool-btn { height: 32px; padding: 0 13px; } +.tool-btn:hover, .load-more:hover, .chapter-end:hover, .icon-btn:hover { border-color: var(--accent); } +.tool-btn:disabled { cursor: default; opacity: 0.42; } + +.chapter-title { + min-width: 120px; + max-width: 30vw; + overflow: hidden; + color: var(--text); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.width-control { + margin-left: auto; + display: flex; + align-items: center; + gap: 7px; + color: var(--muted); + font-size: 12px; +} + +.width-control input { width: 130px; accent-color: var(--accent); } +.quality-label { min-width: 54px; color: var(--muted); font-size: 12px; } + +.reader-layout { + min-height: 0; + flex: 1; + display: flex; +} + +.toc-pane { + width: 290px; + min-width: 240px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--line); + background: var(--panel); +} + +.toc-pane.collapsed { display: none; } + +.toc-head { + flex: 0 0 auto; + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 14px 12px 10px; + border-bottom: 1px solid var(--line); +} + +.toc-meta { margin-top: 4px; color: var(--muted); font-size: 12px; } +.icon-btn { width: 28px; height: 28px; } + +.chapter-list { + min-height: 0; + flex: 1; + overflow: auto; + padding: 7px; +} + +.chapter-item { + width: 100%; + min-height: 39px; + display: block; + padding: 8px 10px; + overflow: hidden; + border: 0; + border-radius: 7px; + color: var(--text); + background: transparent; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.chapter-item:hover { background: var(--hover); } +.chapter-item.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); } +.chapter-item.loading::after { content: " · 加载中"; color: var(--muted); font-size: 11px; } + +.load-more { + min-height: 36px; + margin: 8px; + flex: 0 0 auto; +} + +.page-scroller { + min-width: 0; + flex: 1; + overflow: auto; + overscroll-behavior: contain; + background: var(--bg); +} + +.reader-status { + max-width: 720px; + margin: 28px auto; + padding: 16px; + color: var(--muted); + text-align: center; +} + +.reader-status.error { color: #e36b66; } + +.page-stack { + width: min(var(--manga-page-width, 900px), 100%); + margin: 0 auto; + background: #0b0c0f; +} + +.manga-page { + position: relative; + width: 100%; + min-height: 260px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + color: #8f98a6; + background: #11141a; + border-bottom: 1px solid #252a33; +} + +.manga-page::before { + content: attr(data-placeholder); + padding: 30px; + text-align: center; +} + +.manga-page.loaded { min-height: 0; } +.manga-page.loaded::before { display: none; } +.manga-page.error { min-height: 160px; color: #e36b66; cursor: pointer; } + +.manga-page img { + display: block; + width: 100%; + height: auto; +} + +.chapter-end { + min-width: 220px; + min-height: 42px; + display: block; + margin: 24px auto 44px; + padding: 0 24px; +} + +@media (max-width: 850px) { + .width-control { display: none; } + .quality-label { display: none; } + .toc-pane { + position: absolute; + z-index: 5; + top: 92px; + bottom: 0; + box-shadow: 8px 0 24px rgba(0, 0, 0, 0.28); + } +} diff --git a/src/ui/manga-online.html b/src/ui/manga-online.html new file mode 100644 index 0000000..7ad87b7 --- /dev/null +++ b/src/ui/manga-online.html @@ -0,0 +1,61 @@ + + + + + + 在线漫画 + + + +
    +
    + + + 在线漫画 + 正在打开… +
    +
    +
    + + + + +
    +
    + +
    + + + 尚未加载章节 + + + +
    + +
    + + +
    +
    正在读取漫画信息…
    +
    + +
    +
    + + + + diff --git a/src/ui/manga-online.js b/src/ui/manga-online.js new file mode 100644 index 0000000..c8d807c --- /dev/null +++ b/src/ui/manga-online.js @@ -0,0 +1,313 @@ +const api = window.mangaApi; +const $ = (id) => document.getElementById(id); +const params = new URLSearchParams(location.search); +const sessionId = params.get('sessionId') || ''; + +const state = { + meta: null, + chapters: [], + chapterIds: new Set(), + chapterPage: 0, + chapterMaxPage: 1, + chapterIndex: -1, + manifestId: '', + loadToken: 0, + objectUrls: new Set(), + observer: null, + retentionObserver: null, + queuedPages: [], + activePages: 0, + chapterNavigation: false, + theme: 'dark' +}; + +function errorText(result, fallback) { + return result && result.error ? String(result.error) : fallback; +} + +function setStatus(text, error) { + const status = $('readerStatus'); + status.textContent = text; + status.classList.toggle('error', !!error); + status.classList.remove('hidden'); +} + +function applyTheme(theme) { + state.theme = theme === 'light' ? 'light' : 'dark'; + document.documentElement.dataset.uiTheme = state.theme; +} + +function revokeImages() { + for (const url of state.objectUrls) URL.revokeObjectURL(url); + state.objectUrls.clear(); +} + +function renderChapterList() { + const list = $('chapterList'); + list.textContent = ''; + state.chapters.forEach((chapter, index) => { + const button = document.createElement('button'); + button.className = 'chapter-item'; + button.textContent = chapter.label; + button.title = chapter.group ? `${chapter.label} · ${chapter.group}` : chapter.label; + button.classList.toggle('active', index === state.chapterIndex); + button.addEventListener('click', () => openChapter(index)); + list.appendChild(button); + }); + $('loadMoreBtn').classList.toggle('hidden', state.chapterPage >= state.chapterMaxPage); + $('tocMeta').textContent = `已载入 ${state.chapters.length} 章`; +} + +async function loadChapterPage(page) { + const button = $('loadMoreBtn'); + button.disabled = true; + button.textContent = '加载中…'; + const result = await api.chapters(sessionId, page); + button.disabled = false; + button.textContent = '加载更多章节'; + if (!result || !result.ok || !result.data) { + setStatus(errorText(result, '章节目录加载失败'), true); + return false; + } + for (const chapter of result.data.items || []) { + if (state.chapterIds.has(chapter.chapterId)) continue; + state.chapterIds.add(chapter.chapterId); + state.chapters.push(chapter); + } + state.chapterPage = result.data.page || page; + state.chapterMaxPage = result.data.maxPage || state.chapterPage; + renderChapterList(); + return true; +} + +async function ensureNextChapter() { + if (state.chapterIndex + 1 < state.chapters.length) return true; + if (state.chapterPage >= state.chapterMaxPage) return false; + return loadChapterPage(state.chapterPage + 1); +} + +function updateNavigation() { + const hasPrevious = state.chapterIndex > 0; + const mayHaveNext = state.chapterIndex + 1 < state.chapters.length + || state.chapterPage < state.chapterMaxPage; + $('prevChapterBtn').disabled = state.chapterNavigation || !hasPrevious; + $('nextChapterBtn').disabled = state.chapterNavigation || !mayHaveNext; + $('chapterEndNextBtn').classList.toggle('hidden', !mayHaveNext); + renderChapterList(); +} + +function bytesOf(value) { + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (value && value.data) return Uint8Array.from(value.data); + throw new Error('图片数据格式无效'); +} + +function schedulePage(element, index, token) { + if (element.dataset.state) return; + element.dataset.state = 'queued'; + state.queuedPages.push({ element, index, token, manifestId: state.manifestId }); + pumpPages(); +} + +function pumpPages() { + while (state.activePages < 4 && state.queuedPages.length) { + const job = state.queuedPages.shift(); + if (job.token !== state.loadToken || !job.element.isConnected) continue; + state.activePages++; + loadPage(job).finally(() => { + state.activePages--; + pumpPages(); + }); + } +} + +async function loadPage(job) { + const { element, index, token } = job; + element.dataset.state = 'loading'; + element.dataset.placeholder = `第 ${index + 1} 页加载中…`; + const result = await api.image(sessionId, job.manifestId, index); + if (token !== state.loadToken || !element.isConnected) return; + if (!result || !result.ok || !result.data) { + element.dataset.state = 'error'; + element.dataset.placeholder = `${errorText(result, '图片加载失败')},点击重试`; + element.classList.add('error'); + return; + } + let bytes; + try { + bytes = bytesOf(result.data.bytes); + } catch (error) { + element.dataset.state = 'error'; + element.dataset.placeholder = `${error.message},点击重试`; + element.classList.add('error'); + return; + } + const url = URL.createObjectURL(new Blob([bytes], { type: result.data.mimeType })); + state.objectUrls.add(url); + const image = document.createElement('img'); + image.alt = `第 ${index + 1} 页`; + image.decoding = 'async'; + image.addEventListener('load', () => { + if (token !== state.loadToken) return; + URL.revokeObjectURL(url); + state.objectUrls.delete(url); + element.style.minHeight = ''; + element.classList.add('loaded'); + element.dataset.state = 'loaded'; + }, { once: true }); + image.addEventListener('error', () => { + URL.revokeObjectURL(url); + state.objectUrls.delete(url); + element.textContent = ''; + element.classList.add('error'); + element.classList.remove('loaded'); + element.dataset.state = 'error'; + element.dataset.placeholder = '图片解码失败,点击重试'; + }, { once: true }); + image.src = url; + element.textContent = ''; + element.appendChild(image); +} + +function buildPages(count, token) { + const stack = $('pageStack'); + stack.textContent = ''; + state.queuedPages = []; + state.retentionObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + const page = entry.target; + if (entry.isIntersecting || page.dataset.state !== 'loaded') continue; + const height = Math.max(160, Math.ceil(page.getBoundingClientRect().height)); + const image = page.querySelector('img'); + if (image) image.remove(); + page.style.minHeight = `${height}px`; + page.classList.remove('loaded'); + page.dataset.state = ''; + page.dataset.placeholder = `第 ${Number(page.dataset.index) + 1} 页`; + state.observer.observe(page); + } + }, { root: $('pageScroller'), rootMargin: '2600px 0px' }); + state.observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const index = Number(entry.target.dataset.index); + schedulePage(entry.target, index, token); + state.observer.unobserve(entry.target); + } + }, { root: $('pageScroller'), rootMargin: '1200px 0px' }); + for (let index = 0; index < count; index++) { + const page = document.createElement('div'); + page.className = 'manga-page'; + page.dataset.index = String(index); + page.dataset.placeholder = `第 ${index + 1} 页`; + page.addEventListener('click', () => { + if (page.dataset.state !== 'error') return; + page.classList.remove('error'); + page.dataset.state = ''; + schedulePage(page, index, token); + }); + stack.appendChild(page); + state.observer.observe(page); + state.retentionObserver.observe(page); + } +} + +async function openChapter(index) { + const chapter = state.chapters[index]; + if (!chapter) return; + const token = ++state.loadToken; + if (state.observer) state.observer.disconnect(); + if (state.retentionObserver) state.retentionObserver.disconnect(); + state.queuedPages = []; + revokeImages(); + state.manifestId = ''; + state.chapterIndex = index; + $('pageStack').textContent = ''; + $('chapterTitle').textContent = chapter.label; + $('chapterEndNextBtn').classList.add('hidden'); + $('pageScroller').scrollTop = 0; + setStatus(`正在加载「${chapter.label}」…`); + updateNavigation(); + + const result = await api.chapterManifest(sessionId, chapter.chapterId); + if (token !== state.loadToken) return; + if (!result || !result.ok || !result.data) { + setStatus(errorText(result, '章节加载失败'), true); + return; + } + state.manifestId = result.data.manifestId; + $('readerStatus').classList.add('hidden'); + buildPages(result.data.pages, token); + updateNavigation(); +} + +async function nextChapter() { + if (state.chapterNavigation) return; + state.chapterNavigation = true; + const currentIndex = state.chapterIndex; + updateNavigation(); + try { + if (!await ensureNextChapter()) return; + await openChapter(currentIndex + 1); + } finally { + state.chapterNavigation = false; + updateNavigation(); + } +} + +async function init() { + if (!sessionId) { + setStatus('在线漫画会话参数缺失', true); + return; + } + const [themeResult, metaResult] = await Promise.all([ + api.getTheme(), + api.meta(sessionId) + ]); + applyTheme(themeResult && themeResult.ok ? themeResult.data : 'dark'); + if (!metaResult || !metaResult.ok || !metaResult.data) { + setStatus(errorText(metaResult, '在线漫画会话已失效'), true); + return; + } + state.meta = metaResult.data; + $('bookTitle').textContent = state.meta.title; + $('tocTitle').textContent = state.meta.title; + $('qualityLabel').textContent = state.meta.quality === 'data' ? '原画质' : '压缩画质'; + document.title = `${state.meta.title} · 在线漫画`; + const loaded = await loadChapterPage(1); + if (!loaded) return; + if (!state.chapters.length) { + setStatus('该漫画暂无可在线阅读的章节', true); + return; + } + openChapter(0); +} + +$('tocToggleBtn').addEventListener('click', () => $('tocPane').classList.toggle('collapsed')); +$('tocCloseBtn').addEventListener('click', () => $('tocPane').classList.add('collapsed')); +$('prevChapterBtn').addEventListener('click', () => openChapter(state.chapterIndex - 1)); +$('nextChapterBtn').addEventListener('click', nextChapter); +$('chapterEndNextBtn').addEventListener('click', nextChapter); +$('loadMoreBtn').addEventListener('click', () => loadChapterPage(state.chapterPage + 1)); +$('pageWidthRange').addEventListener('input', (event) => { + const value = Math.max(480, Math.min(1400, Number(event.target.value) || 900)); + document.documentElement.style.setProperty('--manga-page-width', `${value}px`); + $('pageWidthLabel').textContent = `${value}px`; +}); +$('themeBtn').addEventListener('click', async () => { + const result = await api.setTheme(state.theme === 'dark' ? 'light' : 'dark'); + if (result && result.ok) applyTheme(result.data); +}); +$('minBtn').addEventListener('click', api.minimize); +$('maxBtn').addEventListener('click', api.maximize); +$('closeBtn').addEventListener('click', api.close); +api.onThemeChanged(applyTheme); +window.addEventListener('beforeunload', () => { + if (state.observer) state.observer.disconnect(); + if (state.retentionObserver) state.retentionObserver.disconnect(); + revokeImages(); + api.closeSession(sessionId).catch(() => {}); +}); + +init().catch((error) => setStatus(error.message || String(error), true)); diff --git a/src/ui/style.css b/src/ui/style.css index 5ffada2..248067a 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -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; } diff --git a/src/ui/views/browse.js b/src/ui/views/browse.js index cba19ae..839235b 100644 --- a/src/ui/views/browse.js +++ b/src/ui/views/browse.js @@ -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 ? `` : ''; - sourceSelect.innerHTML = aggOpt + list.map((s) => - ``).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) => ( + `` + )).join(''); + return `${options}`; + }).join(''); + sourceSelect.innerHTML = aggOpt + grouped; if (!list.length) { state.sourceId = null; grid.innerHTML = '
    未启用任何数据源,请在设置中开启
    '; @@ -313,8 +331,12 @@ const Browse = (() => { return; } state.currentDetail = res.data; - renderDetail(res.data); - if (sourceDownloadsOnDemand(activeSourceId)) { + const chapterBased = sourceIsChapterBased(activeSourceId); + renderDetail(res.data, chapterBased); + if (chapterBased) { + state.chapterPage = 1; + loadChapters(postId, token, 1); + } else if (sourceDownloadsOnDemand(activeSourceId)) { renderOnDemandDownload(postId, activeSourceId, token); } else { loadDownload(postId, activeSourceId, token); @@ -332,7 +354,12 @@ const Browse = (() => { return !!(source && source.downloadOnDemand); } - function renderDetail(d) { + function sourceIsChapterBased(id) { + const source = state.sourceList.find((item) => item.id === id); + return !!(source && source.chapterBased); + } + + function renderDetail(d, chapterBased) { const tagsHtml = (d.tags || []).map((t) => { const idx = t.indexOf(':'); if (idx > 0) return `
    ${escapeHtml(t.slice(0, idx))}${escapeHtml(t.slice(idx + 1))}
    `; @@ -341,6 +368,15 @@ const Browse = (() => { const authorsHtml = (d.authors && d.authors.length) ? `
    ${escapeHtml(d.authors.join(', '))}
    ` : ''; const briefHtml = d.brief ? `
    简介 / 摘要
    ${escapeHtml(d.brief)}
    ` : ''; + const sectionTitle = chapterBased ? '章节列表' : '下载 / 全文'; + const sectionBody = chapterBased + ? `
    + + + +
    +
    加载中...
    ` + : '
    下载信息获取中...
    '; detailContent.innerHTML = `
    @@ -352,17 +388,200 @@ const Browse = (() => { ${d.date ? `
    日期${escapeHtml(d.date)}
    ` : ''} ${tagsHtml} ${d.url ? `` : ''} + ${chapterBased ? '' : ''}
    -
    下载 / 全文
    -
    下载信息获取中...
    +
    ${sectionTitle}
    + ${sectionBody} ${briefHtml} `; $('addLibBtn').onclick = addToLibrary; + const onlineReadBtn = $('onlineReadBtn'); + if (onlineReadBtn) onlineReadBtn.onclick = readOnline; const urlLink = $('detailUrlLink'); if (urlLink) urlLink.onclick = (e) => { e.preventDefault(); window.api.openExternal(d.url); }; + if (chapterBased) { + $('chapterPrevBtn').onclick = () => { + if (state.chapterPage > 1) { + loadChapters(state.currentPostId, state.detailToken, state.chapterPage - 1); + } + }; + $('chapterNextBtn').onclick = () => { + if (state.chapterPage < state.chapterMaxPage) { + loadChapters(state.currentPostId, state.detailToken, state.chapterPage + 1); + } + }; + } + } + + async function readOnline() { + const button = $('onlineReadBtn'); + if (!button || !state.currentDetail) return; + button.disabled = true; + button.textContent = '正在打开…'; + const [qualitySetting, languageSetting] = await Promise.all([ + window.api.settings.get('mangadex.imageQuality', 'dataSaver'), + state.activeSourceId === 'mangadex' + ? window.api.settings.get('mangadex.chapterLanguage', 'zh') + : Promise.resolve({ ok: true, data: 'zh' }) + ]); + const quality = qualitySetting.ok && qualitySetting.data === 'data' ? 'data' : 'dataSaver'; + const language = languageSetting.ok && ['zh', 'zh-hk', 'all'].includes(languageSetting.data) + ? languageSetting.data + : 'zh'; + const result = await window.api.sources.readOnline(state.activeSourceId, { + mangaId: state.currentPostId, + title: state.currentDetail.title, + cover: state.currentDetail.cover, + authors: state.currentDetail.authors, + language, + quality + }); + button.disabled = false; + button.textContent = result.ok ? '在线阅读器已打开' : '在线阅读整部漫画'; + button.title = result.ok ? '' : result.error || '在线阅读器打开失败'; + } + + const CHAPTER_LANG_LABEL = { + zh: '中文', 'zh-hk': '繁体中文', en: '英语', ja: '日语', ko: '韩语' + }; + + function chapterMetaLine(c) { + const bits = []; + if (c.translatedLanguage) bits.push(CHAPTER_LANG_LABEL[c.translatedLanguage] || c.translatedLanguage); + if (c.group) bits.push(c.group); + if (c.pages) bits.push(`${c.pages} 页`); + return bits.join(' · '); + } + + function chapterRowHtml(c) { + const disabled = c.external || c.unavailable; + const hint = c.external ? '(站外章节,无法下载)' : (c.unavailable ? '(暂不可用)' : ''); + const meta = chapterMetaLine(c); + return ` +
    + ${escapeHtml(c.label)}${hint} + ${meta ? `${escapeHtml(meta)}` : ''} + +
    `; + } + + 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 = '
    加载中...
    '; + 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 = `
    加载失败:${escapeHtml(res.error)}
    `; + $('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 = '
    该漫画暂无可用章节
    '; + return; + } + list.innerHTML = items.map(chapterRowHtml).join(''); + list.querySelectorAll('.dl-btn[data-chapter-dl]').forEach((btn) => { + const chapter = items.find((c) => c.chapterId === btn.dataset.chapterDl); + btn.onclick = () => downloadChapter(btn, chapter); + }); + } + + function mangaEntryMeta() { + const d = state.currentDetail || {}; + return { + ...entryMeta(), + mangaId: state.currentPostId, + originalLanguage: d.originalLanguage || 'ja' + }; + } + + async function downloadChapter(btn, chapter) { + if (!chapter) return; + const sourceId = state.activeSourceId; + const entry = mangaEntryMeta(); + const row = btn.closest('.dl-file-row'); + const oldProgress = row && row.querySelector('.dl-progress'); + if (oldProgress) oldProgress.remove(); + const oldError = row && row.querySelector('.dl-error'); + if (oldError) oldError.remove(); + const progress = row ? createDownloadProgress(row) : null; + if (progress) progress.label.textContent = '准备下载…'; + btn.disabled = true; + btn.textContent = '下载中...'; + + const qualitySetting = await window.api.settings.get('mangadex.imageQuality', 'dataSaver'); + const quality = qualitySetting.ok && qualitySetting.data === 'data' ? 'data' : 'dataSaver'; + const payload = { + ...entry, + chapterId: chapter.chapterId, + label: chapter.label, + volume: chapter.volume, + chapter: chapter.chapter, + translatedLanguage: chapter.translatedLanguage, + group: chapter.group, + quality + }; + + let res; + try { + res = await window.api.sources.downloadChapter(sourceId, payload, (data) => { + if (!progress || !data || !data.total) return; + const percent = Math.max(0, Math.min(1, data.done / data.total)); + progress.box.classList.remove('indeterminate'); + progress.fill.style.width = `${Math.round(percent * 100)}%`; + progress.label.textContent = `${data.done} / ${data.total} 页`; + }); + } catch (error) { + res = { ok: false, error: (error && error.message) || String(error) }; + } + + if (!res.ok) { + if (progress) { + progress.box.classList.add('failed'); + progress.label.textContent = res.error || '下载失败'; + } + btn.textContent = '重试'; + btn.title = res.error || ''; + btn.disabled = false; + return; + } + + if (progress) { + progress.box.classList.remove('indeterminate'); + progress.fill.style.width = '100%'; + progress.label.textContent = '下载完成'; + setTimeout(() => progress.box.remove(), 900); + } + btn.textContent = '已下载'; + btn.title = ''; + btn.classList.add('downloaded'); + if (window.Library) window.Library.markDirty(); + refreshAddButton(); } async function refreshAddButton() { diff --git a/src/zip.js b/src/zip.js new file mode 100644 index 0000000..24ed23a --- /dev/null +++ b/src/zip.js @@ -0,0 +1,146 @@ +// 最小 ZIP 读写:写入仅支持 STORE(无压缩)条目。 +// +// 用途:主进程侧组装/追加漫画 EPUB(EPUB 本质是 ZIP 容器)。图片本身已经是 +// JPEG/PNG,压缩收益很低,STORE 把复杂度降到最低——不需要引入 deflate 编解码, +// Node 内置 zlib 从 v22.15 起自带 crc32,Electron 43 打包的 Node 24 满足要求。 +// +// 读取端兼容 STORE 与 DEFLATE(用 zlib.inflateRawSync 解压),但写入端只产出 +// STORE,因为唯一的写入场景(漫画 EPUB)由本模块自己创建,不需要往复压缩。 + +const zlib = require('zlib'); + +const LOCAL_FILE_SIG = 0x04034b50; +const CENTRAL_DIR_SIG = 0x02014b50; +const END_OF_CENTRAL_DIR_SIG = 0x06054b50; +const METHOD_STORE = 0; +const METHOD_DEFLATE = 8; + +function dosDateTime(date) { + const d = date || new Date(); + const time = ((d.getHours() & 0x1f) << 11) | ((d.getMinutes() & 0x3f) << 5) | ((d.getSeconds() >> 1) & 0x1f); + const dosDate = (((d.getFullYear() - 1980) & 0x7f) << 9) | (((d.getMonth() + 1) & 0xf) << 5) | (d.getDate() & 0x1f); + return { time, dosDate }; +} + +// 写入端只产出 STORE 条目:entries 为 [{ name, data(Buffer) }]。 +function writeZip(entries) { + const { time, dosDate } = dosDateTime(new Date()); + const localParts = []; + const centralParts = []; + let offset = 0; + + for (const entry of entries) { + const nameBuf = Buffer.from(entry.name, 'utf8'); + const data = entry.data; + const crc = zlib.crc32(data) >>> 0; + const size = data.length; + + const local = Buffer.alloc(30); + local.writeUInt32LE(LOCAL_FILE_SIG, 0); + local.writeUInt16LE(20, 4); // version needed + local.writeUInt16LE(0x0800, 6); // 通用标志位:文件名 UTF-8 + local.writeUInt16LE(METHOD_STORE, 8); + local.writeUInt16LE(time, 10); + local.writeUInt16LE(dosDate, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(size, 18); + local.writeUInt32LE(size, 22); + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); + + localParts.push(local, nameBuf, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(CENTRAL_DIR_SIG, 0); + central.writeUInt16LE(20, 4); // version made by + central.writeUInt16LE(20, 6); // version needed + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(METHOD_STORE, 10); + central.writeUInt16LE(time, 12); + central.writeUInt16LE(dosDate, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(size, 20); + central.writeUInt32LE(size, 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt16LE(0, 30); // extra length + central.writeUInt16LE(0, 32); // comment length + central.writeUInt16LE(0, 34); // disk number + central.writeUInt16LE(0, 36); // internal attrs + central.writeUInt32LE(0, 38); // external attrs + central.writeUInt32LE(offset, 42); + + centralParts.push(central, nameBuf); + offset += local.length + nameBuf.length + data.length; + } + + const centralStart = offset; + const centralBuf = Buffer.concat(centralParts); + + const end = Buffer.alloc(22); + end.writeUInt32LE(END_OF_CENTRAL_DIR_SIG, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralBuf.length, 12); + end.writeUInt32LE(centralStart, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...localParts, centralBuf, end]); +} + +// 从尾部往前找 EOCD:注释字段长度可变,不能假设它贴在文件末尾固定偏移。 +function findEndOfCentralDir(buf) { + const minLen = 22; + if (buf.length < minLen) throw new Error('不是有效的 ZIP 文件'); + const maxComment = Math.min(buf.length - minLen, 0xffff); + for (let i = 0; i <= maxComment; i++) { + const pos = buf.length - minLen - i; + if (buf.readUInt32LE(pos) === END_OF_CENTRAL_DIR_SIG) return pos; + } + throw new Error('不是有效的 ZIP 文件:找不到目录结尾标记'); +} + +// 读取端兼容 STORE 与 DEFLATE,返回 Map。 +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 };