Node.js + Cheerio 代理采集完全指南:从轻量解析到万级并发

深入讲解 Node.js + Cheerio 服务端 HTML 采集:axios 代理轮换拦截器、p-limit 并发控制、断路器模式,助你高效稳定地采集万级 URL。

Node.js + Cheerio 代理采集完全指南:从轻量解析到万级并发

为什么选择 axios + Cheerio 做轻量采集

很多开发者一提到网页采集就搬出 Puppeteer 或 Playwright,但绝大多数场景下你并不需要一个完整的浏览器引擎。Cheerio 是 jQuery 核心选择器的服务端实现,它只做一件事——解析 HTML 并提供类 jQuery API 来提取数据。配合 axios 发请求,整个采集管线内存占用不到 Puppeteer 的 1/10,速度却快 5-20 倍。

如果你的目标站点返回的是服务端渲染(SSR)的完整 HTML,Cheerio + axios 就是最优解。本文将从零构建一条生产级采集管线:请求 → 代理轮换 → 解析 → 并发 → 错误恢复,并覆盖 Cheerio proxy 集成、axios proxy rotation 拦截器、万级 URL 并发采集等核心话题。

axios + Cheerio 基础架构

最简的采集循环只需两步:发请求拿 HTML,再用 Cheerio 加载并提取。下面这段代码是整条管线的骨架:

import axios from 'axios';
import * as cheerio from 'cheerio';

async function scrapeProduct(url) {
  const { data: html } = await axios.get(url, {
    timeout: 15_000,
    headers: {
      'User-Agent':
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
      'Accept-Language': 'en-US,en;q=0.9',
    },
  });
  const $ = cheerio.load(html);

  return {
    title: $('h1.product-title').text().trim(),
    price: $('span.price').first().text().trim(),
    availability: $('div.stock').text().trim(),
    images: $('img.product-image')
      .map((_, el) => $(el).attr('src'))
      .get(),
  };
}

Cheerio 的 load() 方法将原始 HTML 字符串解析为 DOM 树,之后你就能用 $() 选择器精确提取任何节点。不需要等 JS 执行,不需要渲染 CSS——纯字符串操作,速度极快。

代理集成:让 axios 走代理隧道

当你对同一站点发起大量请求时,IP 封禁几乎是必然的。将请求路由到住宅代理池是标准解法。axios 原生支持 proxy 配置,但只适用于 HTTP 代理;对于 HTTPS 目标站点,你需要 https-proxy-agentundici 的 ProxyAgent。

方式一:axios 原生 proxy 配置(仅 HTTP 目标)

const res = await axios.get('http://example.com/data', {
  proxy: {
    host: 'gate.proxyhat.com',
    port: 8080,
    auth: { username: 'user-country-US', password: 'PASSWORD' },
  },
});

方式二:https-proxy-agent(支持 HTTPS 目标)

import { HttpsProxyAgent } from 'https-proxy-agent';

const agent = new HttpsProxyAgent(
  'http://user-country-US:PASSWORD@gate.proxyhat.com:8080'
);

const res = await axios.get('https://example.com/data', {
  httpsAgent: agent,
  timeout: 15_000,
});

方式二是更通用的方案,因为绝大多数现代站点都走 HTTPS。https-proxy-agent 会为每个请求建立一条 CONNECT 隧道,TLS 握手在代理服务器之后完成,目标站点看到的是代理 IP 而非你的真实 IP。

关键点:ProxyHat 支持在用户名中嵌入国家/城市/会话标识。例如 user-country-US-city-newyork-session-abc123 会锁定纽约住宅 IP,整个会话期间 IP 不变;去掉 session 标识则每次请求自动轮换 IP。

Cheerio 够用吗?静态 HTML vs 动态渲染

这是采集架构最核心的决策点。下表帮你快速判断:

特征 Cheerio + axios Puppeteer / Playwright
目标站点类型 SSR、静态 HTML、传统 MPA SPA、重度 JS 渲染、无限滚动
内存占用 ~30 MB/进程 ~200-500 MB/标签页
单页耗时 0.5-2 秒(含网络) 3-15 秒(含渲染)
并发能力(单机) 100-500 请求/秒 5-20 页面/秒
反检测能力 依赖代理 IP 轮换 代理 + 浏览器指纹伪装
适用场景 电商商品页、新闻、SERP、API 端点 动态加载评论、地图数据、交互式图表

判断技巧:在浏览器中禁用 JavaScript,刷新页面。如果核心数据仍然可见,Cheerio 就够用。如果页面变成空白或骨架屏,说明数据是 JS 动态渲染的,需要无头浏览器。

对于电商商品页、价格页、新闻列表等常见采集目标,Cheerio + 住宅代理的组合已经能覆盖 80% 以上的场景。更深入的场景分析可参考 网页采集用例

旋转代理池:封装为 axios 拦截器

手动为每个请求配置代理太脆弱。更好的做法是写一个 axios 请求拦截器,在每次请求发出前自动注入轮换后的代理配置。这样你的业务代码完全不需要关心代理逻辑。

import axios from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';

// ---- 代理池配置 ----
const PROXY_POOL = [
  'http://user-country-US:PASSWORD@gate.proxyhat.com:8080',
  'http://user-country-DE:PASSWORD@gate.proxyhat.com:8080',
  'http://user-country-GB:PASSWORD@gate.proxyhat.com:8080',
  'http://user-country-JP:PASSWORD@gate.proxyhat.com:8080',
];

let proxyIndex = 0;

function getNextProxy() {
  // Round-robin 轮换;也可改为随机选取
  const proxy = PROXY_POOL[proxyIndex % PROXY_POOL.length];
  proxyIndex++;
  return proxy;
}

// ---- axios 拦截器 ----
const client = axios.create({ timeout: 15_000 });

client.interceptors.request.use((config) => {
  const proxyUrl = getNextProxy();
  config.httpsAgent = new HttpsProxyAgent(proxyUrl);
  config.httpAgent = new HttpsProxyAgent(proxyUrl);
  // 随机 User-Agent 增强隐蔽性
  config.headers = config.headers ?? {};
  config.headers['User-Agent'] = randomUserAgent();
  return config;
});

function randomUserAgent() {
  const agents = [
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_1) AppleWebKit/605.1.15',
    'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
  ];
  return agents[Math.floor(Math.random() * agents.length)];
}

export { client };

使用时只需 client.get(url),代理轮换完全透明。如果你使用 ProxyHat 的住宅代理,更简单的做法是在用户名中去掉 session- 标识,这样每次请求自动分配不同出口 IP,无需维护代理列表:

// 单个入口,每次请求自动轮换 IP
const AUTO_ROTATE_PROXY =
  'http://user:PASSWORD@gate.proxyhat.com:8080';

client.interceptors.request.use((config) => {
  config.httpsAgent = new HttpsProxyAgent(AUTO_ROTATE_PROXY);
  config.httpAgent = new HttpsProxyAgent(AUTO_ROTATE_PROXY);
  return config;
});
最佳实践:对于需要登录态或购物车操作的采集任务,使用 session-xxx 锁定 IP;对于批量商品页采集,去掉 session 标识让 ProxyHat 自动轮换,省去自建轮换逻辑的麻烦。

并发控制:p-limit 实战

Node.js 的异步 I/O 天然适合并发请求,但无限制并发会打爆目标服务器也浪费代理资源。p-limit 是最轻量的并发限制库,核心 API 只有一个函数:

import pLimit from 'p-limit';

const limit = pLimit(50); // 最多 50 个并发请求

async function scrapeAll(urls) {
  const tasks = urls.map((url) =>
    limit(() => scrapeProduct(url).catch((err) => {
      console.error(`Failed: ${url}`, err.message);
      return null; // 标记失败,不中断整体流程
    }))
  );
  const results = await Promise.all(tasks);
  return results.filter(Boolean);
}

pLimit(50) 创建一个信号量,保证任意时刻最多 50 个请求在飞。比 Promise.all 的全量并发安全得多,也比串行 for...of 快几十倍。

如果你的任务需要优先级、重试策略或持久化队列,可以升级到 p-queue,它基于 p-limit 但增加了事件回调和并发动态调整能力。

万级 URL 采集实战:10k 商品页全量提取

下面是一个完整的、可用于生产的采集脚本。它整合了代理轮换拦截器、p-limit 并发控制、断路器和指数退避重试。

import axios from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';
import * as cheerio from 'cheerio';
import pLimit from 'p-limit';
import { createWriteStream } from 'fs';

// ---- 配置 ----
const CONCURRENCY = 50;
const MAX_RETRIES = 3;
const CIRCUIT_THRESHOLD = 10; // 连续失败次数触发断路
const PROXY_URL = 'http://user:PASSWORD@gate.proxyhat.com:8080';
const TARGET_LIST = 'https://shop.example.com/sitemap-products.xml';

// ---- 断路器状态 ----
let consecutiveFailures = 0;
let circuitOpen = false;
let circuitOpenUntil = 0;

function checkCircuit() {
  if (!circuitOpen) return;
  if (Date.now() >= circuitOpenUntil) {
    circuitOpen = false;
    consecutiveFailures = 0;
    console.log('[Circuit] 恢复正常');
  } else {
    throw new Error('Circuit open — 暂停请求');
  }
}

function recordSuccess() {
  consecutiveFailures = 0;
}

function recordFailure() {
  consecutiveFailures++;
  if (consecutiveFailures >= CIRCUIT_THRESHOLD) {
    circuitOpen = true;
    circuitOpenUntil = Date.now() + 60_000; // 冷却 60 秒
    console.warn('[Circuit] 触发断路,冷却 60s');
  }
}

// ---- axios 实例 + 代理拦截器 ----
const client = axios.create({ timeout: 15_000 });

client.interceptors.request.use((config) => {
  config.httpsAgent = new HttpsProxyAgent(PROXY_URL);
  config.httpAgent = new HttpsProxyAgent(PROXY_URL);
  return config;
});

// ---- 核心采集函数 ----
async function scrapeProduct(url, retryCount = 0) {
  checkCircuit();
  try {
    const { data: html } = await client.get(url);
    const $ = cheerio.load(html);
    const result = {
      url,
      title: $('h1').text().trim(),
      price: $('[data-price]').first().text().trim(),
      inStock: $('span.availability').text().includes('In Stock'),
    };
    recordSuccess();
    return result;
  } catch (err) {
    recordFailure();
    const status = err.response?.status;
    // 403/429 → 指数退避重试
    if ((status === 403 || status === 429) && retryCount < MAX_RETRIES) {
      const delay = Math.pow(2, retryCount) * 1000 + Math.random() * 1000;
      console.warn(`Retry ${retryCount + 1}/${MAX_RETRIES} after ${Math.round(delay)}ms — ${url}`);
      await new Promise((r) => setTimeout(r, delay));
      return scrapeProduct(url, retryCount + 1);
    }
    console.error(`Skip: ${url} — ${err.message}`);
    return null;
  }
}

// ---- 主流程 ----
async function main() {
  // 1. 从 sitemap 拉取全部商品 URL
  const { data: sitemapXml } = await client.get(TARGET_LIST);
  const $xml = cheerio.load(sitemapXml, { xmlMode: true });
  const urls = $xml('url > loc').map((_, el) => $xml(el).text()).get();
  console.log(`共 ${urls.length} 个 URL`);

  // 2. 并发采集
  const limit = pLimit(CONCURRENCY);
  const results = [];
  const stream = createWriteStream('./products.ndjson', { flags: 'w' });

  const tasks = urls.map((url) =>
    limit(async () => {
      const data = await scrapeProduct(url);
      if (data) {
        stream.write(JSON.stringify(data) + '\n');
        results.push(data);
      }
    })
  );
  await Promise.all(tasks);
  stream.end();
  console.log(`完成:${results.length}/${urls.length}`);
}

main().catch(console.error);

这个脚本有几个值得注意的设计决策:

  • NDJSON 流式写入:每条结果立刻写盘,进程崩溃也不丢已完成的数据。
  • 断路器:连续 10 次失败后暂停 60 秒,避免在目标站点全面封禁时代理资源空转。
  • 指数退避 + 随机抖动:403/429 时退避时间翻倍并加随机偏移,避免所有并发请求同时重试(雷群效应)。
  • 代理自动轮换:ProxyHat 住宅代理在用户名不含 session 标识时,每次请求自动分配新 IP,配合指数退避天然形成 IP 轮换 + 重试机制。

错误处理深度策略

403 Forbidden 与 429 Too Many Requests

这两个状态码含义不同但应对策略相似:退避 + 换 IP。403 通常是 WAF 或 rate limiter 拦截,429 是显式限速响应。在拦截器层面,你可以统一处理:

client.interceptors.response.use(
  (response) => response,
  async (error) => {
    const config = error.config;
    const status = error.response?.status;
    if ((status === 403 || status === 429) && !config.__retried) {
      config.__retried = true;
      // 换一个代理 IP 重试
      config.httpsAgent = new HttpsProxyAgent(PROXY_URL);
      config.httpAgent = new HttpsProxyAgent(PROXY_URL);
      await new Promise((r) => setTimeout(r, 2000));
      return client.request(config);
    }
    return Promise.reject(error);
  }
);

将重试逻辑放在响应拦截器中,业务代码完全无感知。这比在每个 scrapeProduct 函数里写 try/catch 干净得多。

断路器模式

断路器的核心思想是:在系统明显异常时停止请求,而不是盲目重试。三种状态:

  • Closed(关闭):正常请求,失败计数累加。
  • Open(打开):失败数超过阈值,所有请求立即失败,持续冷却期。
  • Half-Open(半开):冷却期结束后放行一个试探请求,成功则关闭断路器,失败则重新打开。

上文的 checkCircuit() 实现了 Closed 和 Open 两个状态。如需 Half-Open,可在冷却期结束后放行一个请求并观察结果。生产环境中推荐使用 opossum 等成熟库。

超时与网络错误

代理链路会增加延迟。设置合理的超时(建议 15-30 秒),并对 ECONNRESETETIMEDOUTENOTFOUND 等网络错误做统一重试。这些错误通常是代理节点临时不可用,换 IP 重试即可恢复。

规模化:容器化与分布式

单机并发 50-100 已经能覆盖大多数采集需求。但如果你需要处理数十万 URL 或要求分钟级延迟,需要考虑横向扩展:

  • 容器化:将采集脚本打包为 Docker 镜像,每个容器跑一个独立进程,通过环境变量注入代理凭据和并发数。
  • 任务分片:用 Redis 或消息队列(如 BullMQ)分发 URL 分片给多个 worker,每个 worker 处理自己的子集。
  • 结果汇聚:所有 worker 写入同一个 S3/GCS 存储桶或数据库,最后做去重和校验。

ProxyHat 定价页 可以根据并发需求选择合适的流量套餐;全球代理节点 覆盖 190+ 国家,确保每个分片都能匹配目标区域。

关键要点

  • Cheerio + axios 是轻量采集的最优组合——仅当目标站点依赖 JS 渲染时才需要无头浏览器。
  • 代理轮换 应封装为 axios 拦截器,业务代码零耦合;ProxyHat 住宅代理在用户名中去掉 session 标识即可自动轮换 IP。
  • p-limit 控制并发上限,比 Promise.all 安全、比串行快得多。
  • 断路器 + 指数退避 是应对 403/429 的标准模式,避免代理资源空转。
  • 流式写入 NDJSON 保证万级采集任务在崩溃时也能保留已完成数据。
  • 大规模场景下用 Docker + BullMQ + S3 做分布式分片。

需要更多采集场景参考?查看 SERP 追踪用例网页采集用例 了解 ProxyHat 在不同业务中的实践方案。

准备开始了吗?

通过AI过滤访问148多个国家的5000多万个住宅IP。

查看价格住宅代理
← 返回博客