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:
lofyer
2026-07-25 14:51:10 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit 1a1288ce18
33 changed files with 4640 additions and 0 deletions
+130
View File
@@ -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 };