/** * http.js - HTTP 请求工具模块 * 纯 Node.js 原生 https/http 实现,零依赖 */ import https from 'https'; import http from 'http'; import { URL } from 'url'; import config from './config.js'; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); /** * 通用 HTTP 请求 — 支持 cookie 管理、重定向跟踪、JSON 自动解析 */ export async function request(url, options = {}) { const { method = 'GET', headers = {}, body = null, timeout = config.timeout || 30000, followRedirect = true, maxRedirects = 5, cookies = null, } = options; const urlObj = new URL(url); const isHttps = urlObj.protocol === 'https:'; const lib = isHttps ? https : http; const finalHeaders = { 'User-Agent': config.userAgent, 'Accept': 'application/json, text/html, */*', 'Accept-Language': 'en-US,en;q=0.9,ru;q=0.8,zh-CN;q=0.7', 'Accept-Encoding': 'identity', ...headers, }; // 注入 cookies if (cookies && cookies.size > 0) { finalHeaders['Cookie'] = [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; '); } // 处理 body let bodyStr = null; if (body) { bodyStr = typeof body === 'string' ? body : JSON.stringify(body); if (!finalHeaders['Content-Type']) { finalHeaders['Content-Type'] = 'application/json'; } finalHeaders['Content-Length'] = Buffer.byteLength(bodyStr); } return new Promise((resolve, reject) => { const reqOptions = { hostname: urlObj.hostname, port: urlObj.port || (isHttps ? 443 : 80), path: urlObj.pathname + urlObj.search, method, headers: finalHeaders, timeout, }; const req = lib.request(reqOptions, (res) => { // 收集 Set-Cookie const resCookies = new Map(cookies || []); const setCookieHeaders = res.headers['set-cookie'] || []; for (const sc of setCookieHeaders) { const [pair] = sc.split(';'); const [name, ...valueParts] = pair.split('='); resCookies.set(name.trim(), valueParts.join('=').trim()); } // 处理重定向 if (followRedirect && [301, 302, 303, 307, 308].includes(res.statusCode) && maxRedirects > 0) { const location = res.headers['location']; if (location) { const redirectUrl = location.startsWith('http') ? location : new URL(location, url).href; res.resume(); // 丢弃 body resolve(request(redirectUrl, { ...options, maxRedirects: maxRedirects - 1, cookies: resCookies, method: [301, 302, 303].includes(res.statusCode) ? 'GET' : method, body: [301, 302, 303].includes(res.statusCode) ? null : body, })); return; } } let data = ''; res.setEncoding('utf8'); res.on('data', chunk => data += chunk); res.on('end', () => { resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, statusText: res.statusMessage, headers: res.headers, cookies: resCookies, url, text: () => data, json: () => { try { return JSON.parse(data); } catch { throw new Error(`JSON parse error: ${data.substring(0, 200)}`); } }, html: () => data, }); }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error(`请求超时: ${url}`)); }); if (bodyStr) req.write(bodyStr); req.end(); }); } /** * GET 请求简写 */ export async function get(url, options = {}) { return request(url, { ...options, method: 'GET' }); } /** * POST 请求简写 */ export async function post(url, body, options = {}) { return request(url, { ...options, method: 'POST', body }); } /** * 带重试的请求 */ export async function retryRequest(url, options = {}, maxRetries = 3) { let lastError; for (let i = 0; i < maxRetries; i++) { try { const resp = await request(url, options); if (resp.ok || resp.status < 500) return resp; lastError = new Error(`HTTP ${resp.status}: ${resp.text().substring(0, 200)}`); } catch (e) { lastError = e; } if (i < maxRetries - 1) { const delay = 1000 * (i + 1) + Math.random() * 1000; console.log(` [HTTP] 重试 ${i + 1}/${maxRetries} (${Math.round(delay)}ms)...`); await sleep(delay); } } throw lastError; } export { sleep }; /** * 流式 HTTP 请求 — 返回原始响应流 (不缓冲 body) * 用于代理 chataibot SSE 流式响应 */ export async function requestStream(url, options = {}) { const { method = 'GET', headers = {}, body = null, timeout = config.timeout || 120000, cookies = null, } = options; const urlObj = new URL(url); const isHttps = urlObj.protocol === 'https:'; const lib = isHttps ? https : http; const finalHeaders = { 'User-Agent': config.userAgent, 'Accept': 'text/event-stream, application/json', 'Accept-Language': 'en', 'Accept-Encoding': 'identity', ...headers, }; if (cookies && cookies.size > 0) { finalHeaders['Cookie'] = [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; '); } let bodyStr = null; if (body) { bodyStr = typeof body === 'string' ? body : JSON.stringify(body); if (!finalHeaders['Content-Type']) { finalHeaders['Content-Type'] = 'application/json'; } finalHeaders['Content-Length'] = Buffer.byteLength(bodyStr); } return new Promise((resolve, reject) => { const reqOptions = { hostname: urlObj.hostname, port: urlObj.port || (isHttps ? 443 : 80), path: urlObj.pathname + urlObj.search, method, headers: finalHeaders, timeout, }; const req = lib.request(reqOptions, (res) => { const resCookies = new Map(cookies || []); const setCookieHeaders = res.headers['set-cookie'] || []; for (const sc of setCookieHeaders) { const [pair] = sc.split(';'); const [name, ...valueParts] = pair.split('='); resCookies.set(name.trim(), valueParts.join('=').trim()); } resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, headers: res.headers, cookies: resCookies, stream: res, }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error(`流式请求超时: ${url}`)); }); if (bodyStr) req.write(bodyStr); req.end(); }); }