大多数关于 Node.js 中代理的指南建议在项目中引入 https-proxy-agent、socks-proxy-agent 和其他几个包。实际上,从 Node.js 18 版本开始,内置的 HTTP 客户端 undici 已经包含了 ProxyAgent,可以解决大多数通过代理进行 API 解析和测试的任务。在本文中,我们将讨论如何通过 undici 设置 HTTP/HTTPS 隧道、在请求之间组织 IP 轮换,以及如果需要 SOCKS5 而不想引入额外依赖时该怎么办。
为什么选择 undici 而不是 axios 和 request
undici 是一个 HTTP/1.1 和 HTTP/2 客户端,由 Node.js 团队编写,从 18 版本开始,它是全球 fetch 的基础。它没有传递依赖,在大多数连接池基准测试中比 axios 更快,而对我们的话题来说重要的是,它有一个原生模块用于通过 HTTP CONNECT 隧道处理代理。
当您解析 Wildberries、Ozon 或测试来自不同地理位置的广告创意时,node_modules 中的每一个额外包都是在更新 Node.js 时可能出现的故障点。undici 与运行时一起提供,因此您可以获得最新的 TLS、HTTP/2 和保持连接功能,而不必担心废弃的库在新的主要版本中会崩溃。
安装非常简单:
npm install undici
如果您使用的是 Node.js 20+,部分 API 在不导入的情况下可以全局访问,但对于 ProxyAgent,仍然需要导入,因为它不会默认导出到全局 fetch。
ProxyAgent 的基本设置
通过代理的最小工作示例请求如下:
import { ProxyAgent, fetch } from 'undici';
const proxyAgent = new ProxyAgent('http://proxy.example.com:8080');
const response = await fetch('https://httpbin.org/ip', {
dispatcher: proxyAgent,
});
const data = await response.json();
console.log(data);
关键点是参数 dispatcher。这不是旧模块 http 中的 agent 选项,而是 undici 的调度程序,它在 TLS 握手之前拦截连接,并通过 CONNECT 方法建立到目标主机的隧道。对于通过代理的 HTTPS 请求来说,这是至关重要的:普通的 HTTP 代理无法看到加密流量的内容,它只是通过 TCP 隧道转发字节。
如果您使用的是低级的 undici.request 而不是 fetch,语法是类似的:
import { request, ProxyAgent } from 'undici';
const proxyAgent = new ProxyAgent('http://proxy.example.com:8080');
const { statusCode, body } = await request('https://api.example.com/data', {
dispatcher: proxyAgent,
});
console.log(statusCode);
for await (const chunk of body) {
console.log(chunk.toString());
}
对于每分钟进行数百个请求的生产解析器,重要的是不要为每个请求创建新的 ProxyAgent — 这会破坏连接池。在连接池中创建代理实例一次,并在调用之间重用它。
通过用户名和密码进行代理身份验证
大多数付费代理提供商通过用户名/密码对而不是 IP 白名单提供访问。在 undici 中,凭据直接通过代理的 URL 传递:
import { ProxyAgent, fetch } from 'undici';
const login = 'user123';
const password = 'pass456';
const proxyUrl = `http://${login}:${password}@gate.proxycove.com:9000`;
const proxyAgent = new ProxyAgent(proxyUrl);
const response = await fetch('https://httpbin.org/ip', {
dispatcher: proxyAgent,
});
console.log(await response.json());
请注意:如果用户名或密码包含特殊字符(@、:、/),则需要通过 encodeURIComponent 进行编码,否则 undici 内部的 URL 解析器将无法正确解析连接字符串,您将收到 407 身份验证错误,而不是可理解的消息。
对于需要会话稳定性的任务 — 例如,在会话期间绑定到单个 IP 的市场授权 — 使用 住宅代理 和粘性会话更为方便:IP 在指定时间内不变,因此服务不会看到地理位置之间的“跳跃”,也不需要在每个请求中重复输入验证码。
在请求之间轮换代理
如果您有一个 N 个代理的列表,并且任务是在它们之间分配请求,最简单的选项是无第三方库的循环轮换:
import { ProxyAgent, fetch } from 'undici';
const proxyList = [
'http://user:[email protected]:8000',
'http://user:[email protected]:8000',
'http://user:[email protected]:8000',
];
let currentIndex = 0;
function getNextAgent() {
const proxyUrl = proxyList[currentIndex];
currentIndex = (currentIndex + 1) % proxyList.length;
return new ProxyAgent(proxyUrl);
}
async function fetchWithRotation(url) {
const agent = getNextAgent();
return fetch(url, { dispatcher: agent });
}
for (let i = 0; i < 10; i++) {
const res = await fetchWithRotation('https://httpbin.org/ip');
console.log(await res.json());
}
这种方法非常适合解析商品卡片或监控价格,当每个后续请求在逻辑上与前一个请求独立时。对于需要在每个请求中快速更换 IP 的任务,而不需要手动管理列表,使用提供商端自动轮换的代理更为简单 — 那么上述所有代码都可以简化为一个静态的网关 URL,而轮换则由 数据中心代理 或带有定时轮换的住宅池处理。
如果需要的不仅仅是循环轮换,而是加权轮换 — 例如,优先选择更快的代理 — 请在数据结构中添加权重:
const weightedProxies = [
{ url: 'http://user:[email protected]:8000', weight: 5 },
{ url: 'http://user:[email protected]:8000', weight: 1 },
];
function pickWeighted(list) {
const total = list.reduce((sum, p) => sum + p.weight, 0);
let rnd = Math.random() * total;
for (const p of list) {
if (rnd < p.weight) return p.url;
rnd -= p.weight;
}
return list[0].url;
}
检查代理的存活性并排除“死” IP
代理池随着时间的推移会“过期”:部分 IP 被目标网站封禁,部分停止响应。为了避免轮换通过死地址发送请求,请添加带有超时的健康检查,通过内置的 AbortController:
import { ProxyAgent, fetch } from 'undici';
async function isProxyAlive(proxyUrl, timeoutMs = 5000) {
const agent = new ProxyAgent(proxyUrl);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch('https://httpbin.org/ip', {
dispatcher: agent,
signal: controller.signal,
});
return res.status === 200;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
async function filterAliveProxies(proxyList) {
const checks = await Promise.all(
proxyList.map(async (url) => ({ url, alive: await isProxyAlive(url) }))
);
return checks.filter((c) => c.alive).map((c) => c.url);
}
每隔几分钟运行一次这样的检查作为后台定时器,并更新活动池,而不是整个静态列表。这一点尤其重要,如果您在便宜的数据中心 IP 上保持自己的代理 — 那里的故障率高于受管理的提供商池。
无第三方库的 SOCKS5
重要说明:undici 中内置的 ProxyAgent 通过 HTTP CONNECT 隧道工作,并且“开箱即用”只理解 HTTP/HTTPS 代理。SOCKS5 协议的工作方式不同 — 它是在 TCP 层的二进制握手,undici 无法原生解析它。如果您确实需要 SOCKS5 而不安装 socks-proxy-agent,可以通过 net 模块和自定义 Agent 的 connect 选项手动实现一个最小连接器:
import net from 'node:net';
import { Agent, fetch } from 'undici';
function socksHandshake(socket, targetHost, targetPort) {
return new Promise((resolve, reject) => {
// 步骤 1:问候,无身份验证方法
socket.write(Buffer.from([0x05, 0x01, 0x00]));
socket.once('data', (greeting) => {
if (greeting[1] !== 0x00) {
return reject(new Error('SOCKS5: 不支持的身份验证方法'));
}
const hostBuf = Buffer.from(targetHost, 'utf8');
const request = Buffer.concat([
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
hostBuf,
Buffer.from([targetPort >> 8, targetPort & 0xff]),
]);
socket.write(request);
socket.once('data', (reply) => {
if (reply[1] !== 0x00) {
return reject(new Error('SOCKS5: 连接被拒绝'));
}
resolve(socket);
});
});
});
}
const socksAgent = new Agent({
connect: (options, callback) => {
const socket = net.connect(1080, 'socks-proxy.example.com', async () => {
try {
await socksHandshake(socket, options.hostname, 443);
callback(null, socket);
} catch (err) {
callback(err, null);
}
});
socket.on('error', (err) => callback(err, null));
},
});
const response = await fetch('https://httpbin.org/ip', { dispatcher: socksAgent });
console.log(await response.json());
这是一个简化的握手,没有通过用户名和密码的身份验证(方法 0x02),也不支持 IPv6 — 对于生产环境,建议添加身份验证和不同地址类型的处理。但为了理解机制,这已经足够:SOCKS5 在握手阶段不需要 TLS,而 HTTP CONNECT 需要,因此代码比初看起来要短。
实际上,SOCKS5 更常用于转发移动应用程序和反检测浏览器(如 Dolphin Anty 或 AdsPower)的流量,其中协议需要在套接字层工作,而不是 HTTP 请求。如果您的任务是处理 Node.js 中的 HTTP/HTTPS 流量,内置的 ProxyAgent 通常已经足够,手动处理 SOCKS5 连接器没有意义。
处理超时和重试逻辑
代理比直接连接更频繁地断开连接 — 这在住宅网络中是正常的,因为节点可能随时离线。需要一个带有指数延迟的重试包装:
import { ProxyAgent, fetch } from 'undici';
async function fetchWithRetry(url, proxyList, maxRetries = 3) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const proxyUrl = proxyList[attempt % proxyList.length];
const agent = new ProxyAgent(proxyUrl);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
const res = await fetch(url, { dispatcher: agent, signal: controller.signal });
clearTimeout(timer);
if (res.status >= 500) throw new Error(`服务器错误: ${res.status}`);
return res;
} catch (err) {
clearTimeout(timer);
lastError = err;
const delay = 500 * 2 ** attempt;
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error(`所有尝试已用尽: ${lastError?.message}`);
}
这样的包装同时解决了错误时的轮换问题:如果当前代理断开,下一次尝试会自动转到列表中的其他 IP,而不是继续尝试同一个死节点。
undici ProxyAgent、https-proxy-agent 和 socks-proxy-agent 的比较
| 工具 | 依赖 | SOCKS5 支持 | HTTP/2 | 速度 |
|---|---|---|---|---|
| undici ProxyAgent | 无(内置于 Node 18+) | 无开箱即用 | 是 | 高 |
| https-proxy-agent | 1-2 个包 | 无 | 无 | 中等 |
| socks-proxy-agent | 3-4 个包 | 是 | 无 | 中等 |
对于纯 HTTP/HTTPS 解析,依赖最少的情况下,undici ProxyAgent 由于其内置性和与 HTTP/2 的兼容性而占优势。对于生产环境中的 SOCKS5,使用经过验证的 socks-proxy-agent 更为简单,而不是维护自定义握手 — 上述部分的手动实现适合学习和少数边缘案例,但不适合每小时有数千个连接的繁重生产环境。
实际案例:解析和地理测试
市场价格监控。 使用 undici 的脚本,结合轮换代理和上述重试逻辑,完美适合绕过 Wildberries 和 Ozon 的速率限制:每个后续请求的商品卡片都使用新的 IP,而健康检查会自动排除被封禁的地址。
按地理位置测试广告创意。 如果需要检查特定国家用户的内容交付情况,对于 Google Ads 或 Facebook Ads,只需在 ProxyAgent 中更换代理 URL 到所需区域的节点 — 如果通过 API 或服务器的 HTML 响应进行检查,则无需启动任何浏览器。
检查不同国家的内容可用性。 对于根据 IP 提供不同内容的网站,住宅连接对于目标服务器来说与普通用户没有区别,这降低了与数据中心范围相比被封锁的风险。此场景对于 移动代理 特别相关,如果目标服务检查网络类型并阻止明显的数据中心子网。
大规模检查 API 端点。 在通过外部代理池对自己的 API 进行压力测试时,重要的是监控连接池 — undici 默认在一个 ProxyAgent 内重用 TCP 连接,这在对同一主机进行数百个连续请求时显著节省了 TLS 握手的时间。
结论
内置的 undici ProxyAgent 解决了 Node.js 中大多数与 HTTP/HTTPS 代理相关的任务,无需引入第三方包:通过 URL 进行身份验证、通过 CONNECT 隧道、与 HTTP/2 的兼容性和连接池功能都可以立即使用。代理的轮换和健康检查可以轻松通过纯 Promise 和定时器构建,而 SOCKS5 在必要时可以通过自定义连接器手动实现,尽管对于生产环境,最好使用专门针对 SOCKS 的现成库。
如果您的 undici 脚本用于解析市场或监控价格,请注意 住宅代理 — 它们比传统的数据中心 IP 更能抵御反机器人系统,并且在对 Wildberries、Ozon 或 Avito 的大量请求中更少需要额外的验证码解决方案。