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> 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, responseBytes, decodeEntities, stripTags, clampPage, tooShort, isRetryable, withRetry, getCookies, setCookies, clearCookies, setProxy, getProxy, fetchWithProxy };