Node.js 爬虫实战:带代理的礼貌网页抓取
用 Node.js 做网页抓取:一个经过测试的 undici + cheerio 爬虫,带代理轮换、按站点限并发、遵守 Retry-After 的退避、robots.txt 检查和 JSONL 输出。
用 Node.js 写爬虫需要三样东西:一个能走代理的 HTTP 客户端(undici 的 fetch 加 ProxyAgent),一个 HTML 解析器(cheerio),以及让爬虫不招人烦的规矩。这些规矩是:检查 robots.txt、按站点限制并发、遵守 Retry-After 的退避,以及网站一出验证页面就立刻停下。
本文只写一个文件 scrape.mjs,把上面这些都做到,并且每行输出一个 JSON 对象。我们让它通过一个需要登录的本地代理,对一个本地测试网站完整跑了一遍,结果放在文末。这里没有针对某个特定网站的内容:选择器对应的是一个简单的示例页面,换成你自己的就行。
准备工作
- Node.js 22 或更新版本。当前的 undici 版本不支持更老的 Node。
- 三个依赖包,固定为我们测试过的版本:
npm i [email protected] [email protected] [email protected]
- 控制台服务页面上的
HOST、PORT、USERNAME和PASSWORD。没有所有人共用的网关地址,所以每段代码都用这些占位符。 - 一个你有权抓取的网站。下面的
SITE_DOMAIN代表它的域名。开始之前先读它的服务条款;界线通常在哪里,见使用代理合法吗?。
把下面六段代码按顺序粘进同一个文件,命名为 scrape.mjs。
第一步:参数设置
import { createWriteStream } from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';
import * as cheerio from 'cheerio';
import robotsParser from 'robots-parser';
import { fetch, ProxyAgent } from 'undici';
const SITE = 'SITE_DOMAIN';
const START_URL = `https://${SITE}/products?page=1`;
const PROXIES = ['http://USERNAME:PASSWORD@HOST:PORT'];
const NEW_TUNNEL_PER_REQUEST = true;
const USER_AGENT = 'my-price-notes/1.0 (+mailto:[email protected])';
const PER_HOST = 2;
const MIN_GAP_MS = 1000;
const MAX_ATTEMPTS = 4;
const MAX_WAIT_MS = 120_000;
const MAX_PAGES = 50;
const OUTPUT = 'products.jsonl';
const RETRY_STATUS = new Set([429, 500, 502, 503, 504]);
const CHALLENGE = /captcha|verify you are human|unusual traffic/i;
PER_HOST 是同一个网站同时允许进行的请求数,MIN_GAP_MS 是发往同一网站的两次请求之间的最短间隔。MAX_WAIT_MS 是爬虫愿意等待的最长 Retry-After,超过它就当天收工。User-Agent 写明你的爬虫名字和联系方式,这样做既坦诚,也是 robots.txt 规则所匹配的对象。
第二步:在 Node.js 里轮换代理
class Blocked extends Error {}
let stopReason = null;
function stop(message) {
stopReason ??= message;
return new Blocked(stopReason);
}
const agents = new Map();
let turn = 0;
function pickProxy() {
const proxy = PROXIES[turn++ % PROXIES.length];
if (NEW_TUNNEL_PER_REQUEST) return new ProxyAgent(proxy);
if (!agents.has(proxy)) agents.set(proxy, new ProxyAgent(proxy));
return agents.get(proxy);
}
Blocked 标记那些应该让整个任务停下的错误,stop 记下第一个原因,之后的每个请求都能看到它。
轮换方式取决于产品。设为 Randomize IP 的住宅代理服务只给你一个入口,每条新连接换一个出口 IP。问题在于 ProxyAgent 会保持隧道打开并重复使用。在我们的测试里,一个 agent 通过同一个 CONNECT 承载了七个请求,其中三个是并行的;在轮换服务上,这就意味着只用了一个出口 IP。所以当 NEW_TUNNEL_PER_REQUEST = true 时,代码为每个请求新建一个 agent,用完就关。测试代理随后记录到的是每个请求一次 CONNECT。
ISP 和数据中心服务是一组静态 IP,轮换要你自己来做。把每个 IP 列出来,并关掉新隧道:
const PROXIES = [
'http://USERNAME:PASSWORD@HOST_1:PORT_1',
'http://USERNAME:PASSWORD@HOST_2:PORT_2',
'http://USERNAME:PASSWORD@HOST_3:PORT_3',
];
const NEW_TUNNEL_PER_REQUEST = false;
这样每个 IP 都有一个长期保持的 agent,请求在它们之间轮流分配。用三个测试代理时,整次运行里每个代理都只记录到一次 CONNECT。如果密码里有 @、: 或 /,先用 encodeURIComponent 包一下。ProxyAgent 的基础用法见 Node.js 教程,axios 的写法见 axios 教程;什么时候反而需要地址保持不变,见轮换代理与粘性代理。
第三步:按站点限制并发
function hostLimiter(max, gapMs) {
let active = 0;
let nextStart = 0;
const waiting = [];
return async (task) => {
if (active < max) active++;
else await new Promise((resolve) => waiting.push(resolve));
try {
const start = Math.max(Date.now(), nextStart);
nextStart = start + gapMs;
await sleep(start - Date.now());
return await task();
} finally {
const next = waiting.shift();
if (next) next();
else active--;
}
};
}
const limiters = new Map();
function limitFor(url, gapMs) {
const host = new URL(url).host;
if (!limiters.has(host)) limiters.set(host, hostLimiter(PER_HOST, gapMs));
return limiters.get(host);
}
这是一个带间隔规则的小信号量,不需要额外安装包。请求先等一个空闲名额,再等到距离上一次发往同一网站的请求至少过了 gapMs。名额空出来时会直接交给下一个排队的请求,所以即使请求一下子涌来,上限也守得住。
把间隔设为零时,我们的测试网站同一时刻最多只见到两个请求。间隔设为一秒时,它每秒只见到一个新请求,建议保持这个设置。针对某个网站怎么找到安全的数字,见每个代理开多少线程。
第四步:遵守 Retry-After 的重试
function retryAfterMs(value) {
if (!value) return null;
const seconds = Number(value);
if (Number.isFinite(seconds)) return seconds * 1000;
const date = Date.parse(value);
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}
const backoff = (attempt) => 1000 * 2 ** attempt * (0.5 + Math.random());
function rootCause(error) {
while (error.cause) error = error.cause;
return error.message;
}
async function get(url) {
const dispatcher = pickProxy();
try {
const response = await fetch(url, {
dispatcher,
headers: { 'user-agent': USER_AGENT, accept: 'text/html,text/plain' },
signal: AbortSignal.timeout(30_000),
});
return { status: response.status, headers: response.headers, body: await response.text() };
} finally {
if (NEW_TUNNEL_PER_REQUEST) await dispatcher.close();
}
}
async function fetchPage(url) {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
if (stopReason) throw new Blocked(stopReason);
let problem;
let wait;
try {
const { status, headers, body } = await get(url);
const isHtml = (headers.get('content-type') ?? '').includes('html');
if (status === 403 || (isHtml && CHALLENGE.test(body))) throw stop(`challenge or 403 at ${url}`);
if (status >= 200 && status < 300) return body;
if (!RETRY_STATUS.has(status)) return null;
problem = `HTTP ${status}`;
wait = retryAfterMs(headers.get('retry-after')) ?? backoff(attempt);
} catch (error) {
if (error instanceof Blocked) throw error;
problem = rootCause(error);
wait = backoff(attempt);
}
if (attempt === MAX_ATTEMPTS) break;
if (wait > MAX_WAIT_MS) throw stop(`asked to wait ${Math.round(wait / 1000)} s at ${url}`);
console.warn(`${problem} on ${url}, retrying in ${Math.round(wait / 1000)} s`);
await sleep(wait);
}
throw new Error(`gave up on ${url} after ${MAX_ATTEMPTS} attempts`);
}
每种结果的处理方式:
- 2xx: 返回页面。
- 403,或者看起来像验证码、“verify you are human” 的 HTML 页面: 停止整个任务。继续请求只会越陷越深,先改点什么再说。
- 429、500、502、503、504,或网络错误: 等一会儿再试,最多
MAX_ATTEMPTS次。网站发了Retry-After的,就按它说的等,不管是秒数还是日期。没发的,每次等待时间翻倍,并加一点随机,免得并行的请求同时重试。 Retry-After比MAX_WAIT_MS还长: 停止。网站让你十分钟后再来,意思就是今天到此为止。- 其他情况,比如 404: 跳过这个网址,继续往下。
rootCause 从 undici 的 fetch failed 里挖出真正的原因,比如代理拒绝登录时的 Proxy response (407) !== 200 when HTTP Tunneling。大多数重试背后的限流原理,见 429 错误怎么解决。费用方面,我们的诚信页面写道:“连接失败(超时、连接重置,以及我们自己网关返回的错误,比如 407 或 502)计零,但照样显示出来,标成免费。”网站发回来的 429 或 5xx 页面算流量,所以重试一次既花时间,也花那个响应的字节。停止规则比重试次数更重要。
第五步:检查 robots.txt
async function loadRobots(origin) {
const url = `${origin}/robots.txt`;
return robotsParser(url, (await fetchPage(url)) ?? '');
}
robots-parser 在开始时读一次网站规则。isAllowed 按网址回答能不能抓,getCrawlDelay 的值会用在第六步的间隔里,所以要求慢一点的网站会得到慢一点的抓取。按照标准,没有 robots.txt 就当作“全部允许”。如果 robots.txt 本身返回 403 或验证页面,任务在开始前就会停下。
第六步:用 cheerio 解析,写入 JSONL
function parseProduct(html, url) {
const $ = cheerio.load(html);
return {
url,
title: $('h1').first().text().trim(),
price: $('.price').first().text().trim() || null,
scrapedAt: new Date().toISOString(),
};
}
async function main() {
const robots = await loadRobots(new URL(START_URL).origin);
const gap = Math.max(MIN_GAP_MS, (robots.getCrawlDelay(USER_AGENT) ?? 0) * 1000);
const allowed = (url) => robots.isAllowed(url, USER_AGENT) !== false;
const out = createWriteStream(OUTPUT, { flags: 'a' });
const seen = new Set();
let saved = 0;
let pageUrl = START_URL;
let pages = 0;
try {
while (pageUrl && allowed(pageUrl) && pages++ < MAX_PAGES) {
const html = await limitFor(pageUrl, gap)(() => fetchPage(pageUrl));
if (!html) break;
const $ = cheerio.load(html);
const links = $('a.product-link')
.map((_, a) => new URL($(a).attr('href'), pageUrl).href)
.get()
.filter((link) => allowed(link) && !seen.has(link));
links.forEach((link) => seen.add(link));
const results = await Promise.allSettled(
links.map(async (link) => {
const productHtml = await limitFor(link, gap)(() => fetchPage(link));
if (!productHtml) return;
out.write(JSON.stringify(parseProduct(productHtml, link)) + '\n');
saved++;
}),
);
for (const { status, reason } of results) {
if (status === 'fulfilled') continue;
if (reason instanceof Blocked) throw reason;
console.warn(`Skipped: ${reason.message}`);
}
const next = $('a[rel="next"]').attr('href');
pageUrl = next ? new URL(next, pageUrl).href : null;
}
} finally {
out.end();
console.log(`Saved ${saved} products to ${OUTPUT}`);
}
}
try {
await main();
} catch (error) {
console.error(`Stopped: ${error.message}`);
process.exitCode = 1;
} finally {
await Promise.all([...agents.values()].map((agent) => agent.close()));
}
抓取过程沿着 rel="next" 翻列表页,收集商品链接,去掉 robots.txt 不允许的和已经见过的,再通过限流器抓取商品页。每个商品成为 products.jsonl 里的一行 JSON。JSONL 很适合爬虫:每一行自成一体,程序崩溃不会丢掉已经写入的内容,重跑时直接追加。Promise.allSettled 让同一页上的商品即使有一个失败,其余的也能跑完;而 Blocked 结果照样会让任务停下。
选择器(a.product-link、h1、.price)对应的是示例页面。在浏览器里打开你的目标网站,检查一个商品链接和一个价格元素,把这三个字符串改掉。
运行
node scrape.mjs
我们的测试网站让一个列表页返回 429、一个商品页返回 503,输出如下:
HTTP 429 on https://SITE_DOMAIN/products?page=2, retrying in 2 s
HTTP 503 on https://SITE_DOMAIN/products/5, retrying in 2 s
Saved 11 products to products.jsonl
products.jsonl 里的一行:
{"url":"https://SITE_DOMAIN/products/1","title":"Item 1","price":"3.50","scrapedAt":"2026-09-30T10:28:22.198Z"}
我们怎么测试的
我们在自己的机器上搭了一个小型 HTTPS 网站:三个列表页、十二个商品,robots.txt 禁止 /private/ 并要求一秒的抓取间隔。一个列表页对第一次请求返回 429 和 Retry-After: 2;一个商品页对第一次请求返回 503;一个商品是 404;一个列表页链接到被禁止的页面。所有流量都经过一个要求用户名和密码的本地代理。
- 爬虫保存了 11 个商品,跳过了 404,从未请求被禁止的页面。
- 它按 429 的要求整整等了两秒,并在退避后重试了 503。
- 住宅模式下,代理对每个请求都看到一次
CONNECT;静态列表模式下,每个 IP 一次。 - 在一个商品页上返回验证页面时,它以
Stopped: challenge or 403 at ...停下,退出码为 1,已经保存的四行仍留在文件里。 - 遇到
Retry-After: 600时,它停止任务,而不是干等十分钟。 - 代理密码错误时,robots.txt 请求报
Proxy response (407) !== 200 when HTTP Tunneling,重试用完后以Stopped: gave up on ...结束。
什么时候 Node.js 的 fetch 不够用
如果你要的数据是页面加载后由 JavaScript 画出来的,fetch 拿到的只是一个空壳,cheerio 没东西可解析。先看看浏览器开发者工具的网络面板,页面往往会调用一个 JSON 接口,它通常比 HTML 更好读。如果没有,就驱动一个真正的浏览器:Playwright 和 Puppeteer 教程都讲了怎么配代理。浏览器每个页面传输的数据多得多,按 GB 计费时这一点很重要。
常见问题
Node.js 网页抓取该用哪个 HTTP 客户端? 像本文这样用 undici 的 fetch 加 ProxyAgent,或者用 axios 加 https-proxy-agent。Node 自带的 fetch 没有接收代理 URL 的选项。
为什么我的轮换代理每次请求都是同一个 IP? 客户端复用了保持连接的隧道。在 Randomize IP 服务上,每个请求新建一个 ProxyAgent。
需要 p-limit 吗? 不需要。第三步那个小限流器对单个网站能做同样的事,还多了间隔规则。
cheerio 够用吗? 对于 HTML 一次就完整返回的页面,够用。由 JavaScript 生成的页面,用浏览器。
下一步
把 SITE_DOMAIN 换成一个你有权抓取的网站,把 MAX_PAGES 设为 2,先用小额充值跑一次,看看页面、节奏和流量,再决定要不要放大。各产品的价格见价格页面。输出里有看不懂的地方,就把它(去掉密码)贴到 Discord。