实操教程2026年9月29日阅读约 10 分钟

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。

还有后续问题?

去 Discord 问。答案还能帮到下一个读到这个帖子的人。

加入 Discorddiscord.gg/proxypanda
$5 起步去 Discord 问