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