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
@@ -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 };
|
||||
Reference in New Issue
Block a user