feat: PeopleLib 开放文献客户端,集成 Z-Library 与 LibGen 等多源检索
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>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit
1a1288ce18
+11
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 调试探测产生的临时快照
|
||||
probe*.json
|
||||
probe-*.js
|
||||
*.tmp.html
|
||||
scihub.html
|
||||
@@ -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/
|
||||
@@ -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'));
|
||||
@@ -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());
|
||||
Generated
+884
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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')
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 搜索结果在 <div class="record"> 或 <tr> 中
|
||||
// 尝试匹配包含 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 = /<div[^>]+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(/<h3[^>]*>([\s\S]*?)<\/h3>/) || block.match(/<div[^>]+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(/<h1[^>]*>([\s\S]*?)<\/h1>/) || html.match(/<title>([^<]+)<\/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 };
|
||||
}
|
||||
};
|
||||
@@ -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 }] };
|
||||
}
|
||||
};
|
||||
@@ -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}` }]
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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}` }] };
|
||||
}
|
||||
};
|
||||
@@ -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}` }] };
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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}` }] };
|
||||
}
|
||||
};
|
||||
@@ -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}` }] };
|
||||
}
|
||||
};
|
||||
@@ -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 }]
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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}` }
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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: [] };
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
+102
@@ -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');
|
||||
@@ -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 文献库</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="titlebar">
|
||||
<div class="titlebar-left">
|
||||
<span class="brand">PeopleLib <span class="brand-sub">开放文献库</span></span>
|
||||
</div>
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="library">我的书库</button>
|
||||
<button class="tab" data-tab="browse">检索</button>
|
||||
</nav>
|
||||
<div class="titlebar-spacer"></div>
|
||||
<div class="titlebar-controls">
|
||||
<button class="win-btn tab" data-tab="settings" title="设置">⚙</button>
|
||||
<button id="minBtn" class="win-btn" title="最小化">─</button>
|
||||
<button id="maxBtn" class="win-btn" title="最大化">□</button>
|
||||
<button id="closeBtn" class="win-btn win-close" title="关闭">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main id="main">
|
||||
<!-- 我的书库 -->
|
||||
<section id="libraryTab" class="tab-panel">
|
||||
<div class="toolbar">
|
||||
<span id="libStatus" class="status-bar"></span>
|
||||
<div class="spacer"></div>
|
||||
<button id="addLocalBtn" class="tb-btn">+ 添加本地文件</button>
|
||||
</div>
|
||||
<div id="libGrid" class="grid"></div>
|
||||
</section>
|
||||
|
||||
<!-- 检索 -->
|
||||
<section id="browseTab" class="tab-panel hidden">
|
||||
<div id="browseGridView">
|
||||
<div class="browse-head">
|
||||
<div class="toolbar">
|
||||
<select id="sourceSelect" class="source-select"></select>
|
||||
<div class="search-inline">
|
||||
<input id="searchInput" type="text" placeholder="搜索标题 / 作者 / 关键词..." />
|
||||
<button id="searchBtn" class="tb-btn">搜索</button>
|
||||
<button id="clearSearchBtn" class="tb-btn ghost hidden">✕ 清除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="statusBar" class="status-bar"></div>
|
||||
</div>
|
||||
<div id="grid" class="grid"></div>
|
||||
<div id="pager" class="pager hidden">
|
||||
<button id="prevBtn" class="page-btn">← 上一页</button>
|
||||
<span id="pageInfo" class="page-info"></span>
|
||||
<button id="nextBtn" class="page-btn">下一页 →</button>
|
||||
<span class="page-jump">
|
||||
跳转到
|
||||
<input id="jumpInput" type="number" min="1" class="jump-input" />
|
||||
<button id="jumpBtn" class="page-btn">跳转</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="detailView" class="hidden">
|
||||
<button id="backBtn" class="back-btn" aria-label="返回">← 返回</button>
|
||||
<div id="detailContent"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 设置 -->
|
||||
<section id="settingsTab" class="tab-panel hidden">
|
||||
<div class="settings-page">
|
||||
<h2 class="settings-title">设置</h2>
|
||||
<div class="settings-group">
|
||||
<div class="settings-item settings-item-block">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-label">数据源管理</div>
|
||||
<div class="settings-item-desc">选择在检索页显示的数据源</div>
|
||||
</div>
|
||||
<div id="sourceList" class="source-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-label">书库默认排序</div>
|
||||
<div class="settings-item-desc">控制书库条目的排列顺序</div>
|
||||
</div>
|
||||
<select id="sortSelect" class="source-select">
|
||||
<option value="added">添加时间</option>
|
||||
<option value="title">标题</option>
|
||||
<option value="author">作者</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-label">网络代理</div>
|
||||
<div class="settings-item-desc">对所有数据源与下载统一生效;访问 LibGen / Z-Library 通常需要代理(留空表示直连)</div>
|
||||
</div>
|
||||
<input id="proxyInput" type="text" placeholder="留空表示直连,例如 http://localhost:7897" style="width:220px;padding:6px;background:#222;border:1px solid #444;color:#eee;border-radius:4px;" />
|
||||
<button id="proxySaveBtn" class="tb-btn">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-label">Z-Library 账号</div>
|
||||
<div class="settings-item-desc" id="zlibStatus">未登录</div>
|
||||
</div>
|
||||
<button id="zlibLoginBtn" class="tb-btn">登录</button>
|
||||
<button id="zlibLogoutBtn" class="tb-btn ghost hidden">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-info">
|
||||
<div class="settings-item-label">关于</div>
|
||||
<div class="settings-item-desc">PeopleLib <span id="appVersion">-</span> · 开放获取文献与公版图书客户端</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 通用弹窗 -->
|
||||
<div id="modal" class="modal hidden">
|
||||
<div class="modal-box">
|
||||
<div id="modalTitle" class="modal-title"></div>
|
||||
<div id="modalBody" class="modal-body"></div>
|
||||
<div class="modal-actions">
|
||||
<button id="modalCancel" class="page-btn">取消</button>
|
||||
<button id="modalOk" class="tb-btn">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="util.js"></script>
|
||||
<script src="views/browse.js"></script>
|
||||
<script src="views/library.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; }
|
||||
@@ -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, `<p>${escapeHtml(text)}</p>`);
|
||||
@@ -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) =>
|
||||
`<option value="${escapeHtml(s.id)}" data-search="${s.supportsSearch ? '1' : '0'}">${escapeHtml(s.name)}</option>`).join('');
|
||||
if (!list.length) {
|
||||
state.sourceId = null;
|
||||
grid.innerHTML = '<div class="empty">未启用任何数据源,请在设置中开启</div>';
|
||||
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)} <button class="retry-btn" id="gridRetry">重试</button>`;
|
||||
if (isAuth) {
|
||||
grid.innerHTML = '<div class="empty">Z-Library 需要登录,请到"设置"页配置账号</div>';
|
||||
} else {
|
||||
grid.innerHTML = '<div class="empty">该数据源暂时不可用,可切换其它源</div>';
|
||||
}
|
||||
$('gridRetry').onclick = loadGrid;
|
||||
return;
|
||||
}
|
||||
|
||||
const { items, maxPage } = res.data;
|
||||
state.maxPage = maxPage || 1;
|
||||
|
||||
if (!items.length) {
|
||||
grid.innerHTML = '<div class="empty">未找到相关结果</div>';
|
||||
statusBar.textContent = state.mode === 'search' ? `搜索:${escapeHtml(state.keyword)}` : '';
|
||||
return;
|
||||
}
|
||||
|
||||
statusBar.textContent = state.mode === 'search' ? `搜索:${escapeHtml(state.keyword)}(第 ${state.page} 页)` : '';
|
||||
|
||||
grid.innerHTML = items.map((it) => `
|
||||
<div class="card" data-id="${escapeHtml(it.postId)}">
|
||||
<div class="card-cover" style="${coverStyle(it.cover)}">${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
|
||||
<div class="card-title">${escapeHtml(it.title)}</div>
|
||||
${it.subtitle ? `<div class="card-sub">${escapeHtml(it.subtitle)}</div>` : ''}
|
||||
${it.date ? `<div class="card-date">${escapeHtml(it.date)}</div>` : ''}
|
||||
</div>`).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 = '<div class="dl-loading">加载中...</div>';
|
||||
|
||||
const res = await window.api.sources.detail(state.sourceId, postId);
|
||||
if (!res.ok) {
|
||||
detailContent.innerHTML = `<div class="dl-error">加载失败:${escapeHtml(res.error)}<button class="retry-btn" id="detailRetry">重试</button></div>`;
|
||||
$('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 `<div class="meta-row"><b>${escapeHtml(t.slice(0, idx))}</b>${escapeHtml(t.slice(idx + 1))}</div>`;
|
||||
return `<div class="meta-row">${escapeHtml(t)}</div>`;
|
||||
}).join('');
|
||||
const authorsHtml = (d.authors && d.authors.length)
|
||||
? `<div class="detail-authors">${escapeHtml(d.authors.join(', '))}</div>` : '';
|
||||
const briefHtml = d.brief ? `<div class="section-title">简介 / 摘要</div><div class="brief-panel">${escapeHtml(d.brief)}</div>` : '';
|
||||
|
||||
detailContent.innerHTML = `
|
||||
<div class="detail-head">
|
||||
<div class="detail-cover" style="${coverStyle(d.cover)}">${d.cover ? '' : `<div class="ph">${escapeHtml(d.title)}</div>`}</div>
|
||||
<div class="detail-meta">
|
||||
<div class="detail-title">${escapeHtml(d.title)}</div>
|
||||
${authorsHtml}
|
||||
${d.date ? `<div class="meta-row"><b>日期</b>${escapeHtml(d.date)}</div>` : ''}
|
||||
${tagsHtml}
|
||||
${d.url ? `<div class="meta-row"><b>原始链接</b><a href="#" id="detailUrlLink">${escapeHtml(d.url)}</a></div>` : ''}
|
||||
<button class="tb-btn add-lib-btn" id="addLibBtn">加入我的书库</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-title">下载 / 全文</div>
|
||||
<div class="download-box" id="downloadBox"><div class="dl-loading">下载信息获取中...</div></div>
|
||||
${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 = '<div class="dl-loading">下载信息获取中...</div>';
|
||||
const res = await window.api.sources.download(state.sourceId, postId);
|
||||
if (!box.isConnected) return;
|
||||
if (!res.ok) {
|
||||
box.innerHTML = `<div class="dl-error">获取失败:${escapeHtml(res.error)}<button class="retry-btn" id="dlRetry">重试</button></div>`;
|
||||
$('dlRetry').onclick = () => loadDownload(postId);
|
||||
return;
|
||||
}
|
||||
const d = res.data;
|
||||
let html = '';
|
||||
const files = d.files || [];
|
||||
if (files.length) {
|
||||
html += `<div class="dl-files">${files.map((f) => `<div class="dl-file-row">
|
||||
<span class="dl-file-name" title="${escapeHtml(f.name)}">${escapeHtml(f.name)}</span>
|
||||
${f.format ? `<span class="dl-fmt">${escapeHtml(f.format)}</span>` : ''}
|
||||
<button class="copy-btn" data-copy="${escapeHtml(f.link)}">复制</button>
|
||||
<button class="dl-btn" data-file="${escapeHtml(f.link)}" data-name="${escapeHtml(f.name)}">下载</button>
|
||||
</div>`).join('')}</div>`;
|
||||
}
|
||||
const links = d.links || [];
|
||||
if (links.length) {
|
||||
html += links.map((l) => `<div class="dl-link-row">🔗 <a href="#" data-open="${escapeHtml(l.url)}">${escapeHtml(l.name)}</a></div>`).join('');
|
||||
}
|
||||
box.innerHTML = html || '<div class="dl-error">未解析到下载信息</div>';
|
||||
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;
|
||||
@@ -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 = '<div class="empty">书库为空,去「检索」页添加文献 / 图书吧</div>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = items.map((it) => {
|
||||
const hasFile = (it.files || []).some((f) => f.path);
|
||||
const badge = hasFile
|
||||
? '<span class="card-badge">已下载</span>'
|
||||
: '<span class="card-badge miss">未下载</span>';
|
||||
return `
|
||||
<div class="card" data-id="${escapeHtml(it.id)}">
|
||||
<div class="card-cover" style="${coverStyle(it.cover)}">${it.cover ? '' : `<div class="ph">${escapeHtml(it.title)}</div>`}</div>
|
||||
<div class="card-title">${escapeHtml(it.title)}</div>
|
||||
${(it.authors && it.authors.length) ? `<div class="card-sub">${escapeHtml(it.authors.slice(0, 2).join(', '))}</div>` : ''}
|
||||
${badge}
|
||||
<div class="lib-card-actions">
|
||||
<button class="open-btn" data-act="open" ${hasFile ? '' : 'disabled'}>打开</button>
|
||||
${it.url ? '<button data-act="page">页面</button>' : ''}
|
||||
<button data-act="remove">移除</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).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('移除条目', `
|
||||
<p>确定移除「${escapeHtml(it.title)}」吗?</p>
|
||||
${hasFile ? '<p style="margin-top:8px"><label><input type="checkbox" id="delFiles" /> 同时删除已下载的文件</label></p>' : ''}
|
||||
`, () => ({ 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('添加本地文件', `
|
||||
<p>文件:${escapeHtml(p)}</p>
|
||||
<input type="text" id="localTitle" placeholder="标题" value="${escapeHtml(name)}" />
|
||||
<input type="text" id="localAuthor" placeholder="作者(可选)" />
|
||||
`, () => ({
|
||||
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;
|
||||
+111
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user