In this Article
本指南介绍如何使用Node.js进行JavaScript网页抓取。需要大规模收集公开数据时,许多工程师会选择Node.js作为运行时。要用JavaScript进行网页抓取,关键在于为页面选择合适的工具:静态HTML适合使用轻量级HTTP客户端,而由脚本加载的内容则需要无头浏览器。
下文提供适用于这两种情况、可直接运行的Node.js代码,并涵盖项目搭建、导出结构化JSON和CSV数据、分页、带退避的重试、速率限制、代理身份验证、XHR拦截,以及影响抓取程序长期稳定运行的道德规范和封锁问题。
DataImpulse是一家合乎道德规范的代理服务提供商,在195个国家提供超过9000万个住宅、移动和数据中心IP地址。它采用按量付费模式,每GB起价1美元,流量永不过期,可用于网页抓取、广告验证、价格监控、市场研究和多账号管理。
关键事实
- 两种工具链: 静态页面只需要一个HTTP客户端加上像fetch和cheerio这样的解析器,而在浏览器中渲染内容的动态页面则需要像Puppeteer或Playwright这样的无头引擎。
- 最佳代理类型: 轮换住宅代理,使用更接近真实用户的消费者IP,有助于通过检测。
- 价格: 每GB起价1美元,按量付费,流量永不过期,无需订阅。
- 覆盖范围: 覆盖195个国家,拥有超过9000万个符合道德规范来源的IP。
- 可靠性: 99.51%的成功率,在G2上获得5分中的4.8分评级。
- 协议与定位: HTTP、HTTPS和SOCKS5,包含国家定位。

什么是JavaScript网页抓取?
JavaScript网页抓取是指以编程方式请求网页,并使用JavaScript从中提取结构化数据的过程,通常在Node.js运行时中执行,而非在浏览器标签页中进行。
JavaScript是自然的选择,因为这门语言本身就围绕文档对象模型(Document Object Model)工作,前端开发者也熟悉如何选择元素。实际上,抓取程序只做三件事:通过HTTP获取页面,将返回的标记解析成可查询的树,再提取你关心的字段,例如标题、价格或链接。真正的复杂之处在于目标页面交付数据的方式,因此选择正确的工具往往比解析本身更重要。静态页面会在首次HTML响应中返回数据;动态页面则在加载后由浏览器组装数据,两者需要不同的工具链。
如何搭建Node.js抓取项目?
创建一个项目文件夹,用npm初始化它,并在编写任何抓取代码之前安装所需的库。
Node.js 18及更高版本内置了全局fetch,因此对于简单的GET请求你不需要HTTP库,不过许多团队仍然更喜欢axios,因为它有拦截器和代理处理功能。下面的命令搭建了一个项目结构,并安装了用于解析的cheerio、作为替代客户端的axios、用于并发控制的p-limit,以及用于动态页面的Puppeteer和Playwright:
mkdir js-scraper && cd js-scraper
npm init -y
npm install cheerio axios p-limit
npm install puppeteer playwright
# enable modern import syntax
npm pkg set type=module
设置type=module让你可以使用import语句。如果你更喜欢CommonJS,可以保留默认设置并改用require。本指南中的示例使用import语法,但每一个都直接对应一个require调用。安装完成后,你就拥有了在一个项目中进行静态和动态抓取所需的一切。
如何使用fetch和cheerio抓取静态页面?
对于静态页面,使用内置的fetch函数下载HTML,并用cheerio解析它,cheerio在服务端提供了类似jQuery的API。
静态页面在初始HTML响应中直接返回其内容,因此不需要浏览器渲染。这种方法快速且成本低,因为你只下载标记,而不下载图片、字体或脚本。下面的示例获取一个页面,将其加载到cheerio中,并读取每个产品卡片:
import * as cheerio from 'cheerio';
async function scrape(url) {
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; MyScraper/1.0)' }
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
const html = await res.text();
const $ = cheerio.load(html);
const items = [];
$('.product').each((i, el) => {
items.push({
title: $(el).find('h2').text().trim(),
price: $(el).find('.price').text().trim(),
link: $(el).find('a').attr('href')
});
});
return items;
}
console.log(await scrape('https://example.com/catalog'));
如果你更喜欢axios,解析部分是相同的,只有请求部分有所不同。axios会在非2xx状态码时自动抛出错误,并使代理配置变得简单,这就是为什么许多生产环境的抓取程序使用它:
import axios from 'axios';
import * as cheerio from 'cheerio';
const { data: html } = await axios.get('https://example.com/catalog', {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; MyScraper/1.0)' },
timeout: 15000
});
const $ = cheerio.load(html);
const titles = $('.product h2').map((i, el) => $(el).text().trim()).get();
console.log(titles);
在将抓取程序指向任何网站之前,值得确认目标网站是否允许这样做。我们关于如何检查网站是否允许抓取的指南详细介绍了robots.txt和服务条款。
如何将结构化数据导出为JSON和CSV?
将每条记录收集到一个普通对象的数组中,然后将该数组写入JSON文件,或将其扁平化为逗号分隔的行以生成CSV文件。
提取出的原始文本只有在以可预测的形式存储后才有用。JSON是最简单的目标,因为JavaScript数组可以直接序列化。下面的代码片段使用内置的文件系统模块将抓取到的项目写入磁盘:
import { writeFile } from 'node:fs/promises';
const items = await scrape('https://example.com/catalog');
await writeFile('products.json', JSON.stringify(items, null, 2), 'utf-8');
console.log(`Saved ${items.length} records to products.json`);
当数据要输入电子表格或数据仓库时,CSV更合适。你可以在不使用任何库的情况下,通过转义引号并连接字段来构建它,不过转义很重要,因为价格和标题中可能包含逗号:
import { writeFile } from 'node:fs/promises';
function toCsv(rows) {
const headers = Object.keys(rows[0]);
const escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
const lines = [headers.join(',')];
for (const row of rows) {
lines.push(headers.map((h) => escape(row[h])).join(','));
}
return lines.join('\n');
}
const items = await scrape('https://example.com/catalog');
await writeFile('products.csv', toCsv(items), 'utf-8');
在多次运行之间保持字段名称稳定,这样当页面布局发生轻微变化时,下游工具不会崩溃。如果还需要提取二进制资源,请参阅我们关于如何从网站抓取图片的指南。
如何通过分页抓取多个页面?
遍历网站的页面URL,或在每个页面上跟随下一页链接,逐一抓取,并在没有更多结果时停止。
大多数目录使用类似?page=2的查询参数将结果拆分为编号页面,或暴露一个下一页链接。最安全的模式是读取每个页面,收集其记录,然后寻找下一页,并在找不到时停止。下面的示例遍历编号页面,直到某个页面返回零个项目:
import * as cheerio from 'cheerio';
async function scrapeAll(base) {
const all = [];
for (let page = 1; page <= 50; page++) {
const res = await fetch(`${base}?page=${page}`, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; MyScraper/1.0)' }
});
if (!res.ok) break;
const $ = cheerio.load(await res.text());
const items = $('.product').map((i, el) => ({
title: $(el).find('h2').text().trim(),
price: $(el).find('.price').text().trim()
})).get();
if (items.length === 0) break;
all.push(...items);
await new Promise((r) => setTimeout(r, 1000));
}
return all;
}
console.log((await scrapeAll('https://example.com/catalog')).length);
注意页面之间一秒钟的暂停,以及50次迭代的硬性上限,这可以防止在网站永远不返回空页面的情况下出现失控循环。当内容是通过滚动而不是编号页面加载时,你需要一个无头浏览器,下面的Puppeteer部分将介绍这一点。
如何处理重试、退避和速率限制?
将每个请求包装在一个重试辅助函数中,该函数在每次失败后等待更长时间,并限制同时运行的请求数量,以保持在网站的容忍范围内。
网络并不可靠,服务器会返回临时错误,因此在第一次失败时就放弃的抓取程序会不必要地丢失数据。带有指数退避的重试辅助函数在每次尝试之间逐渐等待更长时间,这也给了被限速的服务器恢复的时间:
async function fetchWithRetry(fn, retries = 4, delay = 1000) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === retries) throw err;
const wait = delay * 2 ** (attempt - 1);
console.warn(`Attempt ${attempt} failed, retrying in ${wait}ms`);
await new Promise((r) => setTimeout(r, wait));
}
}
}
const html = await fetchWithRetry(async () => {
const res = await fetch('https://example.com/catalog');
if (res.status === 429) throw new Error('Rate limited');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
});
退避处理的是单个请求,但你还需要控制有多少请求并行运行。同时发出数百个并发请求会降低目标服务器的性能,并使你的流量被标记。p-limit库限制了并发数,使固定数量的worker处理一个URL队列:
import pLimit from 'p-limit';
const limit = pLimit(5); // at most 5 requests in flight
const urls = Array.from({ length: 100 }, (_, i) =>
`https://example.com/item/${i + 1}`
);
const results = await Promise.all(
urls.map((url) =>
limit(() => fetchWithRetry(async () => {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}))
)
);
console.log(`Fetched ${results.length} pages with a cap of 5 in parallel`);
退避和并发上限结合在一起,能让请求行为保持克制,并维持较高成功率。要了解更全面的检查清单,请参阅我们的网页抓取最佳实践指南。
如何使用Puppeteer抓取动态页面?
对于加载后使用JavaScript构建内容的动态页面,使用Puppeteer驱动无头Chrome,等待目标选择器,然后读取完全渲染的DOM。
许多现代网站会返回一个几乎为空的HTML外壳,然后通过后台请求获取数据。HTTP客户端看不到任何有用的内容,因此你需要一个能运行页面自身JavaScript的引擎。Puppeteer控制无头Chrome,可以等待元素、点击和滚动。下面的示例等待一个选择器,然后反复滚动以触发无限滚动加载,之后在浏览器上下文中提取数据:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.setUserAgent('Mozilla/5.0 (compatible; MyScraper/1.0)');
await page.goto('https://example.com/feed', { waitUntil: 'networkidle2' });
// wait until the first batch of cards is present
await page.waitForSelector('.product');
// infinite scroll: scroll to the bottom until height stops growing
let previousHeight = 0;
for (let i = 0; i < 20; i++) {
const height = await page.evaluate('document.body.scrollHeight');
if (height === previousHeight) break;
previousHeight = height;
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)');
await new Promise((r) => setTimeout(r, 1500));
}
const items = await page.evaluate(() =>
Array.from(document.querySelectorAll('.product')).map((el) => ({
title: el.querySelector('h2')?.innerText.trim(),
price: el.querySelector('.price')?.innerText.trim()
}))
);
console.log(`Extracted ${items.length} items`);
await browser.close();
一旦页面高度停止增加,循环就会停止,这表明没有新内容正在加载,而20次滚动的硬性上限可以防止无限循环。要了解更多关于这类目标的信息,请参阅我们关于如何抓取动态网页的指南。
如何使用Playwright替代Puppeteer?
Playwright的工作方式几乎与Puppeteer相同,但增加了对Chromium、Firefox和WebKit的一流支持,以及能减少不稳定选择器的自动等待定位器(locator)。
Playwright由部分Puppeteer的早期工程师创建,与Puppeteer共享大部分API,因此迁移通常只需改动少数调用名称。它的定位器会自动等待元素变为可操作状态,从而省去许多手动的waitForSelector调用。等效的抓取程序如下所示:
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (compatible; MyScraper/1.0)'
});
const page = await context.newPage();
await page.goto('https://example.com/feed', { waitUntil: 'networkidle' });
await page.locator('.product').first().waitFor();
const items = await page.locator('.product').evaluateAll((els) =>
els.map((el) => ({
title: el.querySelector('h2')?.innerText.trim(),
price: el.querySelector('.price')?.innerText.trim()
}))
);
console.log(`Extracted ${items.length} items`);
await browser.close();
当你需要在多个浏览器引擎上测试同一个抓取程序,或者想要其内置的网络模拟功能时,选择Playwright。如果只针对Chrome且希望依赖项更少,选择Puppeteer。两者消耗的内存都远超fetch和cheerio,因此请只将它们用于真正需要渲染的页面。
应选择哪种JavaScript抓取工具?
对静态HTML使用fetch或axios配合cheerio,仅当页面需要在浏览器中渲染数据时,才使用Puppeteer或Playwright。
正确的工具取决于页面交付数据的方式,以及你对速度和渲染保真度的侧重。HTTP客户端速度快、体积小,但无法运行JavaScript;无头浏览器能运行真实用户所看到的一切,但需要消耗更多内存和时间。下表总结了这些权衡:
| 工具 | 运行JS | 速度 | 最适合 |
|---|---|---|---|
| fetch + cheerio | 否 | 最快 | 静态HTML,大批量 |
| axios + cheerio | 否 | 快 | 静态HTML,代理配置简单 |
| Puppeteer | 是 | 慢 | 仅限Chrome的动态页面 |
| Playwright | 是 | 慢 | 动态,跨浏览器测试 |
一种常见的生产模式是将两者结合:仅使用无头浏览器访问需要渲染的页面或端点,然后对大多数请求使用普通的HTTP客户端。JavaScript只是完成这项工作的多种可行技术栈之一;如果你正在比较不同的生态系统,我们关于使用Rust进行网页抓取的指南介绍了一种编译型替代方案。
如何让JavaScript抓取程序通过代理路由?
将代理设置传递给HTTP客户端或浏览器启动选项,使请求从不同的IP地址发出,从而分散负载,并降低单个地址被限速的可能性。
从单个IP抓取大量页面,很容易遭到封锁。在一个地址池中轮换请求,可以让流量看起来像来自许多不同的访问者。使用axios时,你需要在请求配置中提供代理主机、端口和凭据。下面的示例通过DataImpulse网关进行路由:
import axios from 'axios';
const { data } = await axios.get('https://example.com', {
proxy: {
protocol: 'http',
host: 'gw.dataimpulse.com',
port: 823,
auth: { username: 'YOUR_USERNAME', password: 'YOUR_PASSWORD' }
},
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; MyScraper/1.0)' }
});
console.log(data.length, 'bytes received through the proxy');
Puppeteer将代理作为启动参数接收,然后在页面上进行身份验证,因为Chrome不接受代理URL中的凭据。这种模式在Playwright中也是一样的,它直接接受一个带有用户名和密码的代理对象:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: 'new',
args: ['--proxy-server=gw.dataimpulse.com:823']
});
const page = await browser.newPage();
await page.authenticate({
username: 'YOUR_USERNAME',
password: 'YOUR_PASSWORD'
});
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
console.log(await page.title());
await browser.close();
DataImpulse通过HTTP、HTTPS和SOCKS5提供住宅代理、数据中心代理和移动代理,支持轮换和粘性会话。国家定位已包含在内,而州、城市、邮编和ASN定位则是付费附加项。如果代理请求返回身份验证错误,我们关于HTTP错误407的说明解释了最常见的原因,我们的代理身份验证指南更详细地介绍了凭据格式。
如何拦截XHR和JSON端点?
与其抓取渲染后的HTML,不如在无头浏览器中观察网络流量,读取页面在后台获取的JSON响应,通常这样更简洁、速度也更快。
动态页面通常从一个返回JSON的隐藏API获取数据。直接读取该响应完全跳过了DOM解析,并为你提供结构良好的记录。Puppeteer和Playwright都允许你监听响应并捕获你想要的内容。下面的Puppeteer示例捕获来自API路径的任何JSON响应:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
const captured = [];
page.on('response', async (response) => {
const url = response.url();
if (url.includes('/api/') && response.headers()['content-type']?.includes('application/json')) {
try {
captured.push(await response.json());
} catch (err) {
// non-JSON body, ignore
}
}
});
await page.goto('https://example.com/feed', { waitUntil: 'networkidle2' });
console.log(`Captured ${captured.length} JSON payloads`);
await browser.close();
一旦你知道了端点的URL及其参数,你通常可以完全绕过浏览器,使用普通的fetch调用JSON API,并传递页面发送的相同请求头。这样既能获得静态抓取程序的速度,又能获得渲染型抓取程序的完整性,是动态网站可获得的最大性能提升。
网页抓取有哪些道德与法律限制?
只抓取公开数据,尊重网站的robots.txt和服务条款,避免个人数据,切勿使服务器过载。这些做法能让项目更合规,也更可持续。
查看域名根目录下的robots.txt文件,了解网站要求爬虫避开哪些路径,并认真遵守这些要求。用诚实的User-Agent标识你的抓取程序,限制你的请求速率,并缓存响应,以避免不必要地重复获取同一页面。在基础设施方面,你的IP地址来源很重要。DataImpulse运营的道德代理来源于自愿参与并获得报酬的用户,符合GDPR要求,并提供数据处理协议。道德规范不只是合规问题;有礼、透明的抓取程序也更可能长期稳定运行。有关可持续数据收集背后原则的更多信息,请参阅我们关于道德网页抓取的指南。

常见问题
使用JavaScript抓取需要Node.js吗?
对于不止简单单页测试的任务,答案是肯定的。Node.js让你可以在服务器上运行抓取程序、遍历大量URL、处理重试、导出数据并使用代理,而浏览器控制台无法很好地完成这些事情。
什么时候应该使用Puppeteer而不是fetch和cheerio?
当数据存在于初始HTML响应中时,使用fetch或axios配合cheerio。只有当内容是在页面加载后由JavaScript渲染时,才切换到Puppeteer或Playwright,因为无头浏览器要重得多。
如何在Node.js中将抓取的数据导出为CSV?
将记录收集为一个对象数组,然后通过连接每个对象的值并转义引号和逗号来构建逗号分隔的行。使用内置的fs模块将结果写入文件,或使用像csv-stringify这样的库。
为什么使用JavaScript抓取时会被封锁?
常见原因是来自单个IP的请求过多、缺失或可疑的User-Agent,以及请求之间没有延迟。轮换代理、真实的请求头、带退避的重试以及速率限制可以减少封锁。
哪种代理类型最适合JavaScript抓取?
数据中心代理对于宽容的目标来说速度快且便宜,而住宅代理和移动代理在严格的网站上看起来更像真实用户。轮换会话有助于将请求分散到许多地址上。
什么时候DataImpulse并不合适?
如果你需要静态ISP代理、完全托管的抓取API,或访问银行和政府网站,DataImpulse不是合适的工具。它专注于轮换住宅、移动和数据中心代理,用于收集公开数据和访问内容。
相关指南
开始使用可靠代理进行抓取
准备好让JavaScript抓取程序在不频繁遭到封锁的情况下稳定运行了吗?DataImpulse为你提供道德来源的住宅、移动和数据中心IP,每GB起价1美元,流量永不过期,无需订阅。创建账户,立即让你的第一个抓取程序通过代理路由。
