From 1a1288ce18ffb34eba183d477f0a901d8d4f6db9 Mon Sep 17 00:00:00 2001 From: lofyer Date: Sat, 25 Jul 2026 14:51:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20PeopleLib=20=E5=BC=80=E6=94=BE=E6=96=87?= =?UTF-8?q?=E7=8C=AE=E5=AE=A2=E6=88=B7=E7=AB=AF=EF=BC=8C=E9=9B=86=E6=88=90?= =?UTF-8?q?=20Z-Library=20=E4=B8=8E=20LibGen=20=E7=AD=89=E5=A4=9A=E6=BA=90?= =?UTF-8?q?=E6=A3=80=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron 桌面客户端,聚合多个开放获取文献源的搜索、详情与下载。 新增数据源: - Z-Library:邮箱登录(凭据本地存储),会话失效自动重登 - LibGen:适配新版 libgen.ac 前端(旧版 search.php 镜像已全部下线) - Memory of the World、Sci-Hub、Anna's Archive 基础设施: - mirror.js:镜像故障转移,支持串行优先与并发竞速两种策略, 失效镜像 5 分钟冷却后自动重试,避免站点恢复后被永久跳过 - http.js:统一 15 秒请求超时,防止单个卡死镜像拖垮整次搜索 - settings.js:全局代理配置持久化,经 Electron net.fetch 生效于所有请求 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .gitignore | 11 + .npmrc | 6 + build-portable.js | 54 ++ main.js | 175 +++++++ package-lock.json | 884 +++++++++++++++++++++++++++++++++ package.json | 36 ++ preload.js | 39 ++ src/library/store.js | 130 +++++ src/settings.js | 45 ++ src/sources/annas.js | 151 ++++++ src/sources/arxiv.js | 105 ++++ src/sources/biorxiv.js | 84 ++++ src/sources/doaj.js | 84 ++++ src/sources/gutenberg.js | 90 ++++ src/sources/http.js | 148 ++++++ src/sources/index.js | 27 + src/sources/libgen.js | 345 +++++++++++++ src/sources/mirror.js | 114 +++++ src/sources/motw.js | 143 ++++++ src/sources/openlibrary.js | 84 ++++ src/sources/pmc.js | 73 +++ src/sources/scihub.js | 160 ++++++ src/sources/semanticscholar.js | 80 +++ src/sources/standardebooks.js | 91 ++++ src/sources/zlib-auth.js | 84 ++++ src/sources/zlib.js | 278 +++++++++++ src/ui/app.js | 102 ++++ src/ui/index.html | 145 ++++++ src/ui/style.css | 273 ++++++++++ src/ui/util.js | 61 +++ src/ui/views/browse.js | 313 ++++++++++++ src/ui/views/library.js | 114 +++++ test-search.js | 111 +++++ 33 files changed, 4640 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 build-portable.js create mode 100644 main.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 preload.js create mode 100644 src/library/store.js create mode 100644 src/settings.js create mode 100644 src/sources/annas.js create mode 100644 src/sources/arxiv.js create mode 100644 src/sources/biorxiv.js create mode 100644 src/sources/doaj.js create mode 100644 src/sources/gutenberg.js create mode 100644 src/sources/http.js create mode 100644 src/sources/index.js create mode 100644 src/sources/libgen.js create mode 100644 src/sources/mirror.js create mode 100644 src/sources/motw.js create mode 100644 src/sources/openlibrary.js create mode 100644 src/sources/pmc.js create mode 100644 src/sources/scihub.js create mode 100644 src/sources/semanticscholar.js create mode 100644 src/sources/standardebooks.js create mode 100644 src/sources/zlib-auth.js create mode 100644 src/sources/zlib.js create mode 100644 src/ui/app.js create mode 100644 src/ui/index.html create mode 100644 src/ui/style.css create mode 100644 src/ui/util.js create mode 100644 src/ui/views/browse.js create mode 100644 src/ui/views/library.js create mode 100644 test-search.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a194bb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +*.log +.DS_Store +Thumbs.db + +# 调试探测产生的临时快照 +probe*.json +probe-*.js +*.tmp.html +scihub.html diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..087435f --- /dev/null +++ b/.npmrc @@ -0,0 +1,6 @@ +registry=https://registry.npmmirror.com +proxy= +https-proxy= +noproxy=* +ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ +electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ diff --git a/build-portable.js b/build-portable.js new file mode 100644 index 0000000..078cf07 --- /dev/null +++ b/build-portable.js @@ -0,0 +1,54 @@ +const fs = require('fs'); +const path = require('path'); + +const ROOT = __dirname; +const OUT = path.join(ROOT, 'dist', 'PeopleLib-win32-x64'); +const APP = path.join(OUT, 'resources', 'app'); +const pkg = require('./package.json'); +const PRODUCT = 'PeopleLib'; + +function rimraf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); } +function copyDir(src, dst, skip) { + fs.mkdirSync(dst, { recursive: true }); + for (const e of fs.readdirSync(src, { withFileTypes: true })) { + if (skip && skip(e)) continue; + const s = path.join(src, e.name); + const d = path.join(dst, e.name); + if (e.isDirectory()) copyDir(s, d, skip); + else fs.copyFileSync(s, d); + } +} + +function skipDevFiles(e) { + const name = e.name.toLowerCase(); + if (e.isDirectory()) return false; + if (/\.(d\.ts|d\.ts\.map|ts|tsx|map|flow)$/.test(name)) return true; + if (/^(readme|changelog|history|license|licence|notice|authors|contributing|security)/.test(name)) return true; + if (/\.(md|markdown)$/.test(name)) return true; + return false; +} + +console.log('清理输出目录...'); +rimraf(OUT); + +console.log('复制 Electron 运行时...'); +copyDir(path.join(ROOT, 'node_modules', 'electron', 'dist'), OUT); + +console.log('重命名可执行文件...'); +fs.renameSync(path.join(OUT, 'electron.exe'), path.join(OUT, PRODUCT + '.exe')); +rimraf(path.join(OUT, 'resources', 'default_app.asar')); + +console.log('组装 app 源码...'); +fs.mkdirSync(APP, { recursive: true }); +fs.copyFileSync(path.join(ROOT, 'main.js'), path.join(APP, 'main.js')); +fs.copyFileSync(path.join(ROOT, 'preload.js'), path.join(APP, 'preload.js')); +copyDir(path.join(ROOT, 'src'), path.join(APP, 'src'), (e) => e.name.startsWith('_test')); + +fs.writeFileSync(path.join(APP, 'package.json'), JSON.stringify({ + name: pkg.name, version: pkg.version, description: pkg.description, + main: 'main.js', author: pkg.author, license: pkg.license +}, null, 2)); + +console.log('\n构建完成:'); +console.log(' 目录:', OUT); +console.log(' 可执行文件:', path.join(OUT, PRODUCT + '.exe')); diff --git a/main.js b/main.js new file mode 100644 index 0000000..56e2a3d --- /dev/null +++ b/main.js @@ -0,0 +1,175 @@ +const { app, BrowserWindow, ipcMain, clipboard, dialog, shell, session } = require('electron'); +const path = require('path'); +const fs = require('fs'); + +const DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'; + +function filenameFromResponse(res, fallback) { + const cd = res.headers.get('content-disposition') || ''; + let m = cd.match(/filename\*=(?:UTF-8'')?([^;]+)/i) || cd.match(/filename="?([^";]+)"?/i); + if (m) { try { return decodeURIComponent(m[1].trim()); } catch (e) { return m[1].trim(); } } + try { + const u = new URL(res.url); + const base = path.basename(u.pathname); + if (base && /\.[a-z0-9]{2,5}$/i.test(base)) return decodeURIComponent(base); + } catch (e) { /* ignore */ } + return fallback || 'download.bin'; +} + +app.setName('PeopleLib'); +// 全局忽略证书错误:访问 Z-Library / LibGen 等使用泛域名证书或经代理的站点时必需 +app.commandLine.appendSwitch('ignore-certificate-errors'); + +const userDataDir = app.isPackaged + ? path.join(path.dirname(app.getPath('exe')), 'data') + : path.join(app.getPath('appData'), 'PeopleLib'); +app.setPath('userData', userDataDir); + +const sources = require('./src/sources'); +const library = require('./src/library/store'); +const zlibAuth = require('./src/sources/zlib-auth'); +const settings = require('./src/settings'); +const { setProxy, getProxy, fetchWithProxy } = require('./src/sources/http'); +zlibAuth.init(userDataDir); +settings.init(userDataDir); +// 启动时从持久化设置恢复代理 +setProxy(settings.get('proxy', '')); + +let mainWindow; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1240, + height: 840, + minWidth: 940, + minHeight: 620, + frame: false, + backgroundColor: '#141414', + title: 'PeopleLib 文献库', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false + } + }); + mainWindow.loadFile(path.join(__dirname, 'src', 'ui', 'index.html')); +} + +library.setChangeListener(() => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('library:changed'); + } +}); + +app.whenReady().then(() => { + // 应用代理到 Chromium defaultSession(影响 net.fetch、窗口加载、所有请求) + const p = getProxy(); + if (p) { + session.defaultSession.setProxy({ proxyRules: p }).catch(() => {}); + } + // 允许证书错误的请求继续(net.fetch / 渲染进程 fetch 都会触发) + app.on('certificate-error', (event, webContents, url, error, certificate, callback) => { + event.preventDefault(); + callback(true); + }); + createWindow(); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); +}); + +function wrap(promise) { + return promise + .then((data) => ({ ok: true, data })) + .catch((err) => ({ ok: false, error: err.message || String(err) })); +} + +// 数据源 +ipcMain.handle('sources:list', () => ({ ok: true, data: sources.listSources() })); +ipcMain.handle('source:list', (_e, sourceId, page) => wrap(sources.getSource(sourceId).list(page))); +ipcMain.handle('source:search', (_e, sourceId, keyword, page) => wrap(sources.getSource(sourceId).search(keyword, page))); +ipcMain.handle('source:detail', (_e, sourceId, postId) => wrap(sources.getSource(sourceId).detail(postId))); +ipcMain.handle('source:download', (_e, sourceId, postId) => wrap(sources.getSource(sourceId).download(postId))); + +// 代理配置:全局生效,影响所有数据源的 HTTP 请求与文件下载 +ipcMain.handle('proxy:get', () => ({ ok: true, data: getProxy() })); +ipcMain.handle('proxy:set', (_e, url) => { + const u = String(url || '').trim(); + setProxy(u); + settings.set('proxy', u); + session.defaultSession.setProxy({ proxyRules: u || 'direct://' }).catch(() => {}); + return { ok: true }; +}); + +// Z-Library 凭据 +ipcMain.handle('zlib:hasCreds', () => ({ ok: true, data: zlibAuth.hasCreds() })); +ipcMain.handle('zlib:login', (_e, email, password) => wrap(sources.getSource('zlib').login(email, password))); +ipcMain.handle('zlib:logout', () => wrap(sources.getSource('zlib').logout())); + +// 本地书库 +ipcMain.handle('library:list', () => wrap(Promise.resolve(library.list()))); +ipcMain.handle('library:get', (_e, id) => wrap(Promise.resolve(library.get(id)))); +ipcMain.handle('library:findBySource', (_e, sourceId, postId) => wrap(Promise.resolve(library.findBySource(sourceId, postId)))); +ipcMain.handle('library:add', (_e, item) => wrap(Promise.resolve(library.add(item)))); +ipcMain.handle('library:update', (_e, id, patch) => wrap(Promise.resolve(library.update(id, patch)))); +ipcMain.handle('library:remove', (_e, id, deleteFiles) => wrap(Promise.resolve(library.remove(id, deleteFiles)))); + +// 下载文件:直接保存到书库下载目录或弹保存框,支持自定义 headers +ipcMain.handle('download:file', async (_e, url, suggestName, entryId, extraHeaders) => { + try { + const headers = { 'User-Agent': DL_UA, ...(extraHeaders || {}) }; + try { headers['Referer'] = new URL(url).origin + '/'; } catch (e) { /* ignore */ } + const res = await fetchWithProxy(String(url || ''), { + redirect: 'follow', + headers + }); + if (!res.ok) throw new Error(`下载失败: ${res.status}`); + const respName = filenameFromResponse(res, suggestName); + const hasExt = suggestName && /\.[a-z0-9]{2,5}$/i.test(suggestName); + const defaultName = (hasExt ? suggestName : respName).replace(/[\\/:*?"<>|]/g, '_'); + const save = await dialog.showSaveDialog(mainWindow, { title: '保存文件', defaultPath: defaultName }); + if (save.canceled || !save.filePath) return { ok: true, data: { canceled: true } }; + const buf = Buffer.from(await res.arrayBuffer()); + fs.writeFileSync(save.filePath, buf); + if (entryId) library.attachFile(entryId, save.filePath); + shell.showItemInFolder(save.filePath); + return { ok: true, data: { path: save.filePath, name: path.basename(save.filePath) } }; + } catch (e) { + return { ok: false, error: e.message || String(e) }; + } +}); + +ipcMain.handle('shell:openPath', async (_e, p) => { + const err = await shell.openPath(p || ''); + return err ? { ok: false, error: err } : { ok: true }; +}); +ipcMain.handle('shell:showItem', (_e, p) => { shell.showItemInFolder(p || ''); return { ok: true }; }); +ipcMain.handle('shell:openExternal', async (_e, url) => { + try { await shell.openExternal(String(url || '')); return { ok: true }; } + catch (e) { return { ok: false, error: e.message || String(e) }; } +}); + +ipcMain.handle('dialog:pickFile', async () => { + const r = await dialog.showOpenDialog(mainWindow, { + title: '选择本地文献文件', + properties: ['openFile'], + filters: [{ name: '文献', extensions: ['pdf', 'epub', 'mobi', 'txt', 'azw3'] }, { name: '所有文件', extensions: ['*'] }] + }); + if (r.canceled || !r.filePaths.length) return { ok: true, data: null }; + const p = r.filePaths[0]; + return { ok: true, data: { path: p, name: path.basename(p, path.extname(p)) } }; +}); + +ipcMain.handle('app:version', () => ({ ok: true, data: app.getVersion() })); +ipcMain.handle('copy', (_e, text) => { clipboard.writeText(String(text || '')); return { ok: true }; }); + +ipcMain.on('win:minimize', () => mainWindow && mainWindow.minimize()); +ipcMain.on('win:maximize', () => { + if (!mainWindow) return; + if (mainWindow.isMaximized()) mainWindow.unmaximize(); else mainWindow.maximize(); +}); +ipcMain.on('win:close', () => mainWindow && mainWindow.close()); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e943363 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,884 @@ +{ + "name": "peoplelib", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "peoplelib", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "undici": "^8.9.0" + }, + "devDependencies": { + "electron": "^31.0.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmmirror.com/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmmirror.com/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/electron": { + "version": "31.7.7", + "resolved": "https://registry.npmmirror.com/electron/-/electron-31.7.7.tgz", + "integrity": "sha512-HZtZg8EHsDGnswFt0QeV8If8B+et63uD6RJ7I4/xhcXqmTIbI08GoubX/wm+HdY0DwcuPe1/xsgqpmYvjdjRoA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmmirror.com/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmmirror.com/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a2338a4 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "peoplelib", + "version": "1.0.0", + "description": "开放获取文献与图书客户端(arXiv / Gutenberg / Open Library / DOAJ / PMC / bioRxiv / Standard Ebooks / Semantic Scholar / LibGen / Z-Library)", + "main": "main.js", + "author": "peoplelib", + "license": "MIT", + "scripts": { + "start": "electron .", + "portable": "node build-portable.js" + }, + "dependencies": { + "undici": "^8.9.0" + }, + "devDependencies": { + "electron": "^31.0.0" + }, + "build": { + "appId": "com.peoplelib.client", + "productName": "PeopleLib", + "directories": { + "output": "dist" + }, + "files": [ + "main.js", + "preload.js", + "src/**/*" + ], + "win": { + "target": "portable" + }, + "portable": { + "artifactName": "PeopleLib-${version}.exe" + } + } +} diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..41b2306 --- /dev/null +++ b/preload.js @@ -0,0 +1,39 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +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) + }, + library: { + list: () => ipcRenderer.invoke('library:list'), + get: (id) => ipcRenderer.invoke('library:get', id), + findBySource: (sourceId, postId) => ipcRenderer.invoke('library:findBySource', sourceId, postId), + add: (item) => ipcRenderer.invoke('library:add', item), + update: (id, patch) => ipcRenderer.invoke('library:update', id, patch), + remove: (id, deleteFiles) => ipcRenderer.invoke('library:remove', id, deleteFiles), + onChanged: (cb) => ipcRenderer.on('library:changed', () => cb()) + }, + downloadFile: (url, suggestName, entryId, extraHeaders) => ipcRenderer.invoke('download:file', url, suggestName, entryId, extraHeaders), + zlib: { + hasCreds: () => ipcRenderer.invoke('zlib:hasCreds'), + login: (email, password) => ipcRenderer.invoke('zlib:login', email, password), + logout: () => ipcRenderer.invoke('zlib:logout') + }, + proxy: { + get: () => ipcRenderer.invoke('proxy:get'), + set: (url) => ipcRenderer.invoke('proxy:set', url) + }, + pickFile: () => ipcRenderer.invoke('dialog:pickFile'), + openPath: (p) => ipcRenderer.invoke('shell:openPath', p), + showItem: (p) => ipcRenderer.invoke('shell:showItem', p), + openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url), + copy: (text) => ipcRenderer.invoke('copy', text), + getVersion: () => ipcRenderer.invoke('app:version'), + minimize: () => ipcRenderer.send('win:minimize'), + maximize: () => ipcRenderer.send('win:maximize'), + close: () => ipcRenderer.send('win:close') +}); diff --git a/src/library/store.js b/src/library/store.js new file mode 100644 index 0000000..7f8c154 --- /dev/null +++ b/src/library/store.js @@ -0,0 +1,130 @@ +const { app } = require('electron'); +const fs = require('fs'); +const path = require('path'); +const { fetchWithProxy } = require('../sources/http'); + +const DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'; + +const FILE = () => path.join(app.getPath('userData'), 'library.json'); +const COVER_DIR = () => path.join(app.getPath('userData'), 'covers'); + +let items = null; +let changeListener = null; + +function isRemoteCover(c) { return typeof c === 'string' && /^https?:\/\//i.test(c); } +function coverExt(url) { + const m = String(url).split('?')[0].match(/\.(png|jpe?g|webp|gif|bmp)$/i); + return m ? m[0].toLowerCase() : '.img'; +} + +async function cacheCover(id, url) { + try { + const res = await fetchWithProxy(url, { headers: { 'User-Agent': DL_UA, 'Referer': new URL(url).origin } }); + if (!res.ok) return ''; + const buf = Buffer.from(await res.arrayBuffer()); + if (!buf.length) return ''; + fs.mkdirSync(COVER_DIR(), { recursive: true }); + const dest = path.join(COVER_DIR(), id + coverExt(url)); + fs.writeFileSync(dest, buf); + return dest; + } catch (e) { return ''; } +} + +async function ensureCoverCached(id) { + const it = get(id); + if (!it || !isRemoteCover(it.cover)) return; + const local = await cacheCover(id, it.cover); + if (local && get(id)) { update(id, { cover: local }); notifyChange(); } +} + +function removeCoverFile(id) { + try { + const dir = COVER_DIR(); + if (!fs.existsSync(dir)) return; + for (const f of fs.readdirSync(dir)) { + if (f === id || f.startsWith(id + '.')) { try { fs.unlinkSync(path.join(dir, f)); } catch (e) { /* ignore */ } } + } + } catch (e) { /* ignore */ } +} + +function setChangeListener(fn) { changeListener = typeof fn === 'function' ? fn : null; } +function notifyChange() { if (changeListener) { try { changeListener(); } catch (e) { /* ignore */ } } } + +function load() { + if (items) return items; + try { + items = JSON.parse(fs.readFileSync(FILE(), 'utf-8')); + if (!Array.isArray(items)) items = []; + } catch (e) { items = []; } + return items; +} + +function persist() { + fs.mkdirSync(path.dirname(FILE()), { recursive: true }); + fs.writeFileSync(FILE(), JSON.stringify(items, null, 2), 'utf-8'); +} + +function genId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); } + +function list() { + return load().slice().sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0)); +} + +function get(id) { return load().find((x) => x.id === id) || null; } + +function findBySource(sourceId, sourcePostId) { + return load().find((x) => x.sourceId === sourceId && String(x.sourcePostId) === String(sourcePostId)) || null; +} + +function add(item) { + load(); + const it = { + id: genId(), + title: item.title || '未命名', + authors: item.authors || [], + cover: item.cover || '', + date: item.date || '', + brief: item.brief || '', + url: item.url || '', + sourceId: item.sourceId || null, + sourcePostId: item.sourcePostId != null ? String(item.sourcePostId) : null, + files: item.files || [], // [{ path, name, format }] + addedAt: Date.now() + }; + items.push(it); + persist(); + if (isRemoteCover(it.cover)) ensureCoverCached(it.id); + return it; +} + +function update(id, patch) { + load(); + const it = items.find((x) => x.id === id); + if (!it) throw new Error('条目不存在'); + Object.assign(it, patch); + persist(); + return it; +} + +function attachFile(id, filePath) { + const it = get(id); + if (!it) return; + const files = (it.files || []).filter((f) => f.path !== filePath); + files.push({ path: filePath, name: path.basename(filePath), format: (path.extname(filePath) || '').slice(1).toUpperCase() }); + update(id, { files }); + notifyChange(); +} + +function remove(id, deleteFiles) { + load(); + const it = get(id); + if (deleteFiles && it && it.files) { + for (const f of it.files) { try { if (f.path && fs.existsSync(f.path)) fs.unlinkSync(f.path); } catch (e) { /* ignore */ } } + } + items = items.filter((x) => x.id !== id); + persist(); + removeCoverFile(id); + return { removed: true }; +} + +module.exports = { list, get, findBySource, add, update, remove, attachFile, setChangeListener }; diff --git a/src/settings.js b/src/settings.js new file mode 100644 index 0000000..2fd46c7 --- /dev/null +++ b/src/settings.js @@ -0,0 +1,45 @@ +// 应用设置持久化(代理等) + +const fs = require('fs'); +const path = require('path'); + +let filePath = null; +let cache = null; + +function init(userDataDir) { + filePath = path.join(userDataDir, 'settings.json'); +} + +function getFilePath() { + if (filePath) return filePath; + const home = process.env.APPDATA || process.env.HOME || process.cwd(); + return path.join(home, 'PeopleLib', 'settings.json'); +} + +function load() { + if (cache) return cache; + try { + cache = JSON.parse(fs.readFileSync(getFilePath(), 'utf8')) || {}; + } catch (e) { cache = {}; } + return cache; +} + +function save() { + try { + fs.mkdirSync(path.dirname(getFilePath()), { recursive: true }); + fs.writeFileSync(getFilePath(), JSON.stringify(cache, null, 2), 'utf8'); + } catch (e) { /* ignore */ } +} + +function get(key, def) { + const c = load(); + return c[key] !== undefined ? c[key] : def; +} + +function set(key, value) { + load(); + cache[key] = value; + save(); +} + +module.exports = { init, get, set }; diff --git a/src/sources/annas.js b/src/sources/annas.js new file mode 100644 index 0000000..819e609 --- /dev/null +++ b/src/sources/annas.js @@ -0,0 +1,151 @@ +// Anna's Archive 数据源:实时在线搜索 +// 通过 annas-archive 镜像站的 HTML 搜索页抓取结果 + +const { fetchText, clampPage, decodeEntities } = require('./http'); +const { tryMirrors } = require('./mirror'); + +const MIRRORS = [ + 'https://annas-archive.org', + 'https://annas-archive.se', + 'https://annas-archive.gs', + 'https://annas-archive.li' +]; + +const PAGE_SIZE = 50; + +function absUrl(base, href) { + if (!href) return ''; + if (/^https?:\/\//.test(href)) return href; + if (href.startsWith('//')) return 'https:' + href; + if (href.startsWith('/')) return base + href; + return base + '/' + href; +} + +function parseSearchHtml(html, base) { + const items = []; + // Anna's Archive 搜索结果在
或 中 + // 尝试匹配包含 md5 链接的卡片 + const md5Re = /href="\/md5\/([a-f0-9]{32})"[^>]*>([\s\S]*?)<\/a>/g; + let m; + while ((m = md5Re.exec(html))) { + const md5 = m[1]; + const inner = m[2]; + const title = decodeEntities(inner.replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim(); + if (title) { + items.push({ + postId: md5, + title, + cover: '', + date: '', + url: `${base}/md5/${md5}`, + subtitle: '' + }); + } + } + // 如果没找到 md5 链接,尝试从 record 区块提取 + if (!items.length) { + const recordRe = /]+class="[^"]*record[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/g; + let r; + while ((r = recordRe.exec(html))) { + const block = r[1]; + const linkM = block.match(/href="([^"]*md5[^"]*)"/); + const titleM = block.match(/]*>([\s\S]*?)<\/h3>/) || block.match(/]+class="[^"]*title[^"]*"[^>]*>([\s\S]*?)<\/div>/); + const title = titleM ? decodeEntities(titleM[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim() : ''; + if (title && linkM) { + const md5M = linkM[1].match(/md5\/([a-f0-9]{32})/i); + const md5 = md5M ? md5M[1] : ''; + if (md5) { + items.push({ + postId: md5, + title, + cover: '', + date: '', + url: absUrl(base, linkM[1]), + subtitle: '' + }); + } + } + } + } + return items; +} + +function parseMaxPage(html) { + // Anna's Archive 分页信息在 "Page 1 of X" 或类似结构中 + const m = html.match(/of\s+(\d+)\s+results/i) || html.match(/(\d+)\s+results/i); + if (m) return Math.max(1, Math.ceil(parseInt(m[1], 10) / PAGE_SIZE)); + const pages = []; + const re = /page=(\d+)/g; + let mm; + while ((mm = re.exec(html))) pages.push(parseInt(mm[1], 10)); + if (pages.length) return Math.max(...pages); + return 1; +} + +async function searchMirror(base, keyword, page) { + const q = encodeURIComponent(keyword); + const url = `${base}/search?q=${q}&page=${page}`; + const html = await fetchText(url); + return { html, base }; +} + +module.exports = { + id: 'annas', + name: "Anna's Archive", + supportsSearch: true, + + async list(page) { + return { items: [], maxPage: 1, page: 1 }; + }, + + async search(keyword, page) { + page = clampPage(page); + const r = await tryMirrors('annas', MIRRORS, (m) => searchMirror(m, keyword, page)); + const items = parseSearchHtml(r.html, r.base); + const maxPage = parseMaxPage(r.html); + return { items, maxPage, page }; + }, + + async detail(postId) { + const r = await tryMirrors('annas', MIRRORS, async (m) => { + const url = `${m}/md5/${postId}`; + const html = await fetchText(url); + return { html, base: m, url }; + }); + const html = r.html; + // 从详情页解析元数据 + let title = '', authors = [], year = '', cover = '', brief = ''; + const titleM = html.match(/]*>([\s\S]*?)<\/h1>/) || html.match(/([^<]+)<\/title>/i); + if (titleM) title = decodeEntities(titleM[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim(); + const authorM = html.match(/author[^>]*>([^<]+)</gi); + if (authorM) authors = authorM.map((a) => decodeEntities(a.replace(/<[^>]*>/g, '').trim())).filter(Boolean); + const yearM = html.match(/(?:year|published)[^\d]*(\d{4})/i); + if (yearM) year = yearM[1]; + const descM = html.match(/description[^>]*>([\s\S]{10,800}?)<\/(?:div|td|p)>/i); + if (descM) brief = decodeEntities(descM[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim(); + const coverM = html.match(/(?:cover|image)[^>]*src="([^"]+)"/i); + if (coverM) cover = absUrl(r.base, coverM[1]); + const tags = []; + const extM = html.match(/extension[^>]*>([^<]+)</i); + if (extM) tags.push(`格式:${decodeEntities(extM[1]).trim()}`); + const sizeM = html.match(/size[^>]*>([^<]+)</i); + if (sizeM) tags.push(`大小:${decodeEntities(sizeM[1]).trim()}`); + return { + postId, + title: title || `MD5 ${postId.slice(0, 8)}`, + cover, + authors, + date: year, + tags, + brief, + url: r.url, + links: [{ name: "Anna's Archive 页", url: r.url }] + }; + }, + + async download(postId) { + // Anna's Archive 下载需要到详情页点击,这里返回各镜像链接 + const links = MIRRORS.map((m) => ({ name: `下载 (${m.replace('https://', '')})`, url: `${m}/md5/${postId}` })); + return { files: [], links }; + } +}; diff --git a/src/sources/arxiv.js b/src/sources/arxiv.js new file mode 100644 index 0000000..6ac72b5 --- /dev/null +++ b/src/sources/arxiv.js @@ -0,0 +1,105 @@ +const { fetchText, decodeEntities, clampPage } = require('./http'); + +const BASE = 'https://export.arxiv.org/api/query'; +const PAGE_SIZE = 20; + +function parseFeed(xml) { + const totalM = xml.match(/<opensearch:totalResults[^>]*>(\d+)<\/opensearch:totalResults>/); + const total = totalM ? parseInt(totalM[1], 10) : 0; + const entries = []; + const re = /<entry>([\s\S]*?)<\/entry>/g; + let m; + while ((m = re.exec(xml))) { + const e = m[1]; + const id = decodeEntities((e.match(/<id>([\s\S]*?)<\/id>/) || [])[1] || '').trim(); + const title = decodeEntities((e.match(/<title>([\s\S]*?)<\/title>/) || [])[1] || '').replace(/\s+/g, ' ').trim(); + const summary = decodeEntities((e.match(/<summary>([\s\S]*?)<\/summary>/) || [])[1] || '').replace(/\s+/g, ' ').trim(); + const published = ((e.match(/<published>([\s\S]*?)<\/published>/) || [])[1] || '').slice(0, 10); + const authors = []; + const are = /<author>\s*<name>([\s\S]*?)<\/name>\s*<\/author>/g; + let a; + while ((a = are.exec(e))) authors.push(decodeEntities(a[1]).trim()); + let pdf = ''; + const lre = /<link[^>]*>/g; + let l; + while ((l = lre.exec(e))) { + if (/title="pdf"/.test(l[0])) { + const hm = l[0].match(/href="([^"]+)"/); + if (hm) pdf = hm[1]; + } + } + const catM = e.match(/<category[^>]*term="([^"]+)"/); + entries.push({ + arxivId: id.replace(/^https?:\/\/arxiv\.org\/abs\//, ''), + title, summary, published, authors, pdf, category: catM ? catM[1] : '', url: id + }); + } + return { total, entries }; +} + +function toItem(e) { + return { + postId: e.arxivId, + title: e.title, + cover: '', + date: e.published, + url: e.url, + subtitle: e.authors.slice(0, 3).join(', ') + (e.authors.length > 3 ? ' 等' : '') + }; +} + +async function query(params) { + const xml = await fetchText(`${BASE}?${params}`); + return parseFeed(xml); +} + +module.exports = { + id: 'arxiv', + name: 'arXiv 论文', + supportsSearch: true, + + async list(page) { + page = clampPage(page); + const start = (page - 1) * PAGE_SIZE; + const { total, entries } = await query( + `search_query=cat:cs.*&start=${start}&max_results=${PAGE_SIZE}&sortBy=submittedDate&sortOrder=descending` + ); + return { items: entries.map(toItem), maxPage: Math.max(1, Math.ceil(total / PAGE_SIZE)), page }; + }, + + async search(keyword, page) { + page = clampPage(page); + const start = (page - 1) * PAGE_SIZE; + const q = `all:"${String(keyword).replace(/"/g, '')}"`; + const { total, entries } = await query( + `search_query=${encodeURIComponent(q)}&start=${start}&max_results=${PAGE_SIZE}&sortBy=relevance&sortOrder=descending` + ); + return { items: entries.map(toItem), maxPage: Math.max(1, Math.ceil(total / PAGE_SIZE)), page }; + }, + + async detail(postId) { + const { entries } = await query(`id_list=${encodeURIComponent(postId)}&max_results=1`); + const e = entries[0]; + if (!e) throw new Error('未找到该论文'); + return { + postId: e.arxivId, + title: e.title, + cover: '', + authors: e.authors, + date: e.published, + tags: e.category ? [`分类:${e.category}`] : [], + brief: e.summary, + url: e.url, + links: [{ name: '摘要页', url: e.url }] + }; + }, + + async download(postId) { + const { entries } = await query(`id_list=${encodeURIComponent(postId)}&max_results=1`); + const e = entries[0]; + if (!e) throw new Error('未找到该论文'); + const files = []; + if (e.pdf) files.push({ name: `${e.arxivId.replace(/\//g, '_')}.pdf`, link: e.pdf, format: 'PDF' }); + return { files, links: [{ name: '摘要页', url: e.url }] }; + } +}; diff --git a/src/sources/biorxiv.js b/src/sources/biorxiv.js new file mode 100644 index 0000000..0c838e1 --- /dev/null +++ b/src/sources/biorxiv.js @@ -0,0 +1,84 @@ +const { fetchJson, clampPage } = require('./http'); + +const PAGE_SIZE = 30; + +function toItem(r, server) { + const authors = String(r.authors || '').split(';').map((s) => s.trim()).filter(Boolean).slice(0, 3).join(', '); + return { + postId: r.doi, + title: r.title || '(无标题)', + cover: '', + date: r.date || '', + url: `https://www.${server}.org/content/${r.doi}v${r.version || 1}`, + subtitle: [authors, r.category].filter(Boolean).join(' · '), + _server: server + }; +} + +function dateStr(d) { return d.toISOString().slice(0, 10); } + +async function fetchWindow(server, from, to, cursor, tries = 3) { + let last; + for (let i = 0; i < tries; i++) { + try { + const j = await fetchJson(`https://api.biorxiv.org/details/${server}/${from}/${to}/${cursor}`); + const total = parseInt(j.messages && j.messages[0] && j.messages[0].total, 10) || 0; + return { total, collection: j.collection || [] }; + } catch (e) { + last = e; + if (!/504|502|503/.test(e.message)) throw e; + await new Promise((r) => setTimeout(r, 1500 * (i + 1))); + } + } + throw last; +} + +module.exports = { + id: 'biorxiv', + name: 'bioRxiv 预印本', + supportsSearch: false, + + async list(page) { + page = clampPage(page); + const server = 'biorxiv'; + const end = new Date(); + const daysPerPage = 3; + const startIdx = (page - 1) * daysPerPage; + const from = new Date(end.getTime() - (startIdx + daysPerPage) * 864e5); + const to = new Date(end.getTime() - startIdx * 864e5); + const { collection } = await fetchWindow(server, dateStr(from), dateStr(to), 0); + const items = collection.slice(0, PAGE_SIZE).map((r) => toItem(r, server)); + return { items, maxPage: 1000, page }; + }, + + async search() { + throw new Error('bioRxiv 暂不支持搜索,请翻页浏览'); + }, + + async detail(postId) { + const j = await fetchJson(`https://api.biorxiv.org/details/biorxiv/${postId}`); + const c = (j.collection || [])[0]; + if (!c) throw new Error('未找到该预印本'); + return { + postId, + title: c.title, + cover: '', + authors: String(c.authors || '').split(';').map((s) => s.trim()).filter(Boolean), + date: c.date || '', + tags: [c.category ? `分类:${c.category}` : '', c.license ? `许可:${c.license}` : ''].filter(Boolean), + brief: c.abstract || '', + url: `https://www.biorxiv.org/content/${c.doi}v${c.version || 1}`, + links: [{ name: 'bioRxiv 页', url: `https://www.biorxiv.org/content/${c.doi}v${c.version || 1}` }] + }; + }, + + async download(postId) { + const j = await fetchJson(`https://api.biorxiv.org/details/biorxiv/${postId}`); + const c = (j.collection || [])[0]; + const v = (c && c.version) || 1; + return { + files: [{ name: `${String(postId).replace(/\//g, '_')}.pdf`, link: `https://www.biorxiv.org/content/${postId}v${v}.full.pdf`, format: 'PDF' }], + links: [{ name: 'bioRxiv 页', url: `https://www.biorxiv.org/content/${postId}v${v}` }] + }; + } +}; diff --git a/src/sources/doaj.js b/src/sources/doaj.js new file mode 100644 index 0000000..42b1fa7 --- /dev/null +++ b/src/sources/doaj.js @@ -0,0 +1,84 @@ +const { fetchJson, clampPage, stripTags } = require('./http'); + +const BASE = 'https://doaj.org/api/v2/search/articles'; +const PAGE_SIZE = 20; + +function idOf(bibjson) { + const ids = bibjson.identifier || []; + const doi = ids.find((x) => x.type === 'doi'); + return doi ? doi.id : (ids[0] && ids[0].id) || ''; +} + +function fulltextOf(bibjson) { + const links = bibjson.link || []; + const pdf = links.find((l) => /pdf/i.test(l.content_type || '')); + const any = links.find((l) => l.url); + return (pdf && pdf.url) || (any && any.url) || ''; +} + +function toItem(r) { + const b = r.bibjson || {}; + const authors = (b.author || []).map((a) => a.name).slice(0, 3).join(', '); + const journal = (b.journal && b.journal.title) || ''; + return { + postId: encodeURIComponent(r.id || idOf(b)), + title: b.title || '(无标题)', + cover: '', + date: b.year || '', + url: fulltextOf(b), + subtitle: [authors, journal].filter(Boolean).join(' · ') + }; +} + +async function run(path) { + const j = await fetchJson(path); + const total = j.total || 0; + return { j, total, maxPage: Math.max(1, Math.ceil(total / PAGE_SIZE)) }; +} + +module.exports = { + id: 'doaj', + name: 'DOAJ 开放期刊', + supportsSearch: true, + + async list(page) { + page = clampPage(page); + const { j, maxPage } = await run(`${BASE}/*?page=${page}&pageSize=${PAGE_SIZE}`); + return { items: (j.results || []).map(toItem), maxPage, page }; + }, + + async search(keyword, page) { + page = clampPage(page); + const { j, maxPage } = await run(`${BASE}/${encodeURIComponent(keyword)}?page=${page}&pageSize=${PAGE_SIZE}`); + return { items: (j.results || []).map(toItem), maxPage, page }; + }, + + async detail(postId) { + const j = await fetchJson(`https://doaj.org/api/v2/articles/${encodeURIComponent(postId)}`); + const b = j.bibjson || {}; + return { + postId, + title: b.title || '(无标题)', + cover: '', + authors: (b.author || []).map((a) => a.name), + date: b.year || '', + tags: [ + b.journal && b.journal.title ? `期刊:${b.journal.title}` : '', + ...(b.keywords || []).slice(0, 5).map((k) => `关键词:${k}`) + ].filter(Boolean), + brief: stripTags(b.abstract || ''), + url: fulltextOf(b), + links: [{ name: '全文页', url: fulltextOf(b) }] + }; + }, + + async download(postId) { + const j = await fetchJson(`https://doaj.org/api/v2/articles/${encodeURIComponent(postId)}`); + const b = j.bibjson || {}; + const files = []; + for (const l of b.link || []) { + if (l.url) files.push({ name: /pdf/i.test(l.content_type || '') ? 'PDF 全文' : (l.content_type || '全文'), link: l.url, format: l.content_type || '' }); + } + return { files, links: [{ name: 'DOAJ 页', url: `https://doaj.org/article/${postId}` }] }; + } +}; diff --git a/src/sources/gutenberg.js b/src/sources/gutenberg.js new file mode 100644 index 0000000..3df28f2 --- /dev/null +++ b/src/sources/gutenberg.js @@ -0,0 +1,90 @@ +const { fetchJson, clampPage } = require('./http'); + +const BASE = 'https://gutendex.com/books'; + +function coverOf(formats) { + if (!formats) return ''; + return formats['image/jpeg'] || formats['image/png'] || ''; +} + +const EXT = { EPUB: 'epub', Kindle: 'mobi', TXT: 'txt', HTML: 'html', PDF: 'pdf' }; + +function bookFiles(formats) { + const out = []; + if (!formats) return out; + const map = [ + ['application/epub+zip', 'EPUB'], + ['application/x-mobipocket-ebook', 'Kindle'], + ['text/plain; charset=utf-8', 'TXT'], + ['text/plain', 'TXT'], + ['application/pdf', 'PDF'] + ]; + const seen = new Set(); + for (const [key, label] of map) { + const url = formats[key]; + if (url && !seen.has(label)) { + seen.add(label); + out.push({ name: label, link: url, format: label }); + } + } + return out; +} + +function toItem(b) { + const authors = (b.authors || []).map((a) => a.name).join(', '); + return { + postId: String(b.id), + title: b.title, + cover: coverOf(b.formats), + date: '', + url: `https://www.gutenberg.org/ebooks/${b.id}`, + subtitle: authors + }; +} + +module.exports = { + id: 'gutenberg', + name: 'Gutenberg 公版书', + supportsSearch: true, + + async list(page) { + page = clampPage(page); + const j = await fetchJson(`${BASE}?page=${page}`); + const total = j.count || 0; + const maxPage = Math.max(1, Math.ceil(total / 32)); + return { items: (j.results || []).map(toItem), maxPage, page }; + }, + + async search(keyword, page) { + page = clampPage(page); + const j = await fetchJson(`${BASE}?search=${encodeURIComponent(keyword)}&page=${page}`); + const total = j.count || 0; + const maxPage = Math.max(1, Math.ceil(total / 32)); + return { items: (j.results || []).map(toItem), maxPage, page }; + }, + + async detail(postId) { + const b = await fetchJson(`${BASE}/${encodeURIComponent(postId)}`); + return { + postId: String(b.id), + title: b.title, + cover: coverOf(b.formats), + authors: (b.authors || []).map((a) => a.name), + date: '', + tags: (b.subjects || []).slice(0, 6).map((s) => `主题:${s}`), + brief: (b.summaries || [])[0] || '', + url: `https://www.gutenberg.org/ebooks/${b.id}`, + links: [{ name: 'Gutenberg 页', url: `https://www.gutenberg.org/ebooks/${b.id}` }] + }; + }, + + async download(postId) { + const b = await fetchJson(`${BASE}/${encodeURIComponent(postId)}`); + const files = bookFiles(b.formats).map((f) => ({ + name: `${String(b.title).replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.${EXT[f.format] || 'bin'}`, + link: f.link, + format: f.format + })); + return { files, links: [{ name: 'Gutenberg 页', url: `https://www.gutenberg.org/ebooks/${b.id}` }] }; + } +}; diff --git a/src/sources/http.js b/src/sources/http.js new file mode 100644 index 0000000..8f69b21 --- /dev/null +++ b/src/sources/http.js @@ -0,0 +1,148 @@ +const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'; + +// 代理配置:默认直连(空字符串)。一旦设置,所有 HTTP 请求统一走该代理。 +// 持久化存储在 userData/settings.json 的 "proxy" 字段。 +let proxyUrl = ''; +let dispatcher = null; + +function setProxy(url) { + proxyUrl = String(url || '').trim(); + dispatcher = null; + if (!proxyUrl) return; + // Electron 下由 session.setProxy 统一接管代理,不需要 undici dispatcher。 + // 仅在纯 Node 环境(脚本/测试)才构建 ProxyAgent。 + if (process.versions.electron) return; + try { + const { ProxyAgent } = require('undici'); + dispatcher = new ProxyAgent({ + uri: proxyUrl, + requestTls: { rejectUnauthorized: false } + }); + } catch (e) { + console.warn('代理初始化失败:', e.message); + } +} + +function getProxy() { return proxyUrl; } + +function fetchWithProxy(url, options = {}) { + // 在 Electron 主进程中优先使用 net.fetch(走 Chromium 网络栈,由 session.setProxy 控制代理) + if (process.versions.electron) { + try { + const { net } = require('electron'); + return net.fetch(url, options); + } catch (e) { /* fallback */ } + } + if (dispatcher) return fetch(url, { ...options, dispatcher }); + return fetch(url, options); +} + +// 简易 cookie jar: Map<domain, Map<name, value>> +const cookieJar = new Map(); + +function domainOf(url) { + try { return new URL(url).hostname; } catch (e) { return ''; } +} + +function getCookies(url) { + const d = domainOf(url); + const m = cookieJar.get(d); + if (!m) return ''; + return Array.from(m.entries()).map(([k, v]) => `${k}=${v}`).join('; '); +} + +function setCookies(url, setCookieHeaders) { + if (!setCookieHeaders || !setCookieHeaders.length) return; + const d = domainOf(url); + if (!d) return; + let m = cookieJar.get(d); + if (!m) { m = new Map(); cookieJar.set(d, m); } + for (const sc of setCookieHeaders) { + const pair = String(sc).split(';')[0]; + const eq = pair.indexOf('='); + if (eq > 0) m.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); + } +} + +function clearCookies(urlPrefix) { + if (!urlPrefix) { cookieJar.clear(); return; } + for (const k of cookieJar.keys()) { + if (k.includes(urlPrefix)) cookieJar.delete(k); + } +} + +// 默认请求超时(毫秒)。没有超时时,单个卡死的镜像会拖死整次搜索。 +const DEFAULT_TIMEOUT = 15000; + +async function fetchRaw(url, options = {}) { + const cookie = getCookies(url); + const headers = { + 'User-Agent': UA, + 'Accept': 'application/json, application/atom+xml, application/xml, text/xml, text/html, */*', + ...(options.headers || {}) + }; + if (cookie && !headers.Cookie) headers.Cookie = cookie; + + const { timeout, ...rest } = options; + const ms = timeout === undefined ? DEFAULT_TIMEOUT : timeout; + + let signal = rest.signal; + let timer = null; + if (!signal && ms > 0) { + const ac = new AbortController(); + signal = ac.signal; + timer = setTimeout(() => ac.abort(), ms); + } + + try { + const res = await fetchWithProxy(url, { redirect: 'follow', ...rest, headers, signal }); + const setCookie = res.headers.getSetCookie ? res.headers.getSetCookie() : []; + setCookies(url, setCookie); + return res; + } catch (e) { + if (e && (e.name === 'AbortError' || /abort/i.test(e.message || ''))) { + throw new Error(`请求超时: ${url}`); + } + throw e; + } finally { + if (timer) clearTimeout(timer); + } +} + +async function fetchText(url, options = {}) { + const res = await fetchRaw(url, options); + if (!res.ok) throw new Error(`请求失败: ${res.status} ${url}`); + return res.text(); +} + +async function fetchJson(url, options = {}) { + const text = await fetchText(url, options); + try { + return JSON.parse(text); + } catch (e) { + throw new Error(`JSON 解析失败: ${url}`); + } +} + +function decodeEntities(s) { + if (s == null) return ''; + return String(s) + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10))) + .replace(/&/g, '&'); +} + +function stripTags(s) { + return decodeEntities(String(s == null ? '' : s).replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim(); +} + +function clampPage(n, min) { + n = parseInt(n, 10); + if (!Number.isFinite(n) || n < (min || 1)) return min || 1; + return n; +} + +module.exports = { UA, fetchRaw, fetchText, fetchJson, decodeEntities, stripTags, clampPage, getCookies, setCookies, clearCookies, setProxy, getProxy, fetchWithProxy }; diff --git a/src/sources/index.js b/src/sources/index.js new file mode 100644 index 0000000..c974e36 --- /dev/null +++ b/src/sources/index.js @@ -0,0 +1,27 @@ +const arxiv = require('./arxiv'); +const gutenberg = require('./gutenberg'); +const openlibrary = require('./openlibrary'); +const doaj = require('./doaj'); +const pmc = require('./pmc'); +const biorxiv = require('./biorxiv'); +const standardebooks = require('./standardebooks'); +const semanticscholar = require('./semanticscholar'); +const libgen = require('./libgen'); +const zlib = require('./zlib'); +const scihub = require('./scihub'); +const motw = require('./motw'); + +const sources = [arxiv, gutenberg, openlibrary, doaj, pmc, biorxiv, standardebooks, semanticscholar, libgen, zlib, scihub, motw]; +const byId = new Map(sources.map((s) => [s.id, s])); + +function listSources() { + return sources.map((s) => ({ id: s.id, name: s.name, supportsSearch: s.supportsSearch !== false })); +} + +function getSource(id) { + const s = byId.get(id); + if (!s) throw new Error(`未知数据源: ${id}`); + return s; +} + +module.exports = { listSources, getSource }; diff --git a/src/sources/libgen.js b/src/sources/libgen.js new file mode 100644 index 0000000..ce00c37 --- /dev/null +++ b/src/sources/libgen.js @@ -0,0 +1,345 @@ +// LibGen 数据源 +// +// 现状(实测): +// - 经典镜像 libgen.is/.rs/.st 域名已失效;.li/.vg/.bz/.la/.gl 全部返回 503 +// - libgen.ac(别名 libgen.mx)在线可用,是重写过的新版前端(Z-Library 引擎), +// 搜索路由为 /s/<关键词>?page=N,结果为 schema.org 标注的 resItemBox 卡片, +// 条目链接形如 /book/<id>;下载需要该站自身账号,因此仅提供跳转链接。 +// +// 策略:优先用新版站点搜索(当前唯一可用);经典镜像作为兜底, +// 一旦恢复即可自动参与(raceMirrors 有 5 分钟冷却重试机制)。 + +const { fetchText, decodeEntities, clampPage } = require('./http'); +const { raceMirrors } = require('./mirror'); + +// 新版站点(当前可用) +const WEB_MIRRORS = [ + 'https://libgen.ac', + 'https://libgen.mx' +]; + +// 经典镜像(当前 503,恢复后自动启用) +const LEGACY_MIRRORS = [ + 'https://libgen.li', + 'https://libgen.vg', + 'https://libgen.bz', + 'https://libgen.la', + 'https://libgen.gl' +]; + +// 已知 md5 时可用的下载入口 +const DOWNLOAD_MIRRORS = [ + 'https://library.lol', + 'https://libgen.li' +]; + +// 未登录时该站每页只返回 10 条 +const PER_PAGE = 10; +const TIMEOUT = 12000; + +function absUrl(base, href) { + if (!href) return ''; + if (/^https?:\/\//.test(href)) return href; + if (href.startsWith('//')) return 'https:' + href; + if (href.startsWith('/')) return base + href; + return base + '/' + href; +} + +function stripTags(s) { + return decodeEntities(String(s || '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim(); +} + +// 取页面内嵌的 schema.org JSON-LD(Book 类型) +function parseJsonLd(html) { + const re = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi; + let m; + while ((m = re.exec(html))) { + try { + const j = JSON.parse(m[1].trim()); + const node = Array.isArray(j) ? j.find((x) => x && x['@type'] === 'Book') : j; + if (node && node['@type'] === 'Book') return node; + } catch (e) { + // 忽略格式不合法的块 + } + } + return {}; +} + +// 从 resItemBox 卡片中取某个 bookProperty 的值 +function propOf(block, label) { + const re = new RegExp( + `<div class="property_label"[^>]*>\\s*${label}\\s*:?\\s*</div>\\s*<div class="property_value[^"]*"[^>]*>([\\s\\S]*?)</div>`, + 'i' + ); + const m = block.match(re); + return m ? stripTags(m[1]) : ''; +} + +// 解析新版站点搜索结果 +function parseWebResults(html, base) { + const items = []; + const seen = new Set(); + // 每个结果卡片以 resItemBox 开始,data-book_id 是稳定标识 + const blocks = html.split(/<div class="resItemBox/).slice(1); + + for (const raw of blocks) { + const block = '<div class="resItemBox' + raw; + const idM = block.match(/data-book_id="(\d+)"/); + if (!idM) continue; + const id = idM[1]; + // 同一卡片内 data-book_id 会出现多次,按 id 去重 + if (seen.has(id)) continue; + seen.add(id); + + // 标题:<h3 itemprop="name"><a ...>标题</a> + let title = ''; + const tM = block.match(/<h3[^>]*itemprop="name"[^>]*>\s*<a[^>]*>([\s\S]*?)<\/a>/i); + if (tM) title = stripTags(tM[1]); + if (!title) { + const alt = block.match(/<img[^>]*alt="([^"]+)"/i); + if (alt) title = decodeEntities(alt[1]).trim(); + } + if (!title) continue; + + // 作者:.authors 区块内的 itemprop="author" + const authors = []; + const authBlock = block.match(/<div class="authors">([\s\S]*?)<\/div>/i); + if (authBlock) { + const aRe = /<a[^>]*itemprop="author"[^>]*>([\s\S]*?)<\/a>/gi; + let a; + while ((a = aRe.exec(authBlock[1]))) { + const name = stripTags(a[1]); + if (name && !authors.includes(name)) authors.push(name); + } + } + + // 封面:懒加载在 data-src + let cover = ''; + const cM = block.match(/<img[^>]*class="[^"]*cover[^"]*"[^>]*data-src="([^"]+)"/i) + || block.match(/<img[^>]*data-src="([^"]+)"/i); + if (cM) cover = absUrl(base, cM[1]); + + const year = propOf(block, 'Year'); + const ext = propOf(block, 'File'); + const language = propOf(block, 'Language'); + const publisher = (block.match(/itemprop="publisher"[\s\S]{0,200}?<span itemprop="name">([\s\S]*?)<\/span>/i) || [])[1]; + + items.push({ + postId: `web:${id}`, + title, + cover, + date: year, + url: `${base}/book/${id}`, + subtitle: authors.join(', '), + ext, + language, + publisher: publisher ? stripTags(publisher) : '' + }); + } + return items; +} + +// 结果总数:<span class="totalCounter">(123)</span> +// 注意未登录时常显示 "(5+)" 这类模糊值,不可用于精确推算总页数。 +function parseWebTotal(html) { + const m = html.match(/class="totalCounter"[^>]*>\s*\(?\s*([\d,]+)\s*\+?\s*\)?/i); + if (!m) return 0; + return parseInt(m[1].replace(/,/g, ''), 10) || 0; +} + +// 从分页控件里取最大页码;没有分页控件说明只有一页。 +function parseWebMaxPage(html, page, count) { + let max = 0; + const re = /[?&]page=(\d+)/g; + let m; + while ((m = re.exec(html))) { + const n = parseInt(m[1], 10); + if (n > max) max = n; + } + // 本页没有结果说明已经翻过头,回退到上一页 + if (!count) return Math.max(1, page - 1); + if (max > page) return max; + return Math.max(page, max); +} + +async function webSearch(keyword, page) { + const kw = encodeURIComponent(keyword); + return raceMirrors('libgen-web', WEB_MIRRORS, async (base) => { + const url = `${base}/s/${kw}${page > 1 ? `?page=${page}` : ''}`; + const html = await fetchText(url, { timeout: TIMEOUT }); + const items = parseWebResults(html, base); + if (!items.length && !/searchResultBox|resItemBox|Nothing found/i.test(html)) { + throw new Error('页面结构无法识别'); + } + return { items, html, base }; + }); +} + +function buildDownloadLinks(md5) { + if (!md5) return []; + return DOWNLOAD_MIRRORS.map((dm) => ({ + name: `下载 (${dm.replace(/^https?:\/\//, '')})`, + url: `${dm}/${dm.includes('library.lol') ? 'book/index.php?md5=' : 'ads.php?md5='}${md5}` + })); +} + +async function fetchBookPage(id) { + return raceMirrors('libgen-web', WEB_MIRRORS, async (base) => { + const html = await fetchText(`${base}/book/${id}`, { timeout: TIMEOUT }); + return { html, base }; + }); +} + +module.exports = { + id: 'libgen', + name: 'Library Genesis', + supportsSearch: true, + + async list(page) { + page = clampPage(page); + // 新版站点有 /popular 榜单 + try { + const r = await raceMirrors('libgen-web', WEB_MIRRORS, async (base) => { + const html = await fetchText(`${base}/popular`, { timeout: TIMEOUT }); + return { items: parseWebResults(html, base), base }; + }); + return { items: r.items, maxPage: 1, page: 1 }; + } catch (e) { + return { items: [], maxPage: 1, page: 1 }; + } + }, + + async search(keyword, page) { + page = clampPage(page); + const q = String(keyword || '').trim(); + if (!q) return { items: [], maxPage: 1, page }; + + let r; + try { + r = await webSearch(q, page); + } catch (e) { + throw new Error(`LibGen 暂不可用:${e.message}`); + } + + // 未登录时该站只返回第一页(约 10 条),第二页为空, + // 因此不虚报页数:只有当页面本身给出更多分页链接时才认为可翻页。 + const maxPage = parseWebMaxPage(r.html, page, r.items.length); + + return { items: r.items, maxPage, page }; + }, + + async detail(postId) { + const s = String(postId); + const webM = s.match(/^web:(\d+)$/); + if (!webM) throw new Error('无效的 LibGen ID'); + const id = webM[1]; + + const { html, base } = await fetchBookPage(id); + + // 页面内嵌 schema.org JSON-LD,是最可靠的元数据来源 + const ld = parseJsonLd(html); + + let title = ''; + const tM = html.match(/<h1[^>]*itemprop="name"[^>]*>([\s\S]*?)<\/h1>/i) + || html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i); + if (tM) title = stripTags(tM[1]); + if (!title && ld.name) title = decodeEntities(ld.name); + if (!title) { + const mt = html.match(/<meta name="title" content="([^"]+)"/i); + if (mt) title = decodeEntities(mt[1]).replace(/\s*\|\s*Libgen\s*$/i, '').trim(); + } + + const authors = []; + if (Array.isArray(ld.author)) { + for (const p of ld.author) { + const n = decodeEntities(String((p && p.name) || '')).trim(); + if (n && !authors.includes(n)) authors.push(n); + } + } + if (!authors.length) { + const aRe = /<a[^>]*itemprop="author"[^>]*>([\s\S]*?)<\/a>/gi; + let a; + while ((a = aRe.exec(html))) { + const n = stripTags(a[1]); + if (n && !authors.includes(n)) authors.push(n); + } + } + + let cover = ld.image ? absUrl(base, ld.image) : ''; + if (!cover) { + const cM = html.match(/<img[^>]*itemprop="image"[^>]*(?:data-src|src)="([^"]+)"/i) + || html.match(/<img[^>]*(?:data-src|src)="([^"]+)"[^>]*alt="[^"]*cover"/i); + if (cM) cover = absUrl(base, cM[1]); + } + + const tags = []; + for (const label of ['Year', 'Publisher', 'Language', 'File', 'Pages', 'ISBN', 'Series', 'Edition']) { + const v = propOf(html, label); + if (v) tags.push(`${label}:${v}`); + } + if (!tags.length && ld.inLanguage) tags.push(`Language:${ld.inLanguage}`); + + let brief = ''; + const dM = html.match(/<div[^>]*id="bookDescriptionBox"[^>]*>([\s\S]*?)<\/div>/i); + if (dM) brief = stripTags(dM[1]).slice(0, 2000); + if (!brief) { + const md = html.match(/<meta name="description" content="([^"]*)"/i); + const v = md ? decodeEntities(md[1]).trim() : ''; + // 过滤 "Download ... for free from Libgen" 之类的模板文案 + if (v && !/for free from Libgen|free E-Books Library/i.test(v)) brief = v; + } + + // 详情页里的 termsHash 即经典 LibGen 的 md5,可用于兜底下载 + const md5 = (html.match(/"termsHash"\s*:\s*"([a-f0-9]{32})"/i) || [])[1] || ''; + + const links = [{ name: 'LibGen 页面', url: `${base}/book/${id}` }].concat(buildDownloadLinks(md5)); + + return { + postId, + title: title || `LibGen ${id}`, + cover, + authors, + date: propOf(html, 'Year'), + tags, + brief, + url: `${base}/book/${id}`, + links + }; + }, + + async download(postId) { + const s = String(postId); + const webM = s.match(/^web:(\d+)$/); + if (!webM) throw new Error('无效的 LibGen ID'); + const id = webM[1]; + + const { html, base } = await fetchBookPage(id); + + // 站点已登录时会渲染真实下载链接,否则只有 /login 按钮。 + // 只接受站内 /dl/ 路径或直接指向文件扩展名的链接,避免误抓页脚社交链接。 + const files = []; + const dlRe = /<a[^>]*class="[^"]*dlButton[^"]*"[^>]*href="([^"]+)"/gi; + let m; + while ((m = dlRe.exec(html))) { + const href = m[1]; + const isFile = /\/dl\/|\/download\/|\.(pdf|epub|mobi|djvu|azw3|fb2|txt|zip|rar)(\?|#|$)/i.test(href); + if (!isFile) continue; + const link = absUrl(base, href); + const extM = link.match(/\.([a-z0-9]{2,5})(?:\?|#|$)/i); + files.push({ + name: `libgen-${id}${extM ? '.' + extM[1] : ''}`, + link, + format: extM ? extM[1].toUpperCase() : '' + }); + } + + const md5 = (html.match(/"termsHash"\s*:\s*"([a-f0-9]{32})"/i) || [])[1] || ''; + const links = [{ name: 'LibGen 页面', url: `${base}/book/${id}` }].concat(buildDownloadLinks(md5)); + + if (!files.length) { + // 没有直链时不报错,交给用户走外部链接(该站下载需登录) + return { files: [], links }; + } + return { files, links }; + } +}; diff --git a/src/sources/mirror.js b/src/sources/mirror.js new file mode 100644 index 0000000..8e65186 --- /dev/null +++ b/src/sources/mirror.js @@ -0,0 +1,114 @@ +// 通用镜像管理与故障转移 +// 每个 source 提供候选镜像列表,运行时记住"当前可用"镜像; +// 失败的镜像会被临时拉黑(带过期时间),避免站点恢复后永远不再尝试。 + +const BAD_TTL = 5 * 60 * 1000; // 失败镜像的冷却时间 + +const stateCache = new Map(); // prefix -> { current, bad: Map<mirror, ts> } + +function stateOf(prefix) { + let c = stateCache.get(prefix); + if (!c) { + c = { current: null, bad: new Map() }; + stateCache.set(prefix, c); + } + return c; +} + +function isBad(c, mirror) { + const ts = c.bad.get(mirror); + if (!ts) return false; + if (Date.now() - ts > BAD_TTL) { + c.bad.delete(mirror); + return false; + } + return true; +} + +function markBad(prefix, mirror) { + const c = stateOf(prefix); + c.bad.set(mirror, Date.now()); + if (c.current === mirror) c.current = null; +} + +function markGood(prefix, mirror) { + const c = stateOf(prefix); + c.current = mirror; + c.bad.delete(mirror); +} + +function currentFor(prefix, mirrors) { + const c = stateOf(prefix); + if (c.current && mirrors.includes(c.current)) return c.current; + return mirrors[0]; +} + +// 候选顺序:上次成功的优先,其余按原顺序,已拉黑的排到最后兜底 +function candidates(prefix, mirrors) { + const c = stateOf(prefix); + const fresh = []; + const stale = []; + for (const m of mirrors) { + if (m === c.current) continue; + (isBad(c, m) ? stale : fresh).push(m); + } + const out = []; + if (c.current && mirrors.includes(c.current)) out.push(c.current); + out.push(...fresh, ...stale); + return out.length ? out : mirrors.slice(); +} + +/** + * 串行尝试:按优先级逐个调用 fn(mirror),第一个成功即返回。 + * 适用于镜像少、需要严格优先级的场景。 + */ +async function tryMirrors(prefix, mirrors, fn) { + const list = candidates(prefix, mirrors); + let lastErr; + for (const m of list) { + try { + const r = await fn(m); + markGood(prefix, m); + return r; + } catch (e) { + lastErr = e; + markBad(prefix, m); + } + } + throw lastErr || new Error('所有镜像均不可用'); +} + +/** + * 竞速尝试:同时向所有候选镜像发起请求,最先成功的胜出。 + * 适用于镜像多且大量失效的场景(如 LibGen),避免串行等待累加。 + */ +async function raceMirrors(prefix, mirrors, fn) { + const list = candidates(prefix, mirrors); + if (!list.length) throw new Error('没有可用镜像'); + + return new Promise((resolve, reject) => { + let pending = list.length; + let settled = false; + let lastErr; + + for (const m of list) { + Promise.resolve() + .then(() => fn(m)) + .then((r) => { + if (settled) return; + settled = true; + markGood(prefix, m); + resolve(r); + }) + .catch((e) => { + lastErr = e; + markBad(prefix, m); + if (--pending === 0 && !settled) { + reject(lastErr || new Error('所有镜像均不可用')); + } + }); + } + }); +} + +module.exports = { tryMirrors, raceMirrors, currentFor }; diff --git a/src/sources/motw.js b/src/sources/motw.js new file mode 100644 index 0000000..6a452a1 --- /dev/null +++ b/src/sources/motw.js @@ -0,0 +1,143 @@ +// Memory of the World 数据源:Calibre 书目服务,实时联网查询 +// 端点: +// /books?page=N 浏览(分页) +// /search/titles/<kw>?page=N 按标题搜索 +// /search/authors/<kw>?page=N 按作者搜索 +// 站点没有单条详情端点(/books/<id> 会回落到列表),因此详情与下载信息 +// 从列表/搜索结果里缓存的原始记录中取。 + +const { fetchJson, clampPage, decodeEntities } = require('./http'); + +const BASE = 'https://library.memoryoftheworld.org'; +const PAGE_SIZE = 48; + +// postId -> 原始记录,供 detail/download 复用(LRU 上限,避免无限增长) +const recordCache = new Map(); +const CACHE_LIMIT = 2000; + +function remember(b) { + if (!b || !b._id) return; + if (recordCache.has(b._id)) recordCache.delete(b._id); + recordCache.set(b._id, b); + while (recordCache.size > CACHE_LIMIT) { + recordCache.delete(recordCache.keys().next().value); + } +} + +function stripHtml(s) { + return decodeEntities(String(s || '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim(); +} + +function fileUrl(b, rel) { + // library_url 形如 /files/hortense/,rel 为库内相对路径 + const lib = String(b.library_url || '').replace(/\/+$/, ''); + const parts = String(rel || '').split('/').map(encodeURIComponent).join('/'); + return `${BASE}${lib}/${parts}`; +} + +function toItem(b) { + remember(b); + return { + postId: b._id, + title: b.title || '', + cover: b.cover_url ? fileUrl(b, b.cover_url) : '', + date: b.pubdate && b.pubdate.slice(0, 4) !== '0101' ? b.pubdate.slice(0, 4) : '', + url: `${BASE}/#/book/${b._id}`, + subtitle: (b.authors || []).join(', ') + }; +} + +function pack(j, page) { + const items = j._items || []; + const total = (j._meta && j._meta.total) || 0; + const size = (j._meta && j._meta.max_results) || PAGE_SIZE; + return { + items: items.map(toItem), + maxPage: Math.max(1, Math.ceil(total / size)), + page + }; +} + +// 搜索关键词出现在路径段中,需要编码;斜杠会破坏路由,统一替换为空格 +function safeKeyword(kw) { + return encodeURIComponent(String(kw || '').replace(/\//g, ' ').trim()); +} + +function getRecord(postId) { + const b = recordCache.get(postId); + if (!b) throw new Error('详情已过期,请返回列表重新进入'); + return b; +} + +module.exports = { + id: 'motw', + name: 'Memory of the World', + supportsSearch: true, + + async list(page) { + page = clampPage(page); + const j = await fetchJson(`${BASE}/books?page=${page}`); + return pack(j, page); + }, + + async search(keyword, page) { + page = clampPage(page); + const kw = safeKeyword(keyword); + if (!kw) return { items: [], maxPage: 1, page }; + + // 标题与作者两路合并,按 _id 去重 + const [byTitle, byAuthor] = await Promise.all([ + fetchJson(`${BASE}/search/titles/${kw}?page=${page}`).catch(() => null), + fetchJson(`${BASE}/search/authors/${kw}?page=${page}`).catch(() => null) + ]); + if (!byTitle && !byAuthor) throw new Error('搜索请求失败'); + + const seen = new Set(); + const items = []; + for (const j of [byTitle, byAuthor]) { + for (const b of (j && j._items) || []) { + if (!b || !b._id || seen.has(b._id)) continue; + seen.add(b._id); + items.push(toItem(b)); + } + } + + const totals = [byTitle, byAuthor] + .map((j) => (j && j._meta && j._meta.total) || 0); + const total = Math.max(...totals, 0); + return { + items, + maxPage: Math.max(1, Math.ceil(total / PAGE_SIZE)), + page + }; + }, + + async detail(postId) { + const b = getRecord(postId); + const tags = (b.tags || []).map((t) => `标签:${t}`); + if (b.publisher) tags.push(`出版:${b.publisher}`); + if (b.languages && b.languages.length) tags.push(`语言:${b.languages.join(', ')}`); + if (b.librarian) tags.push(`馆藏者:${b.librarian}`); + return { + postId, + title: b.title || '', + cover: b.cover_url ? fileUrl(b, b.cover_url) : '', + authors: b.authors || [], + date: b.pubdate && b.pubdate.slice(0, 4) !== '0101' ? b.pubdate.slice(0, 4) : '', + tags, + brief: stripHtml(b.abstract), + url: `${BASE}/#/book/${b._id}`, + links: [{ name: '详情页', url: `${BASE}/#/book/${b._id}` }] + }; + }, + + async download(postId) { + const b = getRecord(postId); + const files = (b.formats || []).map((f) => ({ + name: f.file_name || `${b.title}.${f.format}`, + link: fileUrl(b, `${f.dir_path || ''}${f.file_name || ''}`), + format: (f.format || '').toUpperCase() + })); + return { files, links: [{ name: '详情页', url: `${BASE}/#/book/${b._id}` }] }; + } +}; diff --git a/src/sources/openlibrary.js b/src/sources/openlibrary.js new file mode 100644 index 0000000..b6f1918 --- /dev/null +++ b/src/sources/openlibrary.js @@ -0,0 +1,84 @@ +const { fetchJson, clampPage } = require('./http'); + +const BASE = 'https://openlibrary.org'; +const PAGE_SIZE = 20; + +async function fetchWithRetry(url, tries = 2) { + let last; + for (let i = 0; i < tries; i++) { + try { return await fetchJson(url); } catch (e) { last = e; await new Promise((r) => setTimeout(r, 1200)); } + } + throw last; +} + +function coverOf(doc) { + return doc.cover_i ? `https://covers.openlibrary.org/b/id/${doc.cover_i}-M.jpg` : ''; +} + +function toItem(d) { + const workKey = String(d.key || '').replace(/^\/works\//, ''); + return { + postId: workKey, + title: d.title || '(无标题)', + cover: coverOf(d), + date: d.first_publish_year ? String(d.first_publish_year) : '', + url: `${BASE}/works/${workKey}`, + subtitle: (d.author_name || []).slice(0, 3).join(', ') + }; +} + +const FIELDS = 'key,title,author_name,first_publish_year,cover_i,ia,ocaid,editions'; + +module.exports = { + id: 'openlibrary', + name: 'Open Library 图书', + supportsSearch: true, + + async list(page) { + page = clampPage(page); + const offset = (page - 1) * PAGE_SIZE; + const j = await fetchWithRetry(`${BASE}/search.json?q=${encodeURIComponent('subject:fiction')}&limit=${PAGE_SIZE}&offset=${offset}&fields=${FIELDS}`); + const maxPage = Math.max(1, Math.ceil((j.numFound || 0) / PAGE_SIZE)); + return { items: (j.docs || []).map(toItem), maxPage, page }; + }, + + async search(keyword, page) { + page = clampPage(page); + const offset = (page - 1) * PAGE_SIZE; + const j = await fetchWithRetry(`${BASE}/search.json?q=${encodeURIComponent(keyword)}&limit=${PAGE_SIZE}&offset=${offset}&fields=${FIELDS}`); + const maxPage = Math.max(1, Math.ceil((j.numFound || 0) / PAGE_SIZE)); + return { items: (j.docs || []).map(toItem), maxPage, page }; + }, + + async detail(postId) { + const j = await fetchWithRetry(`${BASE}/works/${encodeURIComponent(postId)}.json`); + const desc = typeof j.description === 'string' ? j.description : (j.description && j.description.value) || ''; + return { + postId, + title: j.title || '(无标题)', + cover: j.covers && j.covers[0] ? `https://covers.openlibrary.org/b/id/${j.covers[0]}-M.jpg` : '', + authors: [], + date: j.first_publish_date || '', + tags: (j.subjects || []).slice(0, 6).map((s) => `主题:${s}`), + brief: desc, + url: `${BASE}/works/${postId}`, + links: [{ name: 'Open Library 页', url: `${BASE}/works/${postId}` }] + }; + }, + + async download(postId) { + const ed = await fetchWithRetry(`${BASE}/works/${encodeURIComponent(postId)}/editions.json?limit=50`); + const files = []; + const seen = new Set(); + for (const e of (ed.entries || [])) { + const ocaid = e.ocaid || (e.ia && e.ia[0]); + if (!ocaid || seen.has(ocaid)) continue; + if (e.access_restricted === 'borrow') continue; // 借阅制,不直接下载 + seen.add(ocaid); + files.push({ name: `${ocaid}.pdf`, link: `https://archive.org/download/${ocaid}/${ocaid}.pdf`, format: 'PDF' }); + files.push({ name: `${ocaid}.epub`, link: `https://archive.org/download/${ocaid}/${ocaid}.epub`, format: 'EPUB' }); + if (files.length >= 6) break; + } + return { files, links: [{ name: 'Open Library 页', url: `${BASE}/works/${postId}` }] }; + } +}; diff --git a/src/sources/pmc.js b/src/sources/pmc.js new file mode 100644 index 0000000..0f02602 --- /dev/null +++ b/src/sources/pmc.js @@ -0,0 +1,73 @@ +const { fetchJson, clampPage } = require('./http'); + +const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils'; +const PAGE_SIZE = 20; + +function toItem(r) { + const authors = (r.authors || []).map((a) => a.name).slice(0, 3).join(', '); + return { + postId: String(r.uid), + title: r.title || '(无标题)', + cover: '', + date: (r.pubdate || '').slice(0, 4), + url: `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${r.uid}/`, + subtitle: [authors, r.fulljournalname || r.source].filter(Boolean).join(' · ') + }; +} + +async function esearch(term, start) { + const j = await fetchJson(`${EUTILS}/esearch.fcgi?db=pmc&term=${encodeURIComponent(term)}&retmode=json&retstart=${start}&retmax=${PAGE_SIZE}&sort=relevance`); + return { count: parseInt(j.esearchresult.count, 10) || 0, ids: j.esearchresult.idlist || [] }; +} + +async function esummary(ids) { + if (!ids.length) return {}; + const j = await fetchJson(`${EUTILS}/esummary.fcgi?db=pmc&id=${ids.join(',')}&retmode=json`); + return j.result || {}; +} + +async function runList(term, page) { + page = clampPage(page); + const start = (page - 1) * PAGE_SIZE; + const { count, ids } = await esearch(term, start); + const result = await esummary(ids); + const items = ids.map((id) => result[id]).filter(Boolean).map(toItem); + return { items, maxPage: Math.max(1, Math.ceil(count / PAGE_SIZE)), page }; +} + +module.exports = { + id: 'pmc', + name: 'PMC 生物医学', + supportsSearch: true, + + list(page) { return runList('open access[filter]', page); }, + search(keyword, page) { return runList(`${keyword} AND open access[filter]`, page); }, + + async detail(postId) { + const result = await esummary([postId]); + const r = result[postId]; + if (!r) throw new Error('未找到该文献'); + return { + postId: String(postId), + title: r.title || '(无标题)', + cover: '', + authors: (r.authors || []).map((a) => a.name), + date: (r.pubdate || '').slice(0, 10), + tags: [ + r.fulljournalname ? `期刊:${r.fulljournalname}` : '', + r.pubdate ? `发表:${r.pubdate}` : '' + ].filter(Boolean), + brief: '', + url: `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/`, + links: [{ name: 'PMC 全文页', url: `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/` }] + }; + }, + + async download(postId) { + const page = `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/`; + return { + files: [{ name: `PMC${postId}.pdf`, link: `https://www.ncbi.nlm.nih.gov/pmc/articles/PMC${postId}/pdf/`, format: 'PDF' }], + links: [{ name: 'PMC 全文页', url: page }] + }; + } +}; diff --git a/src/sources/scihub.js b/src/sources/scihub.js new file mode 100644 index 0000000..ead7131 --- /dev/null +++ b/src/sources/scihub.js @@ -0,0 +1,160 @@ +// Sci-Hub 数据源:按 DOI 精确获取论文 PDF +// 注意:Sci-Hub 各镜像会不定期启用人机验证(ALTCHA),此时无法通过纯 HTTP 抓取, +// 模块会明确抛错并提示用户在浏览器中打开。 + +const { fetchText, clampPage, decodeEntities } = require('./http'); +const { tryMirrors } = require('./mirror'); + +const MIRRORS = [ + 'https://sci-hub.se', + 'https://sci-hub.st', + 'https://sci-hub.ru' +]; + +const DOI_RE = /^10\.\d{4,9}\/\S+$/; + +function normalizeDoi(input) { + let s = String(input || '').trim(); + s = s.replace(/^doi:\s*/i, ''); + s = s.replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, ''); + return s; +} + +function absUrl(base, href) { + if (!href) return ''; + if (/^https?:\/\//.test(href)) return href; + if (href.startsWith('//')) return 'https:' + href; + if (href.startsWith('/')) return base + href; + return base + '/' + href; +} + +function isChallenge(html) { + return /altcha|你是机器人|are you a robot|captcha/i.test(html); +} + +function extractTitle(html, doi) { + // 优先用引文区块(含完整论文标题) + const cite = html.match(/id\s*=\s*["']citation["'][^>]*>([\s\S]{0,600}?)<\/(?:div|i|p)>/i); + if (cite) { + const t = decodeEntities(cite[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim(); + if (t) return t; + } + const titleM = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i); + if (titleM) { + let t = decodeEntities(titleM[1]).replace(/\s+/g, ' ').trim(); + t = t.replace(/^Sci-Hub\s*[::]\s*/i, '').replace(/\s*[-–|]\s*Sci-Hub.*$/i, '').trim(); + if (t) return t; + } + return doi; +} + +function extractPdf(html, base) { + const patterns = [ + /<iframe[^>]+src\s*=\s*["']([^"']+)["']/i, + /<embed[^>]+src\s*=\s*["']([^"']+)["']/i, + /location\.href\s*=\s*['"]([^'"]+)['"]/i, + /<a[^>]+href\s*=\s*["']([^"']*\.pdf[^"']*)["']/i + ]; + for (const re of patterns) { + const m = html.match(re); + if (m && m[1] && /\.pdf|\/downloads?\//i.test(m[1])) { + return absUrl(base, m[1].replace(/#.*$/, '')); + } + } + return ''; +} + +async function fetchSciHub(base, doi) { + const url = `${base}/${doi}`; + const html = await fetchText(url); + if (isChallenge(html)) { + throw new Error(`${base} 启用了人机验证`); + } + const pdfUrl = extractPdf(html, base); + const title = extractTitle(html, doi); + const notFound = /article not found|не найдена|抱歉/i.test(html); + if (!pdfUrl && notFound) throw new Error('该 DOI 在 Sci-Hub 中不存在'); + return { pdfUrl, title, url, base }; +} + +async function resolve(doi) { + try { + return await tryMirrors('scihub', MIRRORS, (m) => fetchSciHub(m, doi)); + } catch (e) { + if (/人机验证/.test(e.message)) { + throw new Error('Sci-Hub 当前要求人机验证,请在浏览器中打开该 DOI 页面'); + } + throw e; + } +} + +module.exports = { + id: 'scihub', + name: 'Sci-Hub(按 DOI)', + supportsSearch: true, + + async list() { + return { items: [], maxPage: 1, page: 1 }; + }, + + async search(keyword, page) { + page = clampPage(page); + const doi = normalizeDoi(keyword); + if (!doi) return { items: [], maxPage: 1, page: 1 }; + if (!DOI_RE.test(doi)) { + throw new Error('Sci-Hub 仅支持 DOI 查询,例如 10.1038/nature12373'); + } + const r = await resolve(doi); + return { + items: [{ + postId: doi, + title: r.title, + cover: '', + date: '', + url: r.url, + subtitle: doi + }], + maxPage: 1, + page: 1 + }; + }, + + async detail(postId) { + const doi = normalizeDoi(postId); + const r = await resolve(doi); + return { + postId: doi, + title: r.title, + cover: '', + authors: [], + date: '', + tags: [`DOI:${doi}`], + brief: '', + url: r.url, + links: [ + { name: 'Sci-Hub 页', url: r.url }, + { name: 'DOI 原文', url: `https://doi.org/${doi}` } + ] + }; + }, + + async download(postId) { + const doi = normalizeDoi(postId); + const r = await resolve(doi); + const files = []; + if (r.pdfUrl) { + files.push({ + name: `${doi.replace(/[\\/:*?"<>|]/g, '_')}.pdf`, + link: r.pdfUrl, + format: 'PDF' + }); + } + return { + files, + links: [ + { name: 'Sci-Hub 页', url: r.url }, + { name: 'DOI 原文', url: `https://doi.org/${doi}` } + ] + }; + } +}; diff --git a/src/sources/semanticscholar.js b/src/sources/semanticscholar.js new file mode 100644 index 0000000..befc932 --- /dev/null +++ b/src/sources/semanticscholar.js @@ -0,0 +1,80 @@ +const { fetchJson, clampPage } = require('./http'); + +const BASE = 'https://api.semanticscholar.org/graph/v1'; +const PAGE_SIZE = 25; +const FIELDS = 'title,authors,year,abstract,openAccessPdf,externalIds,url,venue'; + +async function fetchWithRetry(url, tries = 3) { + let last; + for (let i = 0; i < tries; i++) { + try { return await fetchJson(url); } catch (e) { + last = e; + if (!/429/.test(e.message)) throw e; + await new Promise((r) => setTimeout(r, 2500 * (i + 1))); + } + } + throw last; +} + +function toItem(p) { + return { + postId: p.paperId || (p.externalIds && (p.externalIds.DOI || p.externalIds.ArXiv)) || p.title, + title: p.title || '(无标题)', + cover: '', + date: p.year ? String(p.year) : '', + url: p.url || '', + subtitle: [(p.authors || []).map((a) => a.name).slice(0, 3).join(', '), p.venue].filter(Boolean).join(' · ') + }; +} + +module.exports = { + id: 'semanticscholar', + name: 'Semantic Scholar', + supportsSearch: true, + + async list(page) { + page = clampPage(page); + const offset = (page - 1) * PAGE_SIZE; + const j = await fetchWithRetry(`${BASE}/paper/search?query=${encodeURIComponent('a')}&limit=${PAGE_SIZE}&offset=${offset}&fields=${FIELDS}`); + const maxPage = Math.max(1, Math.ceil((j.total || 0) / PAGE_SIZE)); + return { items: (j.data || []).map(toItem), maxPage: Math.min(maxPage, 400), page }; + }, + + async search(keyword, page) { + page = clampPage(page); + const offset = (page - 1) * PAGE_SIZE; + const j = await fetchWithRetry(`${BASE}/paper/search?query=${encodeURIComponent(keyword)}&limit=${PAGE_SIZE}&offset=${offset}&fields=${FIELDS}`); + const maxPage = Math.max(1, Math.ceil((j.total || 0) / PAGE_SIZE)); + return { items: (j.data || []).map(toItem), maxPage: Math.min(maxPage, 400), page }; + }, + + async detail(postId) { + const p = await fetchWithRetry(`${BASE}/paper/${encodeURIComponent(postId)}?fields=${FIELDS}`); + return { + postId, + title: p.title || '(无标题)', + cover: '', + authors: (p.authors || []).map((a) => a.name), + date: p.year ? String(p.year) : '', + tags: [ + p.venue ? `来源:${p.venue}` : '', + p.externalIds && p.externalIds.DOI ? `DOI:${p.externalIds.DOI}` : '' + ].filter(Boolean), + brief: p.abstract || '', + url: p.url || '', + links: p.url ? [{ name: 'Semantic Scholar 页', url: p.url }] : [] + }; + }, + + async download(postId) { + const p = await fetchWithRetry(`${BASE}/paper/${encodeURIComponent(postId)}?fields=title,openAccessPdf,url,externalIds`); + const files = []; + if (p.openAccessPdf && p.openAccessPdf.url) { + files.push({ name: `${String(p.title || postId).replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.pdf`, link: p.openAccessPdf.url, format: 'PDF' }); + } + const links = []; + if (p.url) links.push({ name: 'Semantic Scholar 页', url: p.url }); + if (p.externalIds && p.externalIds.DOI) links.push({ name: 'DOI', url: `https://doi.org/${p.externalIds.DOI}` }); + return { files, links }; + } +}; diff --git a/src/sources/standardebooks.js b/src/sources/standardebooks.js new file mode 100644 index 0000000..34168f5 --- /dev/null +++ b/src/sources/standardebooks.js @@ -0,0 +1,91 @@ +const { fetchText, clampPage, decodeEntities } = require('./http'); + +const BASE = 'https://standardebooks.org'; +const PAGE_SIZE = 24; + +async function fetchOpds(path) { + return fetchText(`${BASE}${path}`, { headers: { 'Accept': 'application/atom+xml, text/xml, */*' } }); +} + +function parseEntries(xml) { + const entries = []; + const re = /<entry>([\s\S]*?)<\/entry>/g; + let m; + while ((m = re.exec(xml))) { + const e = m[1]; + const title = decodeEntities((e.match(/<title>([\s\S]*?)<\/title>/) || [])[1] || '').trim(); + const id = decodeEntities((e.match(/<id>([\s\S]*?)<\/id>/) || [])[1] || '').trim(); + const author = decodeEntities((e.match(/<author>[\s\S]*?<name>([\s\S]*?)<\/name>[\s\S]*?<\/author>/) || [])[1] || '').trim(); + const summary = decodeEntities((e.match(/<summary>([\s\S]*?)<\/summary>/) || [])[1] || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + let epub = '', cover = '', pageUrl = ''; + const lre = /<link[^>]*\/>/g; + let l; + while ((l = lre.exec(e))) { + const tag = l[0]; + const href = (tag.match(/href="([^"]+)"/) || [])[1] || ''; + const rel = (tag.match(/rel="([^"]+)"/) || [])[1] || ''; + if (/epub/i.test(tag) && !epub) epub = href; + else if (/image/.test(tag) && !cover) cover = href; + else if (rel === 'alternate' && !pageUrl) pageUrl = href; + } + const slug = id.replace(/^urn:uuid:|^https?:\/\/standardebooks\.org\/ebooks\//, '').replace(/\//g, '_') || title; + entries.push({ slug, title, author, summary, epub, cover, pageUrl }); + } + return entries; +} + +function toItem(e) { + return { + postId: encodeURIComponent(e.slug), + title: e.title, + cover: e.cover ? (e.cover.startsWith('http') ? e.cover : BASE + e.cover) : '', + date: '', + url: e.pageUrl ? (e.pageUrl.startsWith('http') ? e.pageUrl : BASE + e.pageUrl) : '', + subtitle: e.author + }; +} + +module.exports = { + id: 'standardebooks', + name: 'Standard Ebooks', + supportsSearch: true, + + async list(page) { + page = clampPage(page); + const xml = await fetchOpds(`/feeds/opds/all?page=${page}`); + return { items: parseEntries(xml).map(toItem), maxPage: 40, page }; + }, + + async search(keyword, page) { + page = clampPage(page); + const xml = await fetchOpds(`/feeds/opds/all?query=${encodeURIComponent(keyword)}&page=${page}`); + return { items: parseEntries(xml).map(toItem), maxPage: 40, page }; + }, + + async detail(postId) { + const slug = decodeURIComponent(postId); + const xml = await fetchOpds(`/feeds/opds/all?query=${encodeURIComponent(slug.replace(/_/g, ' '))}`); + const e = parseEntries(xml).find((x) => x.slug === slug) || parseEntries(xml)[0]; + if (!e) throw new Error('未找到该图书'); + return { + postId, + title: e.title, + cover: e.cover ? (e.cover.startsWith('http') ? e.cover : BASE + e.cover) : '', + authors: e.author ? [e.author] : [], + date: '', + tags: [], + brief: e.summary, + url: e.pageUrl, + links: e.pageUrl ? [{ name: 'Standard Ebooks 页', url: e.pageUrl }] : [] + }; + }, + + async download(postId) { + const slug = decodeURIComponent(postId); + const xml = await fetchOpds(`/feeds/opds/all?query=${encodeURIComponent(slug.replace(/_/g, ' '))}`); + const e = parseEntries(xml).find((x) => x.slug === slug) || parseEntries(xml)[0]; + if (!e || !e.epub) throw new Error('未找到 EPUB 下载'); + const url = e.epub.startsWith('http') ? e.epub : BASE + e.epub; + return { files: [{ name: `${e.title.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.epub`, link: url, format: 'EPUB' }], links: [] }; + } +}; diff --git a/src/sources/zlib-auth.js b/src/sources/zlib-auth.js new file mode 100644 index 0000000..b049995 --- /dev/null +++ b/src/sources/zlib-auth.js @@ -0,0 +1,84 @@ +// Z-Library 凭据与会话存储 +// 注意:凭据以 base64 简单混淆存储于本地 userData 目录,不是真正的加密。 + +const fs = require('fs'); +const path = require('path'); + +let filePath = null; + +function init(userDataDir) { + filePath = path.join(userDataDir, 'zlib-auth.json'); +} + +function getFilePath() { + if (filePath) return filePath; + // 未初始化时回退到用户目录(便于独立 Node 脚本测试) + const home = process.env.APPDATA || process.env.HOME || process.cwd(); + return path.join(home, 'PeopleLib', 'zlib-auth.json'); +} + +function read() { + const fp = getFilePath(); + try { + const raw = fs.readFileSync(fp, 'utf8'); + const j = JSON.parse(raw); + if (!j) return null; + return { + email: j.email ? Buffer.from(j.email, 'base64').toString('utf8') : '', + password: j.password ? Buffer.from(j.password, 'base64').toString('utf8') : '', + userId: j.userId || '', + userKey: j.userKey || '', + mirror: j.mirror || '' + }; + } catch (e) { return null; } +} + +function write(creds) { + const fp = getFilePath(); + try { fs.mkdirSync(path.dirname(fp), { recursive: true }); } catch (e) { /* ignore */ } + const j = { + email: creds.email ? Buffer.from(creds.email, 'utf8').toString('base64') : '', + password: creds.password ? Buffer.from(creds.password, 'utf8').toString('base64') : '', + userId: creds.userId || '', + userKey: creds.userKey || '', + mirror: creds.mirror || '' + }; + fs.writeFileSync(fp, JSON.stringify(j, null, 2), 'utf8'); +} + +// 清除全部(含凭据)——用于"退出登录" +function clear() { + const fp = getFilePath(); + try { fs.unlinkSync(fp); } catch (e) { /* ignore */ } +} + +// 只清除会话令牌,保留邮箱密码以便自动重新登录 +function clearSession() { + const c = read(); + if (!c) return; + c.userId = ''; + c.userKey = ''; + c.mirror = ''; + write(c); +} + +function hasCreds() { + const c = read(); + return !!(c && c.email && c.password); +} + +function getSession() { + const c = read(); + if (c && c.userId && c.userKey) return { userId: c.userId, userKey: c.userKey, mirror: c.mirror || '' }; + return null; +} + +function setSession(userId, userKey, mirror) { + const c = read() || { email: '', password: '' }; + c.userId = userId; + c.userKey = userKey; + c.mirror = mirror || ''; + write(c); +} + +module.exports = { init, read, write, clear, clearSession, hasCreds, getSession, setSession }; diff --git a/src/sources/zlib.js b/src/sources/zlib.js new file mode 100644 index 0000000..bffbe51 --- /dev/null +++ b/src/sources/zlib.js @@ -0,0 +1,278 @@ +// Z-Library 数据源 +// 关键约定(经实测确认): +// - 登录:POST /eapi/user/login (email, password) -> user.id / user.remix_userkey +// - 搜索:POST /eapi/book/search (message, limit, page, userId, userKey) +// * 必须是 POST;用 GET 会被当成取单本书并返回 "Requested book not found" +// * 分页信息在 pagination.total_items / total_pages +// * 作者字段是 author(单数字符串),不是 authors +// - 详情:GET /eapi/book/{id}/{hash} +// - 下载:GET /eapi/book/{id}/{hash}/file -> file.downloadLink +// 镜像域名变动频繁,登录成功的镜像会被记录并优先复用。 + +const { fetchJson, clampPage, decodeEntities } = require('./http'); +const { tryMirrors } = require('./mirror'); +const auth = require('./zlib-auth'); + +const DEFAULT_MIRRORS = [ + 'https://z-lib.fm', + 'https://z-library.sk', + 'https://z-lib.gs', + 'https://1lib.sk', + 'https://singlelogin.re' +]; + +const PAGE_SIZE = 20; +const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' }; + +function getMirrors() { + const custom = (auth.read() || {}).customMirrors; + if (Array.isArray(custom) && custom.length) { + return custom.concat(DEFAULT_MIRRORS.filter((m) => !custom.includes(m))); + } + return DEFAULT_MIRRORS.slice(); +} + +function apiUrl(base, path, params = {}) { + const u = new URL(base + path); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, v); + } + return u.toString(); +} + +function form(params) { + return Object.entries(params) + .filter(([, v]) => v !== undefined && v !== null && v !== '') + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); +} + +function authRequired(msg) { + const err = new Error(msg); + err.code = 'AUTH_REQUIRED'; + return err; +} + +function errMessage(j) { + if (!j || !j.error) return ''; + return typeof j.error === 'string' ? j.error : (j.error.message || ''); +} + +async function doLogin() { + const creds = auth.read(); + if (!creds || !creds.email || !creds.password) { + throw authRequired('Z-Library 需要登录,请先在设置中配置账号'); + } + const r = await tryMirrors('zlib', getMirrors(), async (m) => { + const j = await fetchJson(apiUrl(m, '/eapi/user/login'), { + method: 'POST', + headers: FORM, + body: form({ email: creds.email, password: creds.password }) + }); + if (!j || !j.success || !j.user) throw new Error(errMessage(j) || '登录失败'); + return { userId: String(j.user.id), userKey: j.user.remix_userkey, mirror: m }; + }); + auth.setSession(r.userId, r.userKey, r.mirror); + return r; +} + +async function ensureLogin() { + return auth.getSession() || doLogin(); +} + +// method: 'GET' | 'POST'。凭据 GET 走 query,POST 走 body。 +async function callOn(mirror, path, params, session, method) { + const cred = { userId: session.userId, userKey: session.userKey }; + let j; + if (method === 'POST') { + j = await fetchJson(apiUrl(mirror, path), { + method: 'POST', + headers: FORM, + body: form({ ...params, ...cred }) + }); + } else { + j = await fetchJson(apiUrl(mirror, path, { ...params, ...cred })); + } + const msg = errMessage(j); + if (msg) { + if (/userkey|unauthor|auth|login|token|expired/i.test(msg)) { + const e = new Error(msg); + e.code = 'AUTH_STALE'; + throw e; + } + throw new Error(msg); + } + if (!j || j.success !== 1) throw new Error('该镜像不支持此接口'); + return j; +} + +async function attempt(session, path, params, method) { + const mirrors = getMirrors(); + const ordered = session.mirror + ? [session.mirror, ...mirrors.filter((m) => m !== session.mirror)] + : mirrors; + + let lastErr; + for (const m of ordered) { + try { + const j = await callOn(m, path, params, session, method); + if (session.mirror !== m) auth.setSession(session.userId, session.userKey, m); + return { ok: true, data: j }; + } catch (e) { + if (e.code === 'AUTH_STALE') return { ok: false, stale: true, error: e }; + lastErr = e; + } + } + return { ok: false, stale: false, error: lastErr }; +} + +async function apiCall(path, params = {}, method = 'GET') { + const session = await ensureLogin(); + let r = await attempt(session, path, params, method); + if (r.ok) return r.data; + + const creds = auth.read(); + if (creds && creds.email && creds.password) { + auth.clearSession(); + const fresh = await doLogin(); + r = await attempt(fresh, path, params, method); + if (r.ok) return r.data; + } + + if (r.stale) { + auth.clearSession(); + throw authRequired('Z-Library 会话已过期,请重新登录'); + } + throw r.error || new Error('Z-Library 所有镜像均不可用'); +} + +function splitAuthors(s) { + return String(s || '') + .split(/[,;]| and /i) + .map((a) => a.trim()) + .filter(Boolean); +} + +function bookUrl(b) { + const mirror = (auth.getSession() || {}).mirror || DEFAULT_MIRRORS[0]; + if (b.href) return b.href.startsWith('http') ? b.href : mirror + b.href; + if (b.url) return b.url.startsWith('http') ? b.url : mirror + b.url; + return `${mirror}/book/${b.id}`; +} + +function toItem(b) { + return { + postId: `${b.id}/${b.hash || ''}`, + title: decodeEntities(b.title || ''), + cover: b.cover || '', + date: b.year ? String(b.year) : '', + url: bookUrl(b), + subtitle: decodeEntities(b.author || '') + }; +} + +function parseId(postId) { + const m = String(postId).match(/^(\d+)\/([A-Za-z0-9]+)$/); + if (!m) throw new Error('无效的 Z-Library ID'); + return { id: m[1], hash: m[2] }; +} + +module.exports = { + id: 'zlib', + name: 'Z-Library', + supportsSearch: true, + + // 无关键词时展示热门书目 + async list(page) { + page = clampPage(page); + const j = await apiCall('/eapi/book/most-popular'); + const books = j.books || []; + return { items: books.map(toItem), maxPage: 1, page: 1 }; + }, + + async search(keyword, page) { + page = clampPage(page); + const j = await apiCall('/eapi/book/search', { + message: keyword, + limit: PAGE_SIZE, + page + }, 'POST'); + + const books = j.books || []; + const pg = j.pagination || {}; + const maxPage = pg.total_pages + ? Math.max(1, pg.total_pages) + : Math.max(1, Math.ceil((j.exactBooksCount || books.length) / PAGE_SIZE)); + + return { items: books.map(toItem), maxPage, page }; + }, + + async detail(postId) { + const { id, hash } = parseId(postId); + const j = await apiCall(`/eapi/book/${id}/${hash}`); + const b = j.book; + if (!b) throw new Error('获取详情失败'); + + const tags = []; + if (b.language) tags.push(`语言:${b.language}`); + if (b.extension) tags.push(`格式:${String(b.extension).toUpperCase()}`); + if (b.filesizeString) tags.push(`大小:${b.filesizeString}`); + else if (b.filesize) tags.push(`大小:${(b.filesize / 1048576).toFixed(1)} MB`); + if (b.publisher) tags.push(`出版:${b.publisher}`); + if (b.pages) tags.push(`页数:${b.pages}`); + if (b.series) tags.push(`丛书:${b.series}`); + + return { + postId, + title: decodeEntities(b.title || ''), + cover: b.cover || '', + authors: splitAuthors(b.author), + date: b.year ? String(b.year) : '', + tags, + brief: decodeEntities(String(b.description || '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim(), + url: bookUrl(b), + links: [{ name: 'Z-Library 页', url: bookUrl(b) }] + }; + }, + + async download(postId) { + const { id, hash } = parseId(postId); + const j = await apiCall(`/eapi/book/${id}/${hash}/file`); + const f = j.file; + if (!f || !f.downloadLink) throw new Error('获取下载链接失败(可能已达每日下载上限)'); + + let name = f.description || f.name || ''; + if (!name) name = `zlib-${id}`; + const ext = (f.extension || '').toLowerCase(); + if (ext && !new RegExp(`\\.${ext}$`, 'i').test(name)) name += `.${ext}`; + + return { + files: [{ + name: name.replace(/[\\/:*?"<>|]/g, '_'), + link: f.downloadLink, + format: (f.extension || '').toUpperCase() + }], + links: [] + }; + }, + + async login(email, password) { + auth.write({ email, password, userId: '', userKey: '', mirror: '' }); + try { + await doLogin(); + return { ok: true }; + } catch (e) { + auth.clear(); + return { ok: false, error: e.message }; + } + }, + + async logout() { + auth.clear(); + return { ok: true }; + }, + + hasCreds() { + return auth.hasCreds(); + } +}; diff --git a/src/ui/app.js b/src/ui/app.js new file mode 100644 index 0000000..64821dc --- /dev/null +++ b/src/ui/app.js @@ -0,0 +1,102 @@ +$('minBtn').onclick = () => window.api.minimize(); +$('maxBtn').onclick = () => window.api.maximize(); +$('closeBtn').onclick = () => window.api.close(); + +let currentTab = 'library'; + +function switchTab(tab) { + currentTab = tab; + document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab)); + $('libraryTab').classList.toggle('hidden', tab !== 'library'); + $('browseTab').classList.toggle('hidden', tab !== 'browse'); + $('settingsTab').classList.toggle('hidden', tab !== 'settings'); + if (tab === 'library') Library.refresh(true); +} + +document.querySelectorAll('.tab').forEach((t) => { + t.onclick = () => switchTab(t.dataset.tab); +}); + +Browse.init(); +Library.init(); + +const sortSelect = $('sortSelect'); +sortSelect.value = Library.getSortMode(); +sortSelect.onchange = () => Library.setSortMode(sortSelect.value); + +async function initSourceManager() { + const listEl = $('sourceList'); + const res = await window.api.sources.list(); + const all = res.ok ? res.data : []; + const enabled = getEnabledSources(); + listEl.innerHTML = all.map((s) => { + const checked = enabled ? enabled.includes(s.id) : true; + return ` + <label class="source-row"> + <input type="checkbox" data-id="${escapeHtml(s.id)}" ${checked ? 'checked' : ''} /> + <span>${escapeHtml(s.name)}</span> + </label>`; + }).join(''); + listEl.querySelectorAll('input[type="checkbox"]').forEach((cb) => { + cb.onchange = () => { + const ids = Array.from(listEl.querySelectorAll('input[type="checkbox"]:checked')) + .map((el) => el.dataset.id); + setEnabledSources(ids); + Browse.reloadSources(); + }; + }); +} +initSourceManager(); + +async function refreshZlibStatus() { + const r = await window.api.zlib.hasCreds(); + const logged = r.ok && r.data; + $('zlibStatus').textContent = logged ? '已配置(凭据保存在本地)' : '未登录'; + $('zlibLoginBtn').textContent = logged ? '重新登录' : '登录'; + $('zlibLogoutBtn').classList.toggle('hidden', !logged); +} + +$('zlibLoginBtn').onclick = async () => { + const r = await openModal('Z-Library 登录', ` + <p style="margin-bottom:8px;">使用 Z-Library 账号登录(保存在本地 userData 目录)</p> + <div style="display:flex;flex-direction:column;gap:8px;"> + <input id="zlibEmail" type="email" placeholder="邮箱" style="padding:8px;background:#222;border:1px solid #444;color:#eee;border-radius:4px;" /> + <input id="zlibPassword" type="password" placeholder="密码" style="padding:8px;background:#222;border:1px solid #444;color:#eee;border-radius:4px;" /> + <div id="zlibErr" style="color:#f66;font-size:12px;min-height:16px;"></div> + </div> + `, async () => { + const email = $('zlibEmail').value.trim(); + const password = $('zlibPassword').value; + if (!email || !password) { $('zlibErr').textContent = '请输入邮箱和密码'; return false; } + $('zlibErr').textContent = '登录中...'; + const res = await window.api.zlib.login(email, password); + if (!res.ok) { $('zlibErr').textContent = res.error || '登录失败'; return false; } + if (res.data && res.data.ok === false) { $('zlibErr').textContent = res.data.error || '登录失败'; return false; } + return true; + }); + if (r) refreshZlibStatus(); +}; + +$('zlibLogoutBtn').onclick = async () => { + const ok = await confirmModal('退出 Z-Library', '确定要清除本地保存的 Z-Library 凭据吗?'); + if (ok) { await window.api.zlib.logout(); refreshZlibStatus(); } +}; + +refreshZlibStatus(); + +async function refreshProxy() { + const r = await window.api.proxy.get(); + if (r.ok) $('proxyInput').value = r.data || ''; +} +$('proxySaveBtn').onclick = async () => { + await window.api.proxy.set($('proxyInput').value.trim()); + $('proxySaveBtn').textContent = '已保存 ✓'; + setTimeout(() => { $('proxySaveBtn').textContent = '保存'; }, 1500); +}; +refreshProxy(); + +window.api.getVersion().then((r) => { + if (r && r.ok) $('appVersion').textContent = 'v' + r.data; +}); + +switchTab('library'); diff --git a/src/ui/index.html b/src/ui/index.html new file mode 100644 index 0000000..12496bf --- /dev/null +++ b/src/ui/index.html @@ -0,0 +1,145 @@ +<!DOCTYPE html> +<html lang="zh-CN"> +<head> + <meta charset="UTF-8" /> + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src https: http: data: file:; style-src 'self' 'unsafe-inline';" /> + <title>PeopleLib 文献库 + + + +
+
+ PeopleLib 开放文献库 +
+ +
+
+ + + + +
+
+ +
+ +
+
+ +
+ +
+
+
+ + + + + + +
+ + + + + + + + + + diff --git a/src/ui/style.css b/src/ui/style.css new file mode 100644 index 0000000..e1e7fd7 --- /dev/null +++ b/src/ui/style.css @@ -0,0 +1,273 @@ +:root { + --bg: #14161a; + --bg-soft: #181b20; + --bg-card: #1c2027; + --line: #2a2f38; + --accent: #6ea8fe; + --accent-bright: #9cc2ff; + --text: #dfe4ec; + --text-dim: #8b94a3; + --green: #3fb96f; + --danger: #d9534f; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: "Microsoft YaHei", "PingFang SC", -apple-system, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); + height: 100vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.hidden { display: none !important; } + +/* 标题栏 */ +.titlebar { + height: 44px; + background: linear-gradient(135deg, #171b26, #12141c); + display: flex; align-items: center; + padding: 0 8px 0 16px; + -webkit-app-region: drag; + border-bottom: 1px solid var(--line); + flex-shrink: 0; +} +.titlebar-left { flex-shrink: 0; margin-right: 24px; } +.brand { font-size: 15px; font-weight: 700; color: var(--accent-bright); letter-spacing: 0.5px; } +.brand-sub { color: var(--text-dim); font-weight: 400; font-size: 12px; } + +.tabs { display: flex; gap: 4px; -webkit-app-region: no-drag; } +.tab { + height: 30px; padding: 0 18px; + background: transparent; border: none; color: var(--text-dim); + font-size: 14px; cursor: pointer; border-radius: 8px; +} +.tab:hover { color: var(--text); background: rgba(255,255,255,0.05); } +.tab.active { color: #0d1420; background: var(--accent); font-weight: 600; } + +.titlebar-spacer { flex: 1; } +.titlebar-controls { display: flex; gap: 2px; -webkit-app-region: no-drag; flex-shrink: 0; } +.win-btn { + display: flex; align-items: center; justify-content: center; + width: 40px; height: 30px; + background: transparent; border: none; border-radius: 6px; + color: var(--text-dim); font-size: 14px; cursor: pointer; +} +.win-btn:hover { background: rgba(255,255,255,0.08); color: var(--text); } +.win-close:hover { background: var(--danger); color: #fff; } + +/* 主区 */ +#main { flex: 1; overflow-y: auto; padding: 20px; } +.tab-panel { min-height: 100%; } + +/* 工具栏 */ +.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } +.toolbar .status-bar { margin: 0; flex: 1; } +.spacer { flex: 1; } + +.tb-btn { + height: 30px; padding: 0 16px; + background: var(--accent); color: #0d1420; border: none; border-radius: 8px; + font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; +} +.tb-btn:hover { background: var(--accent-bright); } +.tb-btn.ghost { background: transparent; color: var(--text-dim); border: 1px solid var(--line); } +.tb-btn.ghost:hover { color: var(--text); border-color: var(--accent); } +.tb-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.tb-btn.in-lib { background: #2a2f38; color: var(--text-dim); } +.tb-btn.sm { height: 24px; padding: 0 10px; font-size: 12px; } +.tb-btn.danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); } +.tb-btn.danger:hover { background: var(--danger); color: #fff; } + +.status-bar { color: var(--text-dim); font-size: 13px; min-height: 18px; } + +/* 浏览页头部常驻 */ +.browse-head { + position: sticky; top: -20px; z-index: 6; + margin: -20px -20px 0; padding: 20px 20px 14px; + background: linear-gradient(to bottom, var(--bg) 62%, transparent); +} +.browse-head .toolbar { margin-bottom: 0; } +.browse-head .status-bar { margin: 0; min-height: 0; } +.browse-head .status-bar:not(:empty) { margin-top: 8px; } + +.source-select { + height: 30px; padding: 0 12px; + background: var(--bg-soft); color: var(--text); + border: 1px solid var(--line); border-radius: 8px; font-size: 13px; cursor: pointer; outline: none; +} +.search-inline { display: flex; align-items: center; gap: 8px; } +#searchInput { + width: 300px; height: 30px; + background: rgba(255,255,255,0.06); + border: 1px solid var(--line); border-radius: 8px; padding: 0 14px; + color: var(--text); font-size: 13px; outline: none; +} +#searchInput:focus { border-color: var(--accent); } + +/* 卡片网格 */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 16px; +} +.card { cursor: pointer; } +.card-cover { + width: 100%; aspect-ratio: 3/4; + background: var(--bg-card) center/cover no-repeat; + border: 1px solid var(--line); border-radius: 10px; + display: flex; align-items: center; justify-content: center; + padding: 10px; text-align: center; + transition: transform 0.15s, border-color 0.15s; +} +.card:hover .card-cover { transform: translateY(-3px); border-color: var(--accent); } +.card-cover .ph { color: var(--text-dim); font-size: 12px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } +.card-title { + margin-top: 8px; font-size: 13px; line-height: 1.4; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} +.card-sub { margin-top: 2px; font-size: 11px; color: var(--text-dim); display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; } +.card-date { margin-top: 2px; font-size: 11px; color: var(--text-dim); } +.card-badge { + display: inline-block; margin-top: 4px; padding: 1px 8px; + font-size: 11px; border-radius: 10px; + background: rgba(63,185,111,0.15); color: var(--green); +} +.card-badge.miss { background: rgba(217,83,79,0.15); color: var(--danger); } + +.empty { grid-column: 1 / -1; text-align: center; color: var(--text-dim); padding: 60px 0; } + +/* 分页 */ +.pager { + position: sticky; bottom: -20px; z-index: 6; + margin: 16px -20px -20px; padding: 12px 20px 16px; + background: linear-gradient(to top, var(--bg) 62%, transparent); + display: flex; align-items: center; justify-content: center; gap: 12px; +} +.page-btn { + height: 28px; padding: 0 14px; + background: var(--bg-soft); color: var(--text); + border: 1px solid var(--line); border-radius: 8px; font-size: 12px; cursor: pointer; +} +.page-btn:hover:not(:disabled) { border-color: var(--accent); } +.page-btn:disabled { opacity: 0.35; cursor: not-allowed; } +.page-info { color: var(--text-dim); font-size: 13px; } +.page-jump { display: flex; align-items: center; gap: 6px; color: var(--text-dim); font-size: 12px; } +.jump-input { + width: 60px; height: 28px; text-align: center; + background: rgba(255,255,255,0.06); border: 1px solid var(--line); border-radius: 8px; + color: var(--text); font-size: 12px; outline: none; +} + +/* 详情 */ +.back-btn { + margin-bottom: 14px; height: 30px; padding: 0 14px; + background: transparent; color: var(--text-dim); + border: 1px solid var(--line); border-radius: 8px; font-size: 13px; cursor: pointer; +} +.back-btn:hover { color: var(--text); border-color: var(--accent); } +.detail-head { display: flex; gap: 20px; margin-bottom: 20px; } +.detail-cover { + width: 150px; height: 200px; flex-shrink: 0; + background: var(--bg-card) center/cover no-repeat; + border: 1px solid var(--line); border-radius: 10px; + display: flex; align-items: center; justify-content: center; padding: 12px; text-align: center; +} +.detail-cover .ph { color: var(--text-dim); font-size: 12px; line-height: 1.5; } +.detail-meta { flex: 1; min-width: 0; } +.detail-title { font-size: 20px; font-weight: 700; line-height: 1.4; margin-bottom: 8px; } +.detail-authors { color: var(--accent-bright); font-size: 13px; margin-bottom: 10px; } +.meta-row { font-size: 13px; color: var(--text-dim); margin-bottom: 6px; } +.meta-row b { color: var(--text); font-weight: 600; margin-right: 8px; } +.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; } +.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; } +.brief-panel { + background: var(--bg-soft); border: 1px solid var(--line); border-radius: 10px; + padding: 14px; font-size: 13px; line-height: 1.7; color: var(--text-dim); + max-height: 220px; overflow-y: auto; +} + +/* 下载区 */ +.download-box { display: flex; flex-direction: column; gap: 10px; } +.dl-loading, .dl-error { color: var(--text-dim); font-size: 13px; padding: 10px 0; } +.dl-error { color: var(--danger); } +.retry-btn { margin-left: 10px; background: none; border: none; color: var(--accent); cursor: pointer; font-size: 13px; } +.dl-files { display: flex; flex-direction: column; gap: 6px; } +.dl-panel-name { font-size: 13px; color: var(--text-dim); margin-bottom: 4px; } +.dl-file-row { + display: flex; align-items: center; gap: 10px; + background: var(--bg-soft); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; +} +.dl-file-name { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dl-fmt { font-size: 11px; color: var(--accent); border: 1px solid var(--accent); border-radius: 6px; padding: 0 6px; flex-shrink: 0; } +.copy-btn, .dl-btn { + height: 24px; padding: 0 10px; flex-shrink: 0; + background: transparent; color: var(--text-dim); + border: 1px solid var(--line); border-radius: 6px; font-size: 12px; cursor: pointer; +} +.copy-btn:hover, .dl-btn:hover { color: var(--text); border-color: var(--accent); } +.dl-btn { background: var(--accent); color: #0d1420; border: none; } +.dl-btn:hover { background: var(--accent-bright); color: #0d1420; } +.dl-btn.copied, .copy-btn.copied { color: var(--green); border-color: var(--green); } +.dl-link-row { display: flex; align-items: center; gap: 10px; font-size: 13px; padding: 4px 0; } +.dl-link-row a { color: var(--accent); text-decoration: none; } +.dl-link-row a:hover { text-decoration: underline; } + +/* 书库 */ +.lib-card-actions { display: flex; gap: 6px; margin-top: 8px; } +.lib-card-actions button { + flex: 1; height: 26px; font-size: 12px; border-radius: 6px; cursor: pointer; + border: 1px solid var(--line); background: transparent; color: var(--text-dim); +} +.lib-card-actions button:hover { color: var(--text); border-color: var(--accent); } +.lib-card-actions .open-btn { background: var(--accent); color: #0d1420; border: none; font-weight: 600; } +.lib-card-actions .open-btn:hover { background: var(--accent-bright); } +.lib-card-actions .open-btn:disabled { background: #2a2f38; color: var(--text-dim); cursor: not-allowed; } + +/* 设置 */ +.settings-page { max-width: 720px; } +.settings-title { font-size: 20px; margin-bottom: 20px; } +.settings-group { + background: var(--bg-soft); border: 1px solid var(--line); border-radius: 12px; + padding: 6px 18px; margin-bottom: 16px; +} +.settings-item { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 0; border-bottom: 1px solid var(--line); } +.settings-item:last-child { border-bottom: none; } +.settings-item-block { flex-direction: column; align-items: stretch; } +.settings-item-label { font-size: 14px; font-weight: 600; } +.settings-item-desc { font-size: 12px; color: var(--text-dim); margin-top: 4px; } +.source-list { display: flex; flex-direction: column; gap: 2px; padding: 10px 0 14px; } +.source-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; cursor: pointer; } +.source-row input { accent-color: var(--accent); } + +/* 弹窗 */ +.modal { + position: fixed; inset: 0; z-index: 50; + background: rgba(0,0,0,0.6); + display: flex; align-items: center; justify-content: center; +} +.modal-box { + width: 420px; max-width: 90vw; + background: var(--bg-card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; +} +.modal-title { font-size: 16px; font-weight: 700; margin-bottom: 12px; } +.modal-body { font-size: 13px; color: var(--text-dim); line-height: 1.6; margin-bottom: 18px; } +.modal-body input[type="text"] { + width: 100%; height: 32px; margin-top: 8px; + background: rgba(255,255,255,0.06); border: 1px solid var(--line); border-radius: 8px; + padding: 0 12px; color: var(--text); font-size: 13px; outline: none; +} +.modal-actions { display: flex; justify-content: flex-end; gap: 10px; } + +/* 滚动条 */ +::-webkit-scrollbar { width: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #2a2f38; border-radius: 6px; } +::-webkit-scrollbar-thumb:hover { background: #3a4150; } diff --git a/src/ui/util.js b/src/ui/util.js new file mode 100644 index 0000000..829e97b --- /dev/null +++ b/src/ui/util.js @@ -0,0 +1,61 @@ +window.$ = (id) => document.getElementById(id); + +window.escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' +}[c])); + +window.coverStyle = (cover) => { + if (!cover) return ''; + const url = /^(https?:|data:)/.test(cover) ? cover : 'file:///' + String(cover).replace(/\\/g, '/'); + return `background-image:url('${url.replace(/'/g, "\\'")}')`; +}; + +window.copyText = async (btn, text) => { + await window.api.copy(text); + const orig = btn.textContent; + btn.textContent = '已复制 ✓'; + btn.classList.add('copied'); + setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); }, 1500); +}; + +window.formatDate = (ts) => { + if (!ts) return ''; + const d = new Date(ts); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +}; + +window.getEnabledSources = () => { + let ids = null; + try { + const raw = localStorage.getItem('enabledSources'); + if (raw) ids = JSON.parse(raw); + } catch (e) { /* ignore */ } + return ids; +}; + +window.setEnabledSources = (ids) => { + localStorage.setItem('enabledSources', JSON.stringify(ids)); +}; + +// 通用弹窗: 返回 Promise<{ok, values}|null> +window.openModal = (title, bodyHtml, onOk) => { + const modal = $('modal'); + $('modalTitle').textContent = title; + $('modalBody').innerHTML = bodyHtml; + modal.classList.remove('hidden'); + return new Promise((resolve) => { + const close = (result) => { + modal.classList.add('hidden'); + $('modalOk').onclick = null; + $('modalCancel').onclick = null; + resolve(result); + }; + $('modalCancel').onclick = () => close(null); + $('modalOk').onclick = async () => { + const r = onOk ? await onOk() : true; + if (r !== false) close(r); + }; + }); +}; + +window.confirmModal = (title, text) => window.openModal(title, `

${escapeHtml(text)}

`); diff --git a/src/ui/views/browse.js b/src/ui/views/browse.js new file mode 100644 index 0000000..f23a917 --- /dev/null +++ b/src/ui/views/browse.js @@ -0,0 +1,313 @@ +const Browse = (() => { + const state = { + sourceId: null, + supportsSearch: true, + mode: 'list', + keyword: '', + page: 1, + maxPage: 1, + scrollY: 0, + currentPostId: null, + currentDetail: null + }; + + let grid, statusBar, pager, gridView, detailView, detailContent, mainEl, sourceSelect, searchInput; + + function init() { + grid = $('grid'); + statusBar = $('statusBar'); + pager = $('pager'); + gridView = $('browseGridView'); + detailView = $('detailView'); + detailContent = $('detailContent'); + mainEl = $('main'); + sourceSelect = $('sourceSelect'); + searchInput = $('searchInput'); + + $('searchBtn').onclick = doSearch; + searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); }); + $('clearSearchBtn').onclick = clearSearch; + $('prevBtn').onclick = () => { if (state.page > 1) { state.page--; loadGrid(); } }; + $('nextBtn').onclick = () => { if (state.page < state.maxPage) { state.page++; loadGrid(); } }; + $('jumpBtn').onclick = jumpToPage; + $('jumpInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') jumpToPage(); }); + $('backBtn').onclick = showGrid; + + sourceSelect.onchange = () => { + state.sourceId = sourceSelect.value; + state.supportsSearch = sourceSelect.selectedOptions[0].dataset.search !== '0'; + updateSearchUi(); + state.mode = 'list'; + state.page = 1; + clearSearchUi(); + loadGrid(); + }; + + loadSources(); + } + + function updateSearchUi() { + searchInput.disabled = !state.supportsSearch; + $('searchBtn').disabled = !state.supportsSearch; + searchInput.placeholder = state.supportsSearch ? '搜索标题 / 作者 / 关键词...' : '该源暂不支持搜索,请翻页浏览'; + } + + async function loadSources() { + const res = await window.api.sources.list(); + const all = res.ok ? res.data : []; + const enabled = getEnabledSources(); + const list = enabled ? all.filter((s) => enabled.includes(s.id)) : all; + sourceSelect.innerHTML = list.map((s) => + ``).join(''); + if (!list.length) { + state.sourceId = null; + grid.innerHTML = '
未启用任何数据源,请在设置中开启
'; + pager.classList.add('hidden'); + statusBar.textContent = ''; + return; + } + if (!list.some((s) => s.id === state.sourceId)) state.sourceId = list[0].id; + sourceSelect.value = state.sourceId; + state.supportsSearch = sourceSelect.selectedOptions[0].dataset.search !== '0'; + updateSearchUi(); + loadGrid(); + } + + function doSearch() { + if (!state.supportsSearch) return; + const kw = searchInput.value.trim(); + if (!kw) return; + state.mode = 'search'; + state.keyword = kw; + state.page = 1; + $('clearSearchBtn').classList.remove('hidden'); + loadGrid(); + } + + function clearSearchUi() { + searchInput.value = ''; + $('clearSearchBtn').classList.add('hidden'); + } + + function clearSearch() { + state.mode = 'list'; + state.keyword = ''; + state.page = 1; + clearSearchUi(); + loadGrid(); + } + + async function loadGrid() { + showGrid(); + statusBar.textContent = '加载中...'; + grid.innerHTML = ''; + pager.classList.add('hidden'); + mainEl.scrollTop = 0; + + const res = state.mode === 'search' + ? await window.api.sources.search(state.sourceId, state.keyword, state.page) + : await window.api.sources.browse(state.sourceId, state.page); + + if (!res.ok) { + const isAuth = state.sourceId === 'zlib' && /登录|登录|AUTH/i.test(res.error || ''); + statusBar.innerHTML = `加载失败:${escapeHtml(res.error)} `; + if (isAuth) { + grid.innerHTML = '
Z-Library 需要登录,请到"设置"页配置账号
'; + } else { + grid.innerHTML = '
该数据源暂时不可用,可切换其它源
'; + } + $('gridRetry').onclick = loadGrid; + return; + } + + const { items, maxPage } = res.data; + state.maxPage = maxPage || 1; + + if (!items.length) { + grid.innerHTML = '
未找到相关结果
'; + statusBar.textContent = state.mode === 'search' ? `搜索:${escapeHtml(state.keyword)}` : ''; + return; + } + + statusBar.textContent = state.mode === 'search' ? `搜索:${escapeHtml(state.keyword)}(第 ${state.page} 页)` : ''; + + grid.innerHTML = items.map((it) => ` +
+
${it.cover ? '' : `
${escapeHtml(it.title)}
`}
+
${escapeHtml(it.title)}
+ ${it.subtitle ? `
${escapeHtml(it.subtitle)}
` : ''} + ${it.date ? `
${escapeHtml(it.date)}
` : ''} +
`).join(''); + + grid.querySelectorAll('.card').forEach((el) => { + el.onclick = () => openDetail(el.dataset.id); + }); + + $('pageInfo').textContent = `第 ${state.page} / ${state.maxPage} 页`; + $('prevBtn').disabled = state.page <= 1; + $('nextBtn').disabled = state.page >= state.maxPage; + const jump = $('jumpInput'); + jump.max = state.maxPage; + jump.value = ''; + jump.placeholder = state.page; + pager.classList.remove('hidden'); + } + + function jumpToPage() { + const input = $('jumpInput'); + let n = parseInt(input.value, 10); + if (!n || n < 1) return; + if (n > state.maxPage) n = state.maxPage; + if (n === state.page) return; + state.page = n; + loadGrid(); + } + + function showGrid() { + detailView.classList.add('hidden'); + gridView.classList.remove('hidden'); + if (state.scrollY) mainEl.scrollTop = state.scrollY; + } + + async function openDetail(postId) { + state.scrollY = mainEl.scrollTop; + state.currentPostId = postId; + gridView.classList.add('hidden'); + detailView.classList.remove('hidden'); + mainEl.scrollTop = 0; + detailContent.innerHTML = '
加载中...
'; + + const res = await window.api.sources.detail(state.sourceId, postId); + if (!res.ok) { + detailContent.innerHTML = `
加载失败:${escapeHtml(res.error)}
`; + $('detailRetry').onclick = () => openDetail(postId); + return; + } + state.currentDetail = res.data; + renderDetail(res.data); + loadDownload(postId); + refreshAddButton(); + } + + function renderDetail(d) { + const tagsHtml = (d.tags || []).map((t) => { + const idx = t.indexOf(':'); + if (idx > 0) return `
${escapeHtml(t.slice(0, idx))}${escapeHtml(t.slice(idx + 1))}
`; + return `
${escapeHtml(t)}
`; + }).join(''); + const authorsHtml = (d.authors && d.authors.length) + ? `
${escapeHtml(d.authors.join(', '))}
` : ''; + const briefHtml = d.brief ? `
简介 / 摘要
${escapeHtml(d.brief)}
` : ''; + + detailContent.innerHTML = ` +
+
${d.cover ? '' : `
${escapeHtml(d.title)}
`}
+
+
${escapeHtml(d.title)}
+ ${authorsHtml} + ${d.date ? `
日期${escapeHtml(d.date)}
` : ''} + ${tagsHtml} + ${d.url ? `` : ''} + +
+
+
下载 / 全文
+
下载信息获取中...
+ ${briefHtml} + `; + + $('addLibBtn').onclick = addToLibrary; + const urlLink = $('detailUrlLink'); + if (urlLink) urlLink.onclick = (e) => { e.preventDefault(); window.api.openExternal(d.url); }; + } + + async function refreshAddButton() { + const btn = $('addLibBtn'); + if (!btn) return; + const res = await window.api.library.findBySource(state.sourceId, state.currentPostId); + if (res.ok && res.data) { + btn.textContent = '已在书库 ✓'; + btn.disabled = true; + btn.classList.add('in-lib'); + } + } + + async function addToLibrary() { + const d = state.currentDetail; + if (!d) return; + const res = await window.api.library.add({ + title: d.title, + authors: d.authors || [], + cover: d.cover, + date: d.date || '', + brief: d.brief || '', + url: d.url || '', + sourceId: state.sourceId, + sourcePostId: state.currentPostId + }); + if (res.ok) { + const btn = $('addLibBtn'); + btn.textContent = '已加入书库 ✓'; + btn.disabled = true; + btn.classList.add('in-lib'); + if (window.Library) window.Library.markDirty(); + } + } + + async function loadDownload(postId) { + const box = $('downloadBox'); + if (!box) return; + box.innerHTML = '
下载信息获取中...
'; + const res = await window.api.sources.download(state.sourceId, postId); + if (!box.isConnected) return; + if (!res.ok) { + box.innerHTML = `
获取失败:${escapeHtml(res.error)}
`; + $('dlRetry').onclick = () => loadDownload(postId); + return; + } + const d = res.data; + let html = ''; + const files = d.files || []; + if (files.length) { + html += `
${files.map((f) => `
+ ${escapeHtml(f.name)} + ${f.format ? `${escapeHtml(f.format)}` : ''} + + +
`).join('')}
`; + } + const links = d.links || []; + if (links.length) { + html += links.map((l) => ``).join(''); + } + box.innerHTML = html || '
未解析到下载信息
'; + box.querySelectorAll('.copy-btn').forEach((btn) => { btn.onclick = () => copyText(btn, btn.dataset.copy); }); + box.querySelectorAll('.dl-btn[data-file]').forEach((btn) => { btn.onclick = () => downloadFile(btn, btn.dataset.file); }); + box.querySelectorAll('[data-open]').forEach((a) => { a.onclick = (e) => { e.preventDefault(); window.api.openExternal(a.dataset.open); }; }); + } + + async function downloadFile(btn, url) { + const orig = btn.textContent; + btn.disabled = true; + btn.textContent = '下载中...'; + const lib = await window.api.library.findBySource(state.sourceId, state.currentPostId); + const entryId = (lib.ok && lib.data) ? lib.data.id : undefined; + const res = await window.api.downloadFile(url, btn.dataset.name || '', entryId); + if (res.ok && res.data && res.data.canceled) { + btn.textContent = orig; btn.disabled = false; + return; + } + if (res.ok) { + btn.textContent = '已保存 ✓'; + btn.classList.add('copied'); + setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); btn.disabled = false; }, 2000); + } else { + btn.textContent = '失败'; + setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 2000); + } + } + + return { init, reloadSources: loadSources }; +})(); + +window.Browse = Browse; diff --git a/src/ui/views/library.js b/src/ui/views/library.js new file mode 100644 index 0000000..aa779ea --- /dev/null +++ b/src/ui/views/library.js @@ -0,0 +1,114 @@ +const Library = (() => { + let dirty = true; + let sortMode = localStorage.getItem('libSortMode') || 'added'; + let grid, statusEl; + + const SORTERS = { + added: (a, b) => (b.addedAt || 0) - (a.addedAt || 0), + title: (a, b) => String(a.title).localeCompare(String(b.title), 'zh'), + author: (a, b) => String((a.authors || [])[0] || '').localeCompare(String((b.authors || [])[0] || ''), 'zh') + }; + + function init() { + grid = $('libGrid'); + statusEl = $('libStatus'); + $('addLocalBtn').onclick = addLocal; + window.api.library.onChanged(() => { dirty = true; refresh(true); }); + } + + function getSortMode() { return sortMode; } + function setSortMode(m) { + sortMode = m; + localStorage.setItem('libSortMode', m); + dirty = true; + refresh(true); + } + + async function refresh(force) { + if (!force && !dirty) return; + const res = await window.api.library.list(); + dirty = false; + if (!res.ok) { statusEl.textContent = '加载失败:' + res.error; return; } + const items = res.data.slice().sort(SORTERS[sortMode] || SORTERS.added); + statusEl.textContent = `共 ${items.length} 条`; + if (!items.length) { + grid.innerHTML = '
书库为空,去「检索」页添加文献 / 图书吧
'; + return; + } + grid.innerHTML = items.map((it) => { + const hasFile = (it.files || []).some((f) => f.path); + const badge = hasFile + ? '已下载' + : '未下载'; + return ` +
+
${it.cover ? '' : `
${escapeHtml(it.title)}
`}
+
${escapeHtml(it.title)}
+ ${(it.authors && it.authors.length) ? `
${escapeHtml(it.authors.slice(0, 2).join(', '))}
` : ''} + ${badge} +
+ + ${it.url ? '' : ''} + +
+
`; + }).join(''); + + grid.querySelectorAll('.card').forEach((el) => { + const id = el.dataset.id; + el.querySelectorAll('button').forEach((btn) => { + btn.onclick = (e) => { e.stopPropagation(); onAction(id, btn.dataset.act); }; + }); + }); + } + + async function onAction(id, act) { + const res = await window.api.library.get(id); + if (!res.ok || !res.data) return; + const it = res.data; + if (act === 'open') { + const f = (it.files || []).find((x) => x.path); + if (f) window.api.openPath(f.path); + } else if (act === 'page') { + if (it.url) window.api.openExternal(it.url); + } else if (act === 'remove') { + const hasFile = (it.files || []).some((f) => f.path); + const r = await openModal('移除条目', ` +

确定移除「${escapeHtml(it.title)}」吗?

+ ${hasFile ? '

' : ''} + `, () => ({ del: !!(document.getElementById('delFiles') || {}).checked })); + if (!r) return; + await window.api.library.remove(id, r.del); + dirty = true; + refresh(true); + } + } + + async function addLocal() { + const r = await window.api.pickFile(); + if (!r.ok || !r.data) return; + const { path: p, name } = r.data; + const res = await openModal('添加本地文件', ` +

文件:${escapeHtml(p)}

+ + + `, () => ({ + title: (document.getElementById('localTitle').value || name).trim(), + author: (document.getElementById('localAuthor').value || '').trim() + })); + if (!res) return; + await window.api.library.add({ + title: res.title, + authors: res.author ? [res.author] : [], + files: [{ path: p, name: p.split(/[\\/]/).pop(), format: (p.split('.').pop() || '').toUpperCase() }] + }); + dirty = true; + refresh(true); + } + + function markDirty() { dirty = true; } + + return { init, refresh, markDirty, getSortMode, setSortMode }; +})(); + +window.Library = Library; diff --git a/test-search.js b/test-search.js new file mode 100644 index 0000000..f6b9284 --- /dev/null +++ b/test-search.js @@ -0,0 +1,111 @@ +// 临时测试脚本:完全模拟 main.js 的初始化流程,跑全部数据源搜索 +const { app, session } = require('electron'); +const path = require('path'); + +app.setName('PeopleLib'); +app.commandLine.appendSwitch('ignore-certificate-errors'); + +const userDataDir = path.join(app.getPath('appData'), 'PeopleLib'); +app.setPath('userData', userDataDir); + +const sources = require('./src/sources'); +const zlibAuth = require('./src/sources/zlib-auth'); +const settings = require('./src/settings'); +const { setProxy, getProxy } = require('./src/sources/http'); + +zlibAuth.init(userDataDir); +settings.init(userDataDir); + +// 允许通过命令行覆盖代理:electron test-search.js --proxy http://localhost:7897 +const argv = process.argv.slice(2); +const pIdx = argv.indexOf('--proxy'); +const proxyOverride = pIdx >= 0 ? argv[pIdx + 1] : null; +setProxy(proxyOverride !== null ? proxyOverride : settings.get('proxy', '')); + +function mask(s) { + s = String(s || ''); + return s.length > 8 ? s.slice(0, 4) + '***' + s.slice(-2) : '***'; +} + +const CASES = [ + { id: 'motw', kw: 'marx' }, + { id: 'scihub', kw: '10.1038/nature12373' }, + { id: 'libgen', kw: 'godel escher bach' }, + { id: 'zlib', kw: 'godel' }, + { id: 'gutenberg', kw: 'alice' }, + { id: 'arxiv', kw: 'transformer' } +]; + +async function runCase(c) { + const label = `[${c.id}]`; + const t0 = Date.now(); + try { + const src = sources.getSource(c.id); + const r = await src.search(c.kw, 1); + const n = (r.items || []).length; + console.log(`${label} search("${c.kw}") -> ${n} items, maxPage=${r.maxPage} (${Date.now() - t0}ms)`); + if (!n) { + console.log(`${label} !! EMPTY RESULT`); + return { id: c.id, ok: false, reason: 'empty' }; + } + for (const it of r.items.slice(0, 2)) { + console.log(`${label} - ${String(it.title).slice(0, 70)} | ${String(it.subtitle || '').slice(0, 40)}`); + } + // 顺带验证 detail + download 链路 + const first = r.items[0]; + try { + const d = await src.detail(first.postId); + console.log(`${label} detail OK: ${String(d.title).slice(0, 60)}`); + } catch (e) { + console.log(`${label} detail FAIL: ${e.message}`); + } + try { + const dl = await src.download(first.postId); + const files = (dl.files || []).length; + const links = (dl.links || []).length; + console.log(`${label} download OK: ${files} files, ${links} links`); + if (files) console.log(`${label} file: ${dl.files[0].name} -> ${String(dl.files[0].link).slice(0, 80)}`); + } catch (e) { + console.log(`${label} download FAIL: ${e.message}`); + } + return { id: c.id, ok: true, n }; + } catch (e) { + console.log(`${label} SEARCH FAIL (${Date.now() - t0}ms): ${e.message}`); + return { id: c.id, ok: false, reason: e.message }; + } +} + +app.whenReady().then(async () => { + const p = getProxy(); + console.log('=== 环境 ==='); + console.log('userData:', userDataDir); + console.log('proxy(app):', p || '(空 -> 使用系统代理)'); + if (p) { + await session.defaultSession.setProxy({ proxyRules: p }).catch(() => {}); + } + app.on('certificate-error', (event, wc, url, err, cert, cb) => { + event.preventDefault(); + cb(true); + }); + + const s = zlibAuth.getSession(); + const creds = zlibAuth.read(); + console.log('zlib creds:', creds && creds.email ? creds.email : '(无)'); + console.log('zlib session:', s ? `userId=${s.userId} userKey=${mask(s.userKey)} mirror='${s.mirror}'` : '(无)'); + console.log(''); + + const results = []; + for (const c of CASES) { + results.push(await runCase(c)); + console.log(''); + } + + console.log('=== 汇总 ==='); + for (const r of results) { + console.log(`${r.ok ? 'PASS' : 'FAIL'} ${r.id}${r.ok ? ` (${r.n})` : ` - ${r.reason}`}`); + } + const after = zlibAuth.getSession(); + console.log(''); + console.log('zlib session after:', after ? `mirror='${after.mirror}'` : '(无)'); + app.exit(0); +});