跳至内容
免费开始
返回博客

Node.js 中的 Cheerio 网页抓取:使用 load() 加载经过测试的抓取器

Raluca Penciuc最后更新于 5 min read
Node.js 中的 Cheerio 网页抓取:使用 load() 加载经过测试的抓取器
简而言之:Cheerio 是一款适用于 Node.js 的快速 HTML 解析器,提供 jQuery 风格的 API,因此当数据已经存在于页面的原始 HTML 中时,非常适合网页抓取。静态页面使用 fetch 加 Cheerio;如果页面通过 JSON 加载数据,就直接调用该 JSON;只有当内容由 JavaScript 构建时才用浏览器渲染。本指南构建一个经过测试的爬虫,支持分页、重试以及 JSON 或 CSV 导出。

读完本指南后,你将拥有一个 Cheerio 爬虫程序,它能够加载页面、提取表单字段、处理分页、在服务器返回错误时礼貌地重试、将结果保存为 JSON 和 CSV 格式,并通过离线测试。 示例代码使用 books.toscrape.com 和 quotes.toscrape.com 这两个专为数据抓取构建的练习站点,因此您可以直接运行代码。 你还将学会识别 Cheerio 无法读取的页面,并解决最常让初学者感到困扰的 cheerio.load 错误。初次接触 Node 环境下的网页抓取?不妨从我们的《使用 JavaScript 和 Node.js 进行网页抓取指南》开始学习。

什么是 Cheerio,何时应将其用于网页抓取?

Cheerio 是一款适用于 Node.js 的高速 HTML 和 XML 解析器,具有 jQuery 风格的 API。它读取您已有的标记,并允许您使用 CSS 选择器选择元素。它不运行 JavaScript、不加载 CSS,也不渲染页面,因此当您所需的数据已经包含在页面的原始 HTML 中时,请使用它。

不妨将 Cheerio 视为浏览器的对立面。您向它提供一段标记字符串,它便会构建出一个可供查询的元素树。 没有任何内容会被执行:不会进行布局,不会请求图片或样式表,也不会加载后续添加内容的脚本。这就是 Cheerio 为何如此快速轻量,也是它有时在 Chrome 中看似数据丰富的页面上却一无所获的原因。

在底层,GitHub 上的 Cheerio 项目使用 parse5 解析 HTML,也可切换到容错性更强的 htmlparser2。其上层则提供熟悉的 jQuery 风格 API: $('selector'), .find(), .text(),以及 .attr()。关于它与其他工具的对比,请参阅我们对 Node.js 网页抓取库的汇总文章。

首先,确定你要处理的是哪种页面:

页面类型

应使用什么

原因

原始 HTML 中的数据

fetch + Cheerio

速度最快且成本最低。无需执行任何操作

页面请求的 JSON 数据

直接调用 JSON

无需解析标记

内容由 JavaScript 渲染或控制

已渲染的 HTML(Scraper API render_js 或浏览器 API)渲染,随后由 Cheerio 处理

Cheerio 无法运行脚本

一旦确认 Cheerio 能读取页面,请按以下顺序进行提取:

  • 首先查找结构化数据,例如 JSON-LD 代码块或脚本标签内的数据对象。
  • 如果不存在结构化数据,则回退到对可见标记使用 CSS 选择器。
  • 仅当数据从未出现在服务器发送的 HTML 中时,才进行渲染。

如果您正在权衡解析器与完整浏览器的优劣,我们的《Cheerio 与 Puppeteer 对比》文章将详细分析其中的取舍。

如何在 Node.js 中搭建 Cheerio 项目?

安装最新的 Node.js LTS 版本,使用 npm init 创建项目,在 package.json 中添加 "type": "module",然后运行 npm install cheerio。 使用 `import * as cheerio from 'cheerio'` 导入该库。Node.js 内置的 `fetch` 函数可下载网页,因此您无需使用 Axios 或任何其他 HTTP 客户端即可开始使用。

请使用 Node.js 22 或更高版本;当前的 LTS 版本是最佳选择。检查您的版本,然后创建项目:

node -v
mkdir cheerio-scraper && cd cheerio-scraper
npm init -y
npm pkg set type=module
npm install cheerio

该 .mjs 扩展名会指示 Node 将每个文件视为 ES 模块。在 CommonJS 项目中, const cheerio = require('cheerio') 同样有效。Cheerio 的安装指南也涵盖了相同的步骤。

Node.js 内置的 fetch 负责处理下载。它基于 undici,无需导入,并支持 AbortSignal.timeout().

已经在使用 Axios 了吗?请继续使用它。传递 response.data 为 cheerio.load() 代替 await res.text(),本指南的其余内容仍可照常使用。以下是关于如何在 Axios 中设置请求头以进行数据抓取,或比较原生 `fetch` 及其替代方案的方法。

在 TypeScript 中使用 Cheerio

Cheerio 自带类型定义,因此无需 @types/cheerio 需要安装任何包。导入 load 并声明 CheerioAPI 类型,为返回类型添加注解,编译器就会像检查其他代码一样检查您的解析器:

// TypeScript: Cheerio ships its own types, so no @types package is needed.
import { load, type CheerioAPI } from 'cheerio';

interface Book {
  title: string;
  price: number;
}

export function parseBooks($: CheerioAPI): Book[] {
  return $('article.product_pod')
    .map((_, el) => ({
      title: $(el).find('h3 a').attr('title') ?? '',
      price: Number($(el).find('.price_color').text().replace(/[^0-9.]/g, '')),
    }))
    .get();
}

const $ = load('<article class="product_pod"><h3><a title="Sample">S</a></h3><p class="price_color">£9.99</p></article>');
console.log(parseBooks($));

使用 npx tsx 09-typed.ts:

[ { title: 'Sample', price: 9.99 } ]

改用 tsc ?将 @types/node 作为开发依赖,因为 Cheerio 的类型定义引用了 Node 的内置函数,例如 Buffer 和 node:stream。其解析逻辑与 JavaScript 版本完全一致。

cheerio.load() 是如何工作的?

cheerio.load() 会解析一个 HTML 字符串,并返回一个 $ 函数。 调用 $ 并传入 CSS 选择器以获取匹配的元素,然后使用 .text() 或 .attr() 读取它们。使用 .find() 在元素内部搜索,结合 .get() 调用 .map() 来构建数组,并使用 .next() 或 .parent() 在文档树中移动。

在接触网络相关内容之前,请先从一个简单的静态页面开始。该页面涵盖了本指南其余部分中用到的所有方法:

// Cheerio basics: load HTML, select elements, read text and attributes.
import * as cheerio from 'cheerio';

const html = `
<ul id="products">
  <li class="product" data-sku="A1"><a href="/p/a1">Desk lamp</a><span class="price">$24.00</span></li>
  <li class="product" data-sku="B2"><a href="/p/b2">Monitor arm</a><span class="price">$61.50</span></li>
  <li class="product sold-out" data-sku="C3"><a href="/p/c3">Cable tray</a><span class="price">$12.99</span></li>
</ul>`;

const $ = cheerio.load(html);

// One element: .text() and .attr()
console.log('First product:', $('li.product a').first().text());
console.log('First link:', $('li.product a').first().attr('href'));

// Many elements: .map() returns a Cheerio object, .get() turns it into an array
const products = $('li.product')
  .map((_, el) => {
    const item = $(el);
    return {
      sku: item.attr('data-sku'),
      name: item.find('a').text().trim(),
      price: Number(item.find('.price').text().replace(/[^0-9.]/g, '')),
      inStock: !item.hasClass('sold-out'),
    };
  })
  .get();
console.log(products);

// Traversal: move from one element to its neighbors
console.log('After A1:', $('li[data-sku="A1"]').next().find('a').text());
console.log('Parent id:', $('.price').first().parent().parent().attr('id'));

以下是 node 01-basics.mjs:

First product: Desk lamp
First link: /p/a1
[
  { sku: 'A1', name: 'Desk lamp', price: 24, inStock: true },
  { sku: 'B2', name: 'Monitor arm', price: 61.5, inStock: true },
  { sku: 'C3', name: 'Cable tray', price: 12.99, inStock: false }
]
After A1: Monitor arm
Parent id: products

选择元素

传递给 $() 的字符串是标准的 CSS 选择器,因此标签名、 .class, #id、属性选择器如 [data-sku="A1"],以及组合符如 ul > li 均可正常使用。MDN 的 CSS 选择器指南是最佳参考资料。Cheerio 还添加了一些 jQuery 风格的扩展,例如 :contains() 和 :first,但它不支持 XPath;以下是每种选择器语言的适用场景。

读取文本和属性

.text() 返回所有匹配元素及其子元素的合并文本。 .attr('name') 返回第一个匹配元素的属性值,完全按照标记中的写法。 .hasClass() 检查类名,对于“售罄”之类的标记非常有用。

.map() 这是让大多数新手感到困惑的一步。在 Cheerio 中,它返回的是另一个 Cheerio 对象,而不是普通的数组,因此 console.log() 会输出一个充满内部数据的包装器。在 .get() 在末尾,即可获得你预期的普通对象数组。

在树中遍历

当你想要的元素本身没有有用的选择器时,可以从一个有选择器的元素开始,然后遍历到目标位置。 .find() 会搜索后代, .parent() 向上移动一层, .next() 移动到下一个同级节点,并 .first() 仅保留第一个匹配项。示例中的最后两行依次遍历各个产品,并从单个价格向上遍历至其价格列表。

如何使用 Cheerio 抓取真实的网页?

使用 fetch 下载页面,检查响应状态,并将 HTML 文本传递给 cheerio.load()。然后使用 .map() 遍历每个重复出现的卡片,读取所需的每个字段,将价格转换为数字,并使用 new URL() 解析相对链接,这样输出的就是干净、类型明确的数据,而不是原始字符串。

“Books to Scrape” 每页列出 20 本书,每本书都包含在一个 article.product_pod 卡片中。该脚本会抓取首页,并将每个卡片转换为类型化的记录:

// Fetch one page with Node's built-in fetch and parse it with Cheerio.
import * as cheerio from 'cheerio';

const START_URL = 'https://books.toscrape.com/';
const RATINGS = { One: 1, Two: 2, Three: 3, Four: 4, Five: 5 };

async function getHtml(url) {
  const res = await fetch(url, {
    headers: { 'User-Agent': 'cheerio-tutorial/1.0 (+https://www.webscrapingapi.com)' },
    signal: AbortSignal.timeout(15_000),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
  return res.text();
}

const html = await getHtml(START_URL);
const $ = cheerio.load(html);

const books = $('article.product_pod')
  .map((_, el) => {
    const card = $(el);
    const link = card.find('h3 a');
    const ratingWord = card.find('p.star-rating').attr('class').replace('star-rating', '').trim();
    return {
      title: link.attr('title'),
      price: Number(card.find('.price_color').text().replace(/[^0-9.]/g, '')),
      rating: RATINGS[ratingWord] ?? null,
      inStock: card.find('.availability').text().includes('In stock'),
      url: new URL(link.attr('href'), START_URL).href,
    };
  })
  .get();

console.log(`Found ${books.length} books`);
console.log(books.slice(0, 3));

以下是来自 node 02-books-page.mjs:

Found 20 books
[
  {
    title: 'A Light in the Attic',
    price: 51.77,
    rating: 3,
    inStock: true,
    url: 'https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html'
  },
  {
    title: 'Tipping the Velvet',
    price: 53.74,
    rating: 1,
    inStock: true,
    url: 'https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html'
  },
  {
    title: 'Soumission',
    price: 50.1,
    rating: 1,
    inStock: true,
    url: 'https://books.toscrape.com/catalogue/soumission_998/index.html'
  }
]

四个小技巧可将原始字符串转换为可用数据:

  • 价格:页面显示“£51.77”。去除除数字和小数点以外的所有内容,调用 Number(),即可得到 51.77,该数值可用于排序和求和。
  • 评分:星星纯属 CSS 效果。HTML 中唯一的标识是一个类,例如 star-rating Three,因此脚本会剥离 star-rating ,并将剩余的单词映射为数字。
  • 库存:"有货"被转换为布尔值,这比自由文本更容易进行筛选。
  • URL: href 值是相对的,例如 catalogue/a-light-in-the-attic_1000/index.html。URL 构造函数会根据页面地址解析这些值,将其转换为在任何地方都能正常工作的链接。

请从“查看源代码”中选择选择器,而非开发者工具的“元素”面板。开发者工具显示的是脚本执行后的 DOM,而 Cheerio 仅能看到服务器发送的原始源代码。一个在开发者工具中有效但在“查看源代码”中无效的选择器,在脚本中将无法匹配到任何内容。

fetch 调用还会设置超时,因此单个响应卡顿不会导致运行冻结,并且会设置一个明确标明工具名称的 User-Agent。如果没有 User-Agent,Node 会将自身标识为简单的“node”,正如 httpbin 所示:

// See which User-Agent your scraper sends, then set an honest one.
const plain = await fetch('https://httpbin.org/user-agent').then((r) => r.json());
console.log('default:', plain['user-agent']);

const named = await fetch('https://httpbin.org/user-agent', {
  headers: { 'User-Agent': 'cheerio-tutorial/1.0 (+https://www.webscrapingapi.com)' },
}).then((r) => r.json());
console.log('custom:', named['user-agent']);
default: node
custom: cheerio-tutorial/1.0 (+https://www.webscrapingapi.com)

想抓取行和列而不是卡片?请参考我们的 JavaScript HTML 表格抓取教程。

如何使用 Cheerio 抓取多页内容?

先抓取第一页,读取其“下一页”链接的 href 属性,将其与当前 URL 进行解析,并重复此过程,直到没有“下一页”链接或达到页面限制为止。在每次请求之间留出等待时间,仅对 429 和 5xx 响应进行重试(延迟时间逐次增加),最后将结果写入 JSON 或 CSV 文件。

此爬虫基于之前的脚本构建。它通过“下一页”按钮遍历产品目录,对临时失败进行重试,并将结果保存到两个文件中:

// Follow "next" links, retry politely, and save results to JSON and CSV.
import * as cheerio from 'cheerio';
import { writeFile } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';

const START_URL = 'https://books.toscrape.com/catalogue/page-1.html';
const MAX_PAGES = Number(process.argv[2] ?? 3);
const DELAY_MS = 1_000;
const RATINGS = { One: 1, Two: 2, Three: 3, Four: 4, Five: 5 };

async function getHtml(url, attempts = 3) {
  for (let attempt = 1; attempt <= attempts; attempt++) {
    let waitMs = DELAY_MS * 2 ** attempt; // 2 s, then 4 s
    try {
      const res = await fetch(url, {
        headers: { 'User-Agent': 'cheerio-tutorial/1.0 (+https://www.webscrapingapi.com)' },
        signal: AbortSignal.timeout(15_000),
      });
      if (res.ok) return res.text();
      // Retry only on rate limits and server errors
      if (res.status !== 429 && res.status < 500) throw new Error(`HTTP ${res.status} for ${url}`);
      // Honor Retry-After when the server sends it in seconds
      const retryAfter = Number(res.headers.get('retry-after'));
      if (retryAfter > 0) waitMs = retryAfter * 1_000;
      console.warn(`HTTP ${res.status} on attempt ${attempt} for ${url}`);
    } catch (err) {
      if (attempt === attempts || err.message.startsWith('HTTP 4')) throw err;
      console.warn(`${err.name} on attempt ${attempt} for ${url}`);
    }
    if (attempt < attempts) await sleep(waitMs);
  }
  throw new Error(`Gave up on ${url}`);
}

function parseBooks($, pageUrl) {
  return $('article.product_pod')
    .map((_, el) => {
      const card = $(el);
      const link = card.find('h3 a');
      const ratingWord = card.find('p.star-rating').attr('class').replace('star-rating', '').trim();
      return {
        title: link.attr('title'),
        price: Number(card.find('.price_color').text().replace(/[^0-9.]/g, '')),
        rating: RATINGS[ratingWord] ?? null,
        inStock: card.find('.availability').text().includes('In stock'),
        url: new URL(link.attr('href'), pageUrl).href,
      };
    })
    .get();
}

function toCsv(rows) {
  const cols = Object.keys(rows[0]);
  const escape = (v) => {
    const s = String(v ?? '');
    return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  };
  return [cols.join(','), ...rows.map((r) => cols.map((c) => escape(r[c])).join(','))].join('\n');
}

const books = [];
let url = START_URL;
for (let page = 1; url && page <= MAX_PAGES; page++) {
  const $ = cheerio.load(await getHtml(url));
  const found = parseBooks($, url);
  books.push(...found);
  console.log(`Page ${page}: ${found.length} books`);

  const next = $('li.next a').attr('href');
  url = next ? new URL(next, url).href : null;
  if (url) await sleep(DELAY_MS);
}

await writeFile('out/books.json', JSON.stringify(books, null, 2));
await writeFile('out/books.csv', toCsv(books));
console.log(`Saved ${books.length} books to out/books.json and out/books.csv`);

创建输出文件夹,然后设置页面上限为三页运行该脚本:

mkdir out
node 03-crawl-books.mjs 3
Page 1: 20 books
Page 2: 20 books
Page 3: 20 books
Saved 60 books to out/books.json and out/books.csv

前几行代码 out/books.csv:

title,price,rating,inStock,url
A Light in the Attic,51.77,3,true,https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html

三项安全措施确保爬虫行为得体且可预测:

  • 页面上限。第一个命令行参数设置 MAX_PAGES。当页面中没有“下一页”链接时,循环也会停止。
  • 固定延迟。脚本在页面之间等待一秒钟。
  • 严格的重试策略。仅对 HTTP 429 请求过多和 5xx 服务器错误进行重试,重试间隔分别为 2 秒和 4 秒,或者在 Retry-After 服务器返回的延迟值(单位为秒)之后重试。其他任何4xx错误(如404)均会立即放弃,因为再次请求不会改变结果。

CSV 写入器会将包含逗号、双引号或换行符的任何字段加引号,并将其内部的引号加倍。许多书名中都包含逗号,而一个未加引号的逗号会导致后续所有列的位置发生偏移。

延迟和重试只是基本要求。我们的网络爬虫最佳实践手册涵盖了速率限制、任务调度和监控等内容。

如何使用 Cheerio 的 fromURL() 和 extract() 方法?

fromURL() 通过一次带等待的调用下载页面并加载。随后 extract() 将选择器的普通对象映射为值:字符串会返回第一个匹配项的文本,数组会返回所有匹配项,而嵌套对象或函数则可构建结构化记录。对于简单页面,二者结合可替代大多数手动编写的 .map() 循环。

以下是在“Quotes to Scrape”中应用相同思路的示例,无需 fetch 调用,也无需手动循环:

// Cheerio's fromURL() and extract(): fetch and map fields in one step.
import { fromURL } from 'cheerio';

const PAGE_URL = 'https://quotes.toscrape.com/';
const $ = await fromURL(PAGE_URL);

const data = $.extract({
  pageTitle: 'title',
  quotes: [
    {
      selector: 'div.quote',
      value: {
        text: 'span.text',
        author: 'small.author',
        authorUrl: {
          selector: 'a[href^="/author/"]',
          value: (el) => new URL($(el).attr('href'), PAGE_URL).href, // resolve relative links
        },
        tags: { selector: '.tags', value: (el) => $(el).find('a.tag').map((_, a) => $(a).text()).get() },
      },
    },
  ],
});

console.log(data.pageTitle.trim());
console.log(`Quotes on page: ${data.quotes.length}`);
console.log(data.quotes[0]);
Quotes to Scrape
Quotes on page: 10
{
  text: '“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”',
  author: 'Albert Einstein',
  authorUrl: 'https://quotes.toscrape.com/author/Albert-Einstein',
  tags: [ 'change', 'deep-thoughts', 'thinking', 'world' ]
}

每个键对应一个输出字段。 pageTitle: 'title' 读取第一个 <title> 元素的文本。将描述符封装在数组中,就像 quotes ,将每个匹配项作为独立结果返回,而 value 该属性可以是选择器、嵌套对象,或是接收该元素的函数。extract() 参考文档列出了所有选项。

请自行解析链接。根据您使用的 Cheerio 版本, href 通过 extract() 或 .prop('href') 仍可能返回相对路径,因此 fromURL(),因此该代码片段会对每个值进行 new URL()。这一小小的保护措施可确保无论哪种情况,输出结果都正确。

Cheerio 关于文档加载方式的文档中还补充了两个细节。 fromURL() 会直接返回错误来拒绝非 2xx 响应,而不是将一个需要解析的错误页面传递给你。此外,如果你通过 requestOptions 来设置头部,你必须自己设置 method ,因为系统不会自动为你填充。

请使用 fetch plus load() ,例如上文中的爬虫示例,以实现自定义重试、延迟或代理层。

如何从 JSON-LD 和 script 标签中获取数据?

在编写选择器之前,请搜索 HTML 中包含 data 的 script type="application/ld+json" 代码块和内联脚本。使用 Cheerio 选中它们,读取其文本内容,并在 try/catch 块内通过 JSON.parse 进行解析。结构化数据通常比可见标记更能经受住页面重构的考验,且往往包含清晰的售价、SKU 和库存状态。

许多商店和商品页面都会为搜索引擎嵌入 schema.org 数据,一旦存在,这通常是页面上最规范的数据源:

// Read structured data (JSON-LD) before writing selectors for visible text.
import { load } from 'cheerio';

const html = `<html><head>
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Product","name":"Desk lamp","sku":"A1",
 "offers":{"@type":"Offer","price":"24.00","priceCurrency":"USD","availability":"https://schema.org/InStock"}}
</script></head><body><h1>Desk lamp</h1></body></html>`;

const $ = load(html);
const items = $('script[type="application/ld+json"]')
  .map((_, el) => {
    try {
      return JSON.parse($(el).text());
    } catch {
      return null; // skip malformed blocks instead of crashing
    }
  })
  .get()
  .filter(Boolean);

const product = items.find((item) => item['@type'] === 'Product');
console.log({ name: product.name, price: Number(product.offers.price), currency: product.offers.priceCurrency });
{ name: 'Desk lamp', price: 24, currency: 'USD' }

该 try/catch 可防止单个格式错误的代码块导致解析失败。请根据 @type 所需类型(如 Product 或 Article)进行过滤,因为一个页面通常包含多个数据块。

有些网站将整个数据集封装在内联脚本中,并通过 JavaScript 构建可见标记。这种情况乍看之下似乎无法正常工作,因此专门开辟了一个章节来讨论。

为什么 Cheerio 在 JavaScript 渲染的页面上返回空结果?

Cheerio 只能识别服务器发送的 HTML。如果页面通过浏览器中的 JavaScript 构建内容,你的选择器将无法匹配任何元素,因为这些元素尚未存在。请先检查原始 HTML。然后在脚本或 JSON 接口中查找数据,或者获取渲染后的 HTML 并使用 Cheerio 进行解析。

Quotes to Scrape 提供了一个 JavaScript 版本,地址为 /js/ ,在浏览器中显示效果与静态页面完全一致。该脚本展示了 Cheerio 实际接收的内容以及数据隐藏的位置:

// Spot a JavaScript-rendered page, then look for the data inside a <script> tag.
import { fromURL } from 'cheerio';

const $ = await fromURL('https://quotes.toscrape.com/js/');

// 1. The selector that works on the static version finds nothing here
console.log('div.quote elements in raw HTML:', $('div.quote').length);

// 2. The quotes still ship in the HTML, inside an inline script
const script = $('script')
  .map((_, el) => $(el).text())
  .get()
  .find((code) => code.includes('var data ='));

// Capture the array literal between "var data =" and the first "];"
const json = script.match(/var data = (\[[\s\S]*?\]);/)[1];
const quotes = JSON.parse(json);

console.log(`Quotes found in <script>: ${quotes.length}`);
console.log({ author: quotes[0].author.name, tags: quotes[0].tags });
div.quote elements in raw HTML: 0
Quotes found in <script>: 10
{
  author: 'Albert Einstein',
  tags: [ 'change', 'deep-thoughts', 'thinking', 'world' ]
}

在静态页面上能找到 10 条引语的选择器,在这里却找不到任何结果,因为 div.quote 元素是在浏览器中生成的。这些引号仍然存在于 HTML 中,位于一个 var data = [...] 赋值语句中,因此使用正则表达式和 JSON.parse() 可恢复全部 10 条。这就是服务器端与客户端渲染在实际中的区别。

当数据不在脚本中时,请按以下顺序尝试这些方法:

  1. 查找 JSON 请求。在开发者工具中,打开“网络”选项卡,按“Fetch/XHR”过滤,然后刷新页面。许多页面都是从 JSON 端点加载内容的。如果该端点是公开的且允许你使用,请直接调用它,从而跳过 HTML 解析。
  2. 先渲染,再解析。渲染是指在解析之前,使用真实的引擎(通常是无头浏览器)运行页面。你的 Cheerio 代码保持不变,只有 HTML 的源代码会发生变化。

你可以自行运行浏览器,也可以让 API 代为运行。Scraper API 可以渲染页面并返回最终的 HTML,你只需将其直接传递给 cheerio.load()。将 render_js=1 到请求中,响应内容即为脚本执行完成后的 HTML。JavaScript 渲染和地理定位不会消耗额外积分,且失败的请求不会计费。

// When a page needs a browser, fetch rendered HTML from Scraper API and keep parsing with Cheerio.
import { load } from 'cheerio';

const apiKey = process.env.WSA_API_KEY;
if (!apiKey) {
  console.error('Set WSA_API_KEY to run this example.');
  process.exit(1);
}

const params = new URLSearchParams({
  api_key: apiKey,
  url: 'https://quotes.toscrape.com/js/',
  render_js: '1',
});

const res = await fetch(`https://api.webscrapingapi.com/v2?${params}`, {
  signal: AbortSignal.timeout(120_000),
});
if (!res.ok) throw new Error(`Scraper API returned HTTP ${res.status}`);

const $ = load(await res.text());
console.log('div.quote elements after rendering:', $('div.quote').length);
console.log('First author:', $('div.quote small.author').first().text());

请将密钥设置为环境变量来运行该功能,这样密钥就不会出现在您的代码中:

WSA_API_KEY=your_key node 08-render-handoff.mjs

当数据仅在点击、滚动或等待后才会显示时,Browser API 会为您执行这些步骤。您只需在 REST 请求中描述等待时间以及点击、滚动、输入和提交等操作,浏览器将在我们的服务器端运行,其返回的 HTML 将被放入 cheerio.load() 加载,如同任何其他页面一样。

为什么 cheerio.load 无法正常工作?常见错误及解决方法

大多数 cheerio.load 问题源于以下几种原因:Cheerio 1.x 不再提供的默认导入、针对浏览器渲染的 DOM 编写的选择器、被 html 和 body 标签包裹的片段、被解析为 HTML 的 XML,以及相对链接或混乱的空格。以下每种修复方案都只需一行代码的修改,您可以在几秒钟内进行测试。

以下脚本展示了下方七种修复方法中的五种,并附有修改前后的对比:

// Fixes for the errors people hit most often with cheerio.load().
import * as cheerio from 'cheerio';
import { load } from 'cheerio';

// Fix 1: there is no default export in Cheerio 1.x.
// `import cheerio from 'cheerio'` fails; use a namespace or named import.
console.log('namespace load is a function:', typeof cheerio.load === 'function');
console.log('named load is a function:', typeof load === 'function');

// Fix 2: load() wraps fragments in <html><head><body> by default.
const fragment = '<li>Apples</li><li>Pears</li>';
console.log('document mode:', load(fragment).html());
console.log('fragment mode:', load(fragment, null, false).html());

// Fix 3: parse XML (RSS, sitemaps) with the xml option so tags like <link> keep their text.
const rss = `<rss><channel><item><title>Post</title><link>https://example.com/post</link></item></channel></rss>`;
console.log('HTML mode <link>:', JSON.stringify(load(rss)('item link').text()));
console.log('XML mode <link>:', JSON.stringify(load(rss, { xml: true })('item link').text()));

// Fix 4: .text() keeps whitespace and joins nested text. Normalize it yourself.
const $ = load('<p class="desc">\n  Fast,\n  <b>small</b>   and   tested.\n</p>');
console.log('raw text:', JSON.stringify($('.desc').text()));
console.log('clean text:', JSON.stringify($('.desc').text().replace(/\s+/g, ' ').trim()));

// Fix 5: .attr('href') returns the value as written. Resolve it against the page URL.
const page = 'https://books.toscrape.com/catalogue/page-2.html';
console.log('absolute URL:', new URL('../index.html', page).href);
namespace load is a function: true
named load is a function: true
document mode: <html><head></head><body><li>Apples</li><li>Pears</li></body></html>
fragment mode: <li>Apples</li><li>Pears</li>
HTML mode <link>: ""
XML mode <link>: "https://example.com/post"
raw text: "\n  Fast,\n  small   and   tested.\n"
clean text: "Fast, small and tested."
absolute URL: https://books.toscrape.com/index.html

“cheerio.load 不是一个函数”或“cheerio.default 未定义”

症状:脚本在首次调用 Cheerio 时失败,或者 cheerio.default 返回未定义。在原生 ES 模块中, import cheerio from 'cheerio' 甚至会更早报错,提示“未提供名为'default'的导出”。

原因:Cheerio 1.x 没有默认导出。旧版教程和 1.0 之前的代码曾使用过默认导出。

解决方法:使用命名空间导入, import * as cheerio from 'cheerio',或使用命名导入, import { load } from 'cheerio'。在 CommonJS 中, const cheerio = require('cheerio') 即可正常工作。

选择器返回空值

症状: $('...').length 为 0,且 .text() 返回一个空字符串。

原因:可能有以下三种情况之一。您从开发者工具(DevTools)中复制了选择器,而开发者工具显示的是脚本执行后的 DOM;内容由 JavaScript 渲染(如“JavaScript 渲染的页面”一节所述);或者服务器返回了页面阻塞、登录墙或错误页面。

解决方法:记录 res.status 并记录 HTML 的前 200 个字符,使用 console.log(res.status, html.slice(0, 200))。然后通过“查看源代码”而非“元素”面板核对你的选择器。

输出中出现多余的 html、head 和 body 标签

症状:你加载了如下代码片段 <li>Apples</li>,而 .html() 返回时却被包裹在 <html><head></head><body>.

原因:默认情况下, load() 会将输入视为完整的文档,并像浏览器一样添加缺失的包裹标签。

解决方法:使用 load(html, null, false)。第三个参数用于关闭文档模式。

XML 和 RSS 源丢失数据

症状:当您解析 RSS 源或网站地图时,诸如 <link> 返回为空。

原因:在 HTML 模式下, <link> 是一个空元素,无法包含文本,因此其中的 URL 会被舍弃。在上方的输出中,HTML 模式返回了 "" ,而 XML 模式则返回了该 URL。

解决方法:使用 load(xml, { xml: true })。XML 模式还会保留标签名称的大小写,且不会添加 html 或 body 包裹标签。

文本中出现多余的空格

症状: .text() 返回的字符串中充斥着换行符和连续的空格。

原因: .text() 保留源代码中的空白(包括缩进),并将嵌套元素(如 <b> ,不使用分隔符。

解决方法:使用 .text().replace(/\s+/g, ' ').trim()进行规范化处理。若处理整页内容,请先选中主内容元素,以排除导航栏和页脚文本。

403 或 429 错误

症状:请求因 HTTP 403 禁止访问或 429 请求过多而失败,且解析器从未运行。

原因:这些错误源自服务器,而非 Cheerio。Cheerio 仅在请求成功后才能读取 HTML。

解决方法:设置真实的 User-Agent,降低请求频率,遵守 Retry-After,并遵守 robots.txt 规则及网站使用条款。如果网站仍会阻止你被允许抓取的页面上的普通请求,请通过 Scraper API 转发抓取请求,并保留你的解析器。

链接返回为相对路径

症状: .attr('href') 返回类似 ../index.html 或 /author/Albert-Einstein ,这些路径在当前页面之外无法访问。

原因: .attr() 返回的值与标记中写入的完全一致。

解决方法:使用 new URL(href, pageUrl).href,并以获取的页面地址作为基准。

如何确保 Cheerio 爬虫不会出错?

将解析与抓取分离,将真实页面保存为测试数据,并使用 `node:test` 离线测试解析器。优先使用 ID、数据属性及 itemprop 等稳定的钩子,而非样式类;当页面返回零条记录时抛出错误,这样页面重设计失败时会明确报错,而非保存空文件。

抓取器很少会因错误而失效。通常是页面布局发生变化,所有选择器都悄无声息地无法匹配任何内容,而你却发现一整周生成的都是空文件。请将解析器设计为可测试的纯函数,并在未找到任何内容时发出警告。

将真实页面的副本保存为测试数据:

mkdir fixtures
curl -s https://books.toscrape.com/catalogue/page-1.html -o fixtures/books-page-1.html

将解析功能移至独立模块中。该模块接受 HTML 和 URL 作为参数,且不关心 HTML 的来源:

// parse-books.mjs: keep parsing separate from fetching so it can be tested offline.
import { load } from 'cheerio';

const RATINGS = { One: 1, Two: 2, Three: 3, Four: 4, Five: 5 };

export function parseBooks(html, pageUrl) {
  const $ = load(html);
  const books = $('article.product_pod')
    .map((_, el) => {
      const card = $(el);
      const link = card.find('h3 a');
      const ratingWord = card.find('p.star-rating').attr('class').replace('star-rating', '').trim();
      return {
        title: link.attr('title'),
        price: Number(card.find('.price_color').text().replace(/[^0-9.]/g, '')),
        rating: RATINGS[ratingWord] ?? null,
        inStock: card.find('.availability').text().includes('In stock'),
        url: new URL(link.attr('href'), pageUrl).href,
      };
    })
    .get();

  // Fail loudly when the layout changes instead of saving an empty file
  if (books.length === 0) throw new Error(`No books found on ${pageUrl}; check the selectors`);
  return books;
}

然后使用 Node 内置的测试运行器进行测试,无需额外依赖:

// Run with: node --test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { parseBooks } from './parse-books.mjs';

const PAGE_URL = 'https://books.toscrape.com/catalogue/page-1.html';

test('parses every book card on a saved page', async () => {
  const html = await readFile(new URL('./fixtures/books-page-1.html', import.meta.url), 'utf8');
  const books = parseBooks(html, PAGE_URL);

  assert.equal(books.length, 20);
  for (const book of books) {
    assert.ok(book.title.length > 0);
    assert.ok(book.price > 0);
    assert.ok(book.rating >= 1 && book.rating <= 5);
    assert.match(book.url, /^https:\/\/books\.toscrape\.com\/catalogue\//);
  }
});

test('throws when the layout changes', () => {
  assert.throws(() => parseBooks('<html><body><p>Redesigned</p></body></html>', PAGE_URL), /No books found/);
});

运行 node --test,测试摘要的结尾显示:

# tests 2
# pass 2
# fail 0

第一个测试验证解析器能否正确读取所有 20 张卡片并获取合理值;第二个测试验证,当页面经过重新设计时,系统会抛出异常而非返回空数组。由于测试数据存储在磁盘上,测试可在离线状态下运行。当网站发生变更时,保存一份新的测试数据,并更新选择器直至测试通过。

养成以下习惯可延长选择器的使用寿命:

  • 优先使用 id、 data-* 属性以及 itemprop 属性。它们承载着具体含义,且比样式类更少发生变化。
  • 避免使用通用类和生成的类名,因为它们可能会在每次部署时发生变化。
  • 以结构为锚点,而非位置。“每个产品卡片内的价格”能经受住新横幅的更新;而“第三个 div”则不能。

如果维护数据提取层的成本高于解析成本,请根据任务需求和成本对网络抓取 API 进行比较。

常见问题

Cheerio 比 Puppeteer 快吗?

通常情况下,对于静态页面是这样。Cheerio 仅解析文本,而 Puppeteer 会启动完整的浏览器、加载资源并运行脚本,因此 Cheerio 每页处理的工作量要少得多。请针对您的具体目标进行测试,而非盲目相信通用基准测试结果。如果数据需要通过 JavaScript 才能显示,仅靠 Cheerio 无论以何种速度都无法获取该数据。

Cheerio 能运行 JavaScript 或抓取动态页面吗?

不能。Cheerio 仅解析您提供的 HTML 字符串,绝不会运行脚本。对于动态页面,请先在内联脚本或页面的 JSON 请求中查找数据。如果这两种方法都不奏效,请通过无头浏览器或渲染 API 获取渲染后的 HTML,并将其传递给 cheerio.load()。

使用 Cheerio 是否需要 Axios?

不需要。当前版本的 Node.js 已包含全局 `fetch` 函数,因此您可以调用 `await fetch(url)`,并将 `await res.text()` 传递给 `cheerio.load()`。Cheerio 的 `fromURL()` 方法也可以为您获取数据。如果您的项目已经使用了 Axios,那也没问题:以同样的方式将 `response.data` 传递给 `cheerio.load()` 即可。

Cheerio 支持 XPath 吗?

不支持。Cheerio 通过 CSS 选择器以及一些 jQuery 风格的扩展(如 :contains() 和 :first)来选择元素,并且不具备 XPath 引擎。 大多数 XPath 表达式可转换为 CSS 选择器加上 .parent()、.next() 或 .filter() 等遍历方法。如果你依赖 XPath,请使用支持它的解析器。

Cheerio 是否支持 TypeScript?

支持。Cheerio 自带类型定义,因此无需单独安装 @types 包。导入 load 和 CheerioAPI 类型,为解析器的返回类型添加注解,然后使用 tsx 运行文件,或者添加 @types/node 并使用 tsc 编译。解析逻辑与 JavaScript 版本完全一致。

为什么 cheerio.load 返回空结果?

通常是因为原始 HTML 中不存在这些元素。可能是您从开发者工具(DevTools)中复制了选择器,而开发者工具显示的是脚本执行后的页面;也可能是服务器返回了空白页面或错误。请记录状态码和 HTML 的前几行,然后对照“查看源代码”检查您的选择器。

如何使用 Cheerio 从 HTML 中提取纯文本?

对选定内容调用 .text() 方法,然后使用 .replace(/\s+/g, ' ').trim() 规范化空格。 .text() 方法会将每个嵌套元素的文本连接起来,同时保留源代码中的换行符和缩进。 对于整个页面,请先选中主要内容元素,以避免导航栏和页脚文本混入结果中。

Cheerio 能解析 XML 和 RSS 源吗?

可以。将 { xml: true } 作为选项传递:cheerio.load(xml, { xml: true })。 在默认的 HTML 模式下,诸如 link 之类的标签会被视为空 HTML 元素并丢失其文本内容,这会导致 RSS 解析失败。XML 模式还会保留标签名称的大小写,并跳过 html、head 和 body 包裹标签。

如何在使用 Cheerio 抓取时避免被封禁?

封禁发生在请求层面,而非 Cheerio 本身。 请使用真实的 User-Agent,在请求之间加入延迟,对 429 和 5xx 响应采用退避策略重试,遵守 Retry-After 规则,并遵循 robots.txt 及网站的使用条款。如果允许抓取的页面仍返回 403 错误,请更改抓取层,同时保留您的解析器。

使用 Cheerio 进行网页抓取是否合法?

Cheerio 仅是一个解析器,因此合法性取决于你抓取的内容及方式。在遵守网站条款的前提下,以礼貌方式收集的公开数据通常风险较低。个人数据、需登录才能访问的内容以及受版权保护的材料则涉及额外义务。各国法规各异,因此商业项目应寻求法律建议。本文不构成法律建议。

何时应将数据抓取任务交给 API?

请让 Cheerio 专注于其最擅长的领域——将 HTML 转换为干净的数据。当页面需要 JavaScript 处理或持续返回 403 错误时,请让其他工具来获取这些页面。 Scraper API 套餐提供 5,000 次请求的免费试用。JavaScript 渲染和地理定位无需额外消耗积分,且失败的请求不会计费。对于需要点击、滚动或等待的流程,请根据您的工作负载规模申请 Browser API 报价。无论采用哪种方式,您的解析器都将保持不变。

关于作者

Raluca Penciuc, 全栈开发工程师 @ WebScrapingAPI

Raluca Penciuc

全栈开发工程师

Raluca Penciuc 是 WebScrapingAPI 的全栈开发工程师,主要负责开发爬虫、优化规避机制,并探索可靠的方法以降低在目标网站上的被检测概率。

开始构建

准备好扩展您的数据收集规模了吗?

加入2,000多家企业,使用WebScrapingAPI在无需任何基础设施开销的情况下,以企业级规模提取网络数据。