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
+148
View File
@@ -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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)))
.replace(/&amp;/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 };