在 Node 中让单个请求通过代理是一个十行代码就能搞定的问题。所有教程都止步于此,然后项目开始成长:第二个 HTTP 客户端出现了,凭证被硬编码在三个文件里,有人需要用德国而不是美国的出口,而没有人能说清楚这些请求究竟是从哪里发出去的。
这是一份配置指南,而不是语法指南。如果你需要的是每个客户端所期望的确切代理配置,包括为什么 Axios 需要在使用 agent 的同时设置 proxy: false,这些内容已经在在 Node.js 中使用 Axios 和 Got 配置住宅代理中详细讲解过了。下面要讲的是如何组织一个项目,使其在成长过程中保持接线正确。
明确你实际支持哪些客户端
Node 中常用的 HTTP 客户端有三种,它们接入代理的机制各不相同。在存在了一段时间的代码库中同时支持这三种是常态,但这应该是一个决定,而不是意外。
Axios 需要显式的 agent,因为它自身的 proxy 选项并不会按人们期望的方式处理 HTTPS 隧道。需要安装 https-proxy-agent。
Got 通过 agent.https 这个位置接收 agent,而不是顶层选项。
原生 fetch 通过 undici 运作,因此它需要将 ProxyAgent 作为 dispatcher。undici 随 Node 一起发布,但如果你要自行构造 dispatcher,最好将其作为显式依赖。
由此产生的实际后果是,你的依赖列表由你保留哪些客户端来决定,而保留两个客户端就意味着要维护两条代理路径。大多数代码库最好统一使用一种客户端并转换掉其余的,而那些确实需要两种客户端的项目至少应该清楚原因。
凭证属于环境变量,而不是源代码
网关通过用户名接收目标定位信息,这意味着凭证字符串和路由配置是同一个字符串。这很方便,但这也正是代理凭证最终被提交到代码仓库的原因。
在配置中将这些部分分开保存:
PROXY_HOST=p.shifter.ioPROXY_PORT=443PROXY_USER=customer-USERNAMEPROXY_PASS=your-passwordPROXY_COUNTRY=us然后在运行时组装用户名。能在日后为你省下麻烦的规则是:除了你的代理模块之外,任何文件都不应包含字面写出的代理 URL。一旦带有嵌入式定位信息的凭证字符串出现在某个爬虫代码里,它就会被复制,而这些副本会逐渐产生偏差。
不要把密码放进你会记录日志的 URL 里。代理 URL 是凭证泄露到日志聚合系统最常见的途径,因为最自然的调试写法就是把整个 URL 打印出来。
一个模块负责构建所有 agent
最重要的结构性决定是:只在一个地方把配置转换成 agent。这个模块很小,却能防止一大类问题。
import { HttpsProxyAgent } from "https-proxy-agent";
const { PROXY_HOST, PROXY_PORT, PROXY_USER, PROXY_PASS } = process.env;
export function proxyUrl({ country, session, ttl } = {}) { const parts = [PROXY_USER]; if (country) parts.push("country", country); if (session) parts.push("sid", session); if (session && ttl) parts.push("ttl", String(ttl)); const user = parts.join("-"); return `https://${user}:${PROXY_PASS}@${PROXY_HOST}:${PROXY_PORT}`;}
export function agentFor(opts) { return new HttpsProxyAgent(proxyUrl(opts));}这段代码中有两个细节值得明确说明。定位标志是根据命名选项构建的,而不是在每个调用点拼接字符串,这正是防止标志名拼写错误变成看起来像凭证问题的 407 错误的关键。而 ttl 只有在存在 session 时才会被添加,因为没有会话标识符,存活时间就没有意义。
调用方之后就永远不会看到 URL:
const agent = agentFor({ country: "de", session: "job-141", ttl: 600 });
// axiosawait axios.get(url, { httpsAgent: agent, proxy: false });
// gotawait got(url, { agent: { https: agent } });原生 fetch 是唯一不接受 agent 的,因为 undici 需要的是 dispatcher:
import { ProxyAgent, fetch } from "undici";import { proxyUrl } from "./proxy.js";
const dispatcher = new ProxyAgent(proxyUrl({ country: "de" }));await fetch(url, { dispatcher });将 proxyUrl 单独导出,正是让 fetch 路径能够与另外两个共享配置而不必重复构建字符串的关键。
复用 agent,并提前决定会话策略
一个 agent 持有一个连接池。每次请求都新建一个 agent,就是每次都把这个连接池扔掉,这会表现为延迟增加,而你很可能会把它误诊为网络问题。
针对你使用的每种配置只构建一次 agent,并将其缓存起来。如果你要轮换五个国家,那就意味着在进程的整个生命周期中持有五个 agent,而不是每个请求都新建一个。
会话是与之相关的另一个决定。没有会话标识符时,网关会进行轮换,这正是独立请求所需要的。有了 sid,你就得到一个粘性 IP,在你指定的 ttl 时间内保持不变,这正是任何需要连续性的场景所需要的:分页结果集、多步骤流程,以及任何第二个请求需要与第一个请求来自同一位置的场景。如果不设置,默认的粘性时间是 120 秒。
在配置阶段就做出这个选择,而不是在每个调用点上做,是拥有一套连贯的轮换策略和一个一半请求碰巧是粘性的代码库之间的区别。这些权衡在粘性代理与轮换住宅代理对比中有详细说明。
在依赖它构建之前先验证
在写爬虫之前,先写验证脚本。这只需要五分钟,却能把整整一类未来的困惑变成一个立即可知的答案。
import { agentFor } from "./proxy.js";import axios from "axios";
const agent = agentFor({ country: "de" });const { data } = await axios.get("https://api.ipify.org?format=json", { httpsAgent: agent, proxy: false,});console.log(data);如果打印出来的是你自己的地址,那说明 agent 没有被正确接入,项目中的每个请求都在直连出站。这种状态很常见,可能持续好几天都不会被发现,因为一切看起来都在正常运行:代码能跑,只是没有用到代理。检查国家是否确实与你请求的一致是这项检查的另一半,正确的做法记录在测试代理速度、成功率和位置准确性中。
把它做成 package.json 里的一个脚本,这样任何人在遇到异常时都可以运行它。
一次性把错误映射清楚
代理错误具体到足以被自动诊断,而在一个地方统一处理这件事,远胜过在凌晨三点反复解读这些错误:
- 407 表示凭证有误,或某个定位标志格式不正确。拼错的标志会导致这个错误,这就是为什么在检查密码之前,先检查一下你的标志名称是值得的。
- 502 表示没有出口节点符合你的筛选条件。是筛选条件太窄,而不是网络出了故障。
- 509 表示带宽额度已用尽。
- 连接被拒绝 通常意味着遗留下来的旧主机或端口,来自更早的配置。
只有暂时性的失败才值得重试。循环重试 407 只会消耗你的速率限制去应对一个永远不会自行解决的问题,而重试 509 则完全无济于事。用真正的退避策略来包装重试逻辑,而不是固定延迟,具体做法在速率限制与请求节流中有讲解。
有意识地限制并发
Node 会很乐意同时发起一万个请求,而这三种客户端都不会阻止你这么做。在使用代理的情况下,这种情况比平常更糟,因为每一个请求都是一条需要建立的隧道。
从一开始就使用并发限制器,而不是等第一次事故发生之后再补上。适中的并发量配合稳定的吞吐量,胜过那种触发防御机制然后把时间都花在重试上的无限制突发流量。
开发、CI 和生产环境各不相同
三个实践性的注意事项,在每个引入代理的 Node 项目中都会出现。
住宅流量是按带宽计费的,所以一个通过代理访问真实目标的测试套件,只会带来反复出现的账单,却没有任何好处。在单元测试中模拟 HTTP 层,并将少量真实请求保留在一个单独的、手动触发的检查中。
本地开发应该使用与生产环境相同的模块和相同的环境变量,只是取值不同。只存在于生产环境中的配置,是没有人测试过的配置。
而容器镜像不应该内置凭证。在运行时传入凭证,和处理其他任何机密信息一样。
常见问题
如果我只使用 fetch,还需要 https-proxy-agent 吗?
不需要。undici 的 ProxyAgent 覆盖了那条路径。你只有在使用 Axios 和 Got 时才需要单独的 agent 软件包。
为什么我已经给 Axios 提供了 agent,它还需要 proxy: false?
因为否则 Axios 会试图在 agent 之上再套用自己的代理处理逻辑,而这两者无法协同工作。设置为 false 就是把整个工作完全交给 agent。
我可以按请求而不是按 agent 来设置定位信息吗?
可以,但每一种不同的配置对应一个不同的 agent,所以每个请求都新建一个,代价就是失去连接池。按配置键来缓存它们。
国家应该是配置值,还是每次调用时传入的参数?
实际上两者都需要。在配置中设一个默认值,针对需要特定地区的任务再按调用覆盖它。你要避免的是国家信息以字面值的形式出现在各个爬虫代码内部。
结论
Node 中的代理接线本身很小,也很好理解。真正决定一个项目能否保持可维护性的,是围绕它的组织方式:一个根据配置构建 agent 的模块,存放在环境变量中的凭证,复用而非重建的 agent,经过深思熟虑选定的会话策略,一个在爬虫之前就已存在的验证脚本,以及能够区分”筛选条件太窄”和”密码错误”的错误处理逻辑。
一次性搭建好这些,之后添加一个客户端库或一个新地区就只是一次配置变更。省略这一步,每一个新需求都会变成在代码库里搜索硬编码字符串。网关的详细信息见住宅代理页面,带宽费率见价格页面。