为什么选择 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-agent 或 undici 的 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 秒),并对 ECONNRESET、ETIMEDOUT、ENOTFOUND 等网络错误做统一重试。这些错误通常是代理节点临时不可用,换 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 做分布式分片。






