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>
176 lines
7.4 KiB
JavaScript
176 lines
7.4 KiB
JavaScript
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());
|