构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
222 lines
8.2 KiB
JavaScript
222 lines
8.2 KiB
JavaScript
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 PeopleLib/2.1.1 (+https://github.com/lofyer/peoplelib)';
|
||
const { fetch: undiciFetch, ProxyAgent } = require('undici');
|
||
|
||
// 代理配置:默认直连(空字符串)。一旦设置,所有 HTTP 请求统一走该代理。
|
||
// 持久化存储在 userData/settings.json 的 "proxy" 字段。
|
||
let proxyUrl = '';
|
||
let dispatcher = null;
|
||
|
||
function setProxy(url) {
|
||
const nextUrl = String(url || '').trim();
|
||
if (nextUrl === proxyUrl) return;
|
||
let nextDispatcher = null;
|
||
if (nextUrl) {
|
||
const parsed = new URL(nextUrl);
|
||
if (!/^https?:$/.test(parsed.protocol)) throw new Error('代理地址仅支持 http:// 或 https://');
|
||
nextDispatcher = new ProxyAgent({ uri: nextUrl, connectTimeout: 30000 });
|
||
}
|
||
if (dispatcher) {
|
||
dispatcher.close().catch(() => {});
|
||
}
|
||
proxyUrl = nextUrl;
|
||
dispatcher = nextDispatcher;
|
||
}
|
||
|
||
function getProxy() { return proxyUrl; }
|
||
|
||
function fetchWithProxy(url, options = {}) {
|
||
return undiciFetch(url, dispatcher ? { ...options, dispatcher } : options);
|
||
}
|
||
|
||
function fetchWithElectron(url, options = {}) {
|
||
try {
|
||
const electron = require('electron');
|
||
if (electron && electron.net && typeof electron.net.fetch === 'function') {
|
||
return electron.net.fetch(url, options);
|
||
}
|
||
} catch (e) { /* Node 测试环境没有 Electron 网络栈 */ }
|
||
return fetchWithProxy(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());
|
||
}
|
||
}
|
||
|
||
// target 可以是完整 URL 或裸主机名;jar 以主机名为键,
|
||
// 传 URL 时要先取出 hostname,否则永远匹配不到。
|
||
function clearCookies(target) {
|
||
if (!target) { cookieJar.clear(); return; }
|
||
const host = domainOf(target) || String(target);
|
||
for (const k of [...cookieJar.keys()]) {
|
||
if (k === host || k.endsWith(`.${host}`)) 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, signal: outerSignal, useElectronNet, ...rest } = options;
|
||
const ms = timeout === undefined ? DEFAULT_TIMEOUT : timeout;
|
||
|
||
// 调用方传入的 signal 不能顶替超时,否则外部取消一旦启用就再也没有超时保护
|
||
const ac = new AbortController();
|
||
const abort = () => ac.abort();
|
||
if (outerSignal) {
|
||
if (outerSignal.aborted) ac.abort();
|
||
else outerSignal.addEventListener('abort', abort, { once: true });
|
||
}
|
||
const timer = ms > 0 ? setTimeout(abort, ms) : null;
|
||
|
||
try {
|
||
const request = useElectronNet ? fetchWithElectron : fetchWithProxy;
|
||
const res = await request(url, { redirect: 'follow', ...rest, headers, signal: ac.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 || ''))) {
|
||
if (outerSignal && outerSignal.aborted) throw new Error('请求已取消');
|
||
throw new Error('请求超时,站点无响应');
|
||
}
|
||
// undici / Chromium 的底层网络错误信息很不友好,统一换成可读文案
|
||
const msg = (e && e.message) || String(e);
|
||
if (/fetch failed|ERR_|ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|socket hang up/i.test(msg)) {
|
||
throw new Error('网络连接失败,请检查网络或代理设置');
|
||
}
|
||
throw e;
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
if (outerSignal) outerSignal.removeEventListener('abort', abort);
|
||
}
|
||
}
|
||
|
||
const STATUS_HINT = {
|
||
403: '站点拒绝访问(403)',
|
||
404: '资源不存在(404)',
|
||
422: '查询条件不被接受(422)',
|
||
429: '请求过于频繁,请稍后再试(429)',
|
||
500: '站点内部错误(500)',
|
||
502: '站点网关错误(502)',
|
||
503: '站点暂时不可用(503)',
|
||
504: '站点网关超时(504)'
|
||
};
|
||
|
||
async function fetchOnce(url, options) {
|
||
const res = await fetchRaw(url, options);
|
||
if (!res.ok) throw new Error(STATUS_HINT[res.status] || `请求失败(HTTP ${res.status})`);
|
||
return res.text();
|
||
}
|
||
|
||
// 默认对瞬时故障(超时/网络抖动/5xx/限流)自动重试一次。
|
||
// 镜像轮询类数据源(LibGen / Sci-Hub / Z-Library)应传 retries: 0,
|
||
// 由上层的 tryMirrors / raceMirrors 负责换镜像,避免重试叠加放大耗时。
|
||
//
|
||
// 重试会把最坏耗时翻倍(超时 15s -> 30s),聚合搜索下体感很差。
|
||
// 因此重试时缩短单次超时:宁可放弃这次重试,也不要让整组结果一直转圈。
|
||
function fetchText(url, options = {}) {
|
||
const { retries, retryDelay, ...rest } = options;
|
||
const tries = (retries === undefined ? 1 : retries) + 1;
|
||
if (tries <= 1) return fetchOnce(url, rest);
|
||
|
||
const baseTimeout = rest.timeout === undefined ? DEFAULT_TIMEOUT : rest.timeout;
|
||
return withRetry(
|
||
(attempt) => fetchOnce(url, attempt === 0
|
||
? rest
|
||
: { ...rest, timeout: Math.max(3000, Math.round(baseTimeout / 2)) }),
|
||
{ tries, delay: retryDelay || 800 }
|
||
);
|
||
}
|
||
|
||
async function fetchJson(url, options = {}) {
|
||
const text = await fetchText(url, options);
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch (e) {
|
||
throw new Error('返回内容不是有效 JSON');
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// 部分站点(OpenLibrary / LibGen)对过短的关键词直接返回 422。
|
||
// 与其把 HTTP 错误抛给用户,不如提前返回空结果并带上说明。
|
||
function tooShort(keyword, min = 3) {
|
||
const kw = String(keyword || '').trim();
|
||
if (kw.length >= min) return null;
|
||
return { items: [], maxPage: 1, page: 1, note: `关键词太短,该源要求至少 ${min} 个字符` };
|
||
}
|
||
|
||
// 可自愈的错误:超时、网络抖动、5xx、限流。4xx 属请求本身的问题,重试无意义。
|
||
// 主动取消(竞速败者、切换页面)不是故障,重试只会浪费一次请求。
|
||
function isRetryable(e) {
|
||
const msg = (e && e.message) || '';
|
||
if (/请求已取消/.test(msg)) return false;
|
||
return /超时|网络连接失败|站点内部错误|站点网关|暂时不可用|过于频繁/.test(msg);
|
||
}
|
||
|
||
// 带退避的重试包装。仅在错误可自愈时重试,避免为 4xx 白等几秒。
|
||
// fn 会收到当前尝试序号(0 起),便于按次调整超时等参数。
|
||
async function withRetry(fn, { tries = 2, delay = 1000 } = {}) {
|
||
for (let i = 0; ; i++) {
|
||
try {
|
||
return await fn(i);
|
||
} catch (e) {
|
||
if (i >= tries - 1 || !isRetryable(e)) throw e;
|
||
await new Promise((r) => setTimeout(r, delay * (i + 1)));
|
||
}
|
||
}
|
||
}
|
||
|
||
module.exports = { UA, fetchRaw, fetchText, fetchJson, decodeEntities, stripTags, clampPage, tooShort, isRetryable, withRetry, getCookies, setCookies, clearCookies, setProxy, getProxy, fetchWithProxy };
|