Files
peoplelib/src/sources/http.js
T

257 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.3 (+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);
}
async function responseBytes(res, maxBytes) {
const declared = Number(res.headers && res.headers.get && res.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
try { if (res.body && res.body.cancel) await res.body.cancel(); } catch (e) { /* ignore */ }
return null;
}
if (!res.body || typeof res.body.getReader !== 'function') {
const bytes = Buffer.from(await res.arrayBuffer());
return bytes.length <= maxBytes ? bytes : null;
}
const reader = res.body.getReader();
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
size += chunk.length;
if (size > maxBytes) {
await reader.cancel();
return null;
}
chunks.push(chunk);
}
} finally {
try { reader.releaseLock(); } catch (e) { /* ignore */ }
}
return Buffer.concat(chunks, size);
}
// 简易 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(/&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;
}
// 部分站点(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, responseBytes, decodeEntities, stripTags,
clampPage, tooShort, isRetryable, withRetry, getCookies, setCookies, clearCookies,
setProxy, getProxy, fetchWithProxy
};