Crawlee TypeScript 商品采集教程:分页、详情提取与 JSON 导出
从安装 Crawlee 开始,以图书目录为例完成两页列表发现、详情字段提取、商品去重与 JSON 导出,并通过发现记录和失败记录定位漏采原因。
要把商品目录变成一份能使用的数据,至少要解决三个相连的问题:怎样找到所有目标商品,怎样准确读取每个商品的字段,以及怎样知道有没有漏掉数据。只抓到一个页面的标题,离完成这件事还有距离。
本文面向会基本 TypeScript、第一次使用 Crawlee 的开发者。我们用公开的 Books to Scrape 练习站,采集总目录前两页中的图书,进入详情页读取 UPC、完整书名和含税价格,最后导出 JSON。你会得到一段完整的正文示例,以及一套区分“没有发现链接”和“详情提取失败”的排查方法。
练习站上的价格和评分是演示数据,不代表真实交易信息。本文处理静态 HTML,不涉及登录、浏览器交互和断点恢复;先把单次小任务的数据链路做好,再考虑扩大规模。
准备输入地址
先在浏览器找到下列页面并确认内容,再从地址栏复制完整地址。服务或仓库地址从相应账户设置、官方项目的接入说明或克隆菜单取得。不要从示例输出中反推真实地址。
BOOKS_LIST_URL:Books to Scrape 总目录第一页的完整地址。
在执行后续命令的同一个 Bash/WSL 终端中设置:
export BOOKS_LIST_URL='<paste-the-actual-books-list-url>'下文 JSON 或任务参数中的 <变量名> 是待替换标记,提交前填写刚复制的实际值;JSON 不会自动展开环境变量。结果示例中的 example.16yun.cn 只表示来源字段的形状,不是本文部署的演示站。
先确定采集什么,避免得到一份含义不清的数据
打开第一本书的详情页,可以看到顶部价格和商品信息表。表格里还区分了不含税价格、含税价格和税额。即使练习数据中两种价格相等,也要选择其中一种含义,不能因为数字相同就混用字段。
本文导出的每条记录包含以下字段:
| 字段 | 本文含义 | 为什么这样保存 |
|---|---|---|
upc | 商品信息表中的 UPC | 用业务编号识别商品,不依赖书名或列表排序 |
title | 详情页的完整标题 | 列表上显示的书名可能被截断 |
price_minor | 含税价格,单位为便士 | 用整数表达本例两位小数的英镑金额 |
currency | GBP | 金额必须和币种一起解释 |
source_url | 实际加载的详情页地址 | 方便回到页面核对字段 |
例如,price_minor: 5177 与 currency: "GBP" 一起表示 £51.77。这里没有把运费纳入价格,也没有根据货币符号自动推断任意站点的币种。换站点时,需要重新确认税费、币种和金额格式。
这个选择会直接影响代码:我们读取表格中名为 Price (incl. tax) 的那一行,而不是对页面中所有带有 price 的元素一起取文本。
为什么这里使用 CheerioCrawler
浏览器中能看到数据,并不意味着采集时一定要启动浏览器。先对详情页使用“查看网页源代码”,搜索书名或 Price (incl. tax)。如果响应 HTML 已包含字段,HTTP 下载后解析 HTML 就足够。
Crawlee 的 CheerioCrawler 正是这种方式:下载页面,然后把解析后的 $ 对象交给处理函数;它不执行页面 JavaScript。需要点击按钮才能加载的列表,应另选浏览器抓取器。两者的能力差异见 CheerioCrawler 官方说明。
对本例使用 CheerioCrawler,可以把注意力集中在链接、字段和输出上,不需要额外安装浏览器。
安装:只准备本例需要的运行环境
准备 Node.js 24 和 npm,在终端确认版本:
node --version
npm --version第一条命令应显示 v24.x.x。本文使用 .mts 文件,让 Node 按 ES Module 运行 TypeScript;示例中的接口和类型注解可以由 Node 的类型擦除功能处理,因此不需要额外配置编译步骤。类型擦除不执行类型检查,也不支持全部 TypeScript 语法,具体边界见 Node.js TypeScript 文档。
在一个新目录中初始化并安装 Crawlee:
mkdir product-crawler
cd product-crawler
npm init -y
npm install crawlee@3.18.2固定 Crawlee 版本,是为了让正文接口与安装结果对应。保留生成的 package-lock.json;以后在相同项目重新安装,可以使用 npm ci,而不是无条件升级依赖。
在这个目录中新建 main.mts。下面三段 TypeScript 按顺序放进同一个文件,不需要另外创建路由、工具函数文件或下载示例附件。
第一步:定义记录,以及检查漏采所需的信息
先放入导入、数据类型和任务设置:
import {
CheerioCrawler,
Dataset,
KeyValueStore,
NonRetryableError,
} from 'crawlee';
interface Product {
upc: string;
title: string;
price_minor: number;
currency: 'GBP';
source_url: string;
}
const START_URL = process.env.BOOKS_LIST_URL;
if (!START_URL) throw new Error('Set BOOKS_LIST_URL before running');
const MAX_LIST_PAGES = 2;
const completedLists = new Set<number>();
const discoveredDetails = new Set<string>();
const completedDetails = new Set<string>();
const products = new Map<string, Product>();
const failures = new Map<string, string>();这里的几个集合解决不同的问题:
completedLists记录处理完成的列表页编号,防止漏翻一页却误以为任务完成。discoveredDetails记录列表发现的详情 URL,表示任务应该处理哪些地址。completedDetails记录已经成功提取字段的详情 URL,用于与发现集合求差集。products按 UPC 保存商品,同一本书即使通过不同 URL 到达,最终也只导出一条记录。failures保存最终失败的地址和原因,便于解释为什么存在缺失。
这些集合都只在当前进程中有效,适合本例的小规模任务。进程中断后重新运行,会重新构建它们。它们不能提供断点恢复,也不适合把几十万条商品一直保留在内存中。
第二步:把列表发现与详情提取连接起来
继续在 main.mts 中追加抓取器定义:
const crawler = new CheerioCrawler({
maxConcurrency: 2,
maxRequestsPerMinute: 30,
maxRequestsPerCrawl: 100,
maxRequestRetries: 1,
async requestHandler({ request, $, enqueueLinks, log }) {
const pageUrl = request.loadedUrl ?? request.url;
if (request.label === 'LIST') {
const listPage = Number(request.userData.listPage);
const links = $('article.product_pod h3 a');
const next = $('li.next a');
if (links.length === 0) {
throw new NonRetryableError(`列表没有商品链接:${pageUrl}`);
}
if (listPage < MAX_LIST_PAGES && next.length !== 1) {
throw new NonRetryableError(`找不到唯一的下一页链接:${pageUrl}`);
}
links.each((_, element) => {
const href = $(element).attr('href');
if (href) {
discoveredDetails.add(new URL(href, pageUrl).href);
}
});
await enqueueLinks({
selector: 'article.product_pod h3 a',
label: 'DETAIL',
strategy: 'same-origin',
});
if (listPage < MAX_LIST_PAGES) {
await enqueueLinks({
selector: 'li.next a',
label: 'LIST',
userData: { listPage: listPage + 1 },
strategy: 'same-origin',
});
}
completedLists.add(listPage);
log.info(`列表 ${listPage}:发现 ${links.length} 个商品链接`);
return;
}
if (request.label !== 'DETAIL') {
throw new NonRetryableError(`未知页面标签:${request.label}`);
}
const fields = new Map<string, string>();
$('table.table-striped tr').each((_, row) => {
fields.set(
$(row).find('th').text().trim(),
$(row).find('td').text().trim(),
);
});
const upc = fields.get('UPC');
const title = $('.product_main h1').text().trim();
const priceText = fields.get('Price (incl. tax)') ?? '';
const amount = /^£(\d+)\.(\d{2})$/.exec(priceText);
if (!upc || !title || !amount) {
throw new NonRetryableError(
`详情字段不符合预期:${JSON.stringify({ upc, title, priceText })}`,
);
}
products.set(upc, {
upc,
title,
price_minor: Number(amount[1]) * 100 + Number(amount[2]),
currency: 'GBP',
source_url: pageUrl,
});
completedDetails.add(request.url);
},
async failedRequestHandler({ request, log }, error) {
failures.set(request.url, error.message);
log.error(`最终失败 ${request.url}:${error.message}`);
},
});列表只负责发现,详情才生成记录
初始请求带 LIST 标签,发现的商品链接带 DETAIL 标签。处理函数先判断标签,再选择页面规则,避免把列表页当成商品保存。
article.product_pod h3 a 只选商品卡片里的标题链接;li.next a 只选分页中的下一页。不要对整个页面直接调用不带选择器的 enqueueLinks(),否则分类菜单等无关页面也可能进入任务。
链接中的 href 可以是相对路径,不能直接拼接站点域名。第二页上的商品链接和第一页处于同一目录时,拼接方式一旦错误,就可能生成不存在的地址。new URL(href, pageUrl) 以当前页为基准解析相对路径,enqueueLinks 也提供相对链接处理能力。这里分别用它们建立发现清单和请求队列,接口说明见 相对链接示例。
same-origin 限制新链接的来源,但同源不等于符合业务范围。真正限定本例路径的是两个选择器;迁移到其他站点时还要检查它们是否命中推荐位、广告或站内搜索结果。
页数限制与请求上限解决两件事
MAX_LIST_PAGES = 2 控制业务范围:第二页仍然提取详情,但不再添加第三页。不要把 maxRequestsPerCrawl 改成 2 来实现“两页商品采集”,因为详情页同样消耗请求数量。
本例在每页 20 本书、没有重复时,需要处理 2 个列表和 40 个详情,共 42 个页面。设置 100 是给这个小任务留出余量,不是声明要采集 100 本书。Crawlee 的请求上限也不是严格的网络请求计数器,并发和重试都需要另行理解,见 抓取器参数说明。
并发设为 2、每分钟最多启动 30 个请求,是练习任务的保守起点,不是通用性能建议。maxRequestRetries: 1 允许普通请求失败后再尝试一次;缺字段、价格格式不符合约定时抛出 NonRetryableError,避免重复下载同一份错误结构。
字段校验应暴露规则失效
表格按字段名读取,而不是用“第三行、第四行”的位置猜价格。站点增加一行信息时,按行号提取容易把税额当成价格;按字段名读取则更容易发现字段已更名。
价格正则只接受本例的 £51.77 格式,再分别计算整数英镑和便士,得到 5177。不直接删除全部非数字字符,是为了避免把 £51.77 £60.00 这样的混合文本悄悄变成一个错误金额。
如果出现 £1,234.56、其他币种或空价格,这段示例会拒绝该记录。迁移站点时,应根据真实格式修改解析规则;不能无条件放宽正则,也不能用零填补缺失价格。零是一个有效业务值,缺失则表示没有取得数据。
第三步:运行任务,导出商品与缺失清单
最后在 main.mts 中追加下面的收尾代码:
await crawler.run([
{ url: START_URL, label: 'LIST', userData: { listPage: 1 } },
]);
const missingDetails = [...discoveredDetails]
.filter((url) => !completedDetails.has(url));
const summary = {
requested_list_pages: MAX_LIST_PAGES,
completed_list_pages: [...completedLists].sort((a, b) => a - b),
discovered_detail_urls: discoveredDetails.size,
completed_detail_urls: completedDetails.size,
unique_products: products.size,
missing_detail_urls: missingDetails,
failed_requests: [...failures].map(([url, message]) => ({ url, message })),
};
if (products.size > 0) {
await Dataset.pushData([...products.values()]);
}
await KeyValueStore.setValue('SUMMARY', summary);
await Dataset.exportToJSON('PRODUCTS');
console.log(JSON.stringify(summary, null, 2));
if (
completedLists.size !== MAX_LIST_PAGES
|| missingDetails.length > 0
|| failures.size > 0
) {
process.exitCode = 1;
}crawler.run() 结束之后,才把按 UPC 合并过的商品写入默认 Dataset。这里故意把写入放在收尾阶段,使“一个业务商品只输出一条”更容易理解;代价是处理中断时,这批内存记录还没有写入 Dataset。长期任务应该采用持久化和按业务键更新的存储方案,不能照搬这个小任务的内存做法。
Crawlee 的 Dataset 适合追加结构化记录,exportToJSON 将它导出到 Key-value store。本例没有另外配置存储路径,默认文件位置由 结果存储说明 定义,导出方法见 Dataset API。
保存 main.mts,在同一目录运行:
node main.mts在默认本地配置下,查看这两个文件:
storage/key_value_stores/default/PRODUCTS.json:商品数组。storage/key_value_stores/default/SUMMARY.json:列表完成情况、详情缺失地址和最终失败原因。
Dataset 的逐条 JSON 记录位于 storage/datasets/default/。它与导出的 PRODUCTS.json 表示同一批商品,不要把两处文件再合并一次。
本文按新建目录、默认配置、每次重新采集的方式使用存储。不要在已有任务目录中直接套用这些假设;修改了存储目录或清理配置时,先确认读到的是当前任务结果。运行中的 URL 队列也不等于本文内存集合能够恢复,二者不能混为一谈。
怎样确认结果确实覆盖了前两页
下面是按任务范围推导的正常汇总结构,不是运行日志。它假设页面仍保持每页 20 本书,两个列表没有重复,并且所有详情都提取成功:
{
"requested_list_pages": 2,
"completed_list_pages": [1, 2],
"discovered_detail_urls": 40,
"completed_detail_urls": 40,
"unique_products": 40,
"missing_detail_urls": [],
"failed_requests": []
}这些数字要连起来读。列表完成数少,问题可能还没到详情提取阶段;发现 40 个地址却只完成 39 个,应查缺失地址及其错误;完成 40 个地址却只有 39 个 UPC,可能有两个地址指向同一商品,也可能读取了错误的编号。
数量一致也不能证明字段正确。打开 PRODUCTS.json,找到 UPC 为 a897fe39b1053632 的记录,与前面的详情页核对。下面是对应字段的示例结构,数组顺序可能因并发而变化:
{
"upc": "a897fe39b1053632",
"title": "A Light in the Attic",
"price_minor": 5177,
"currency": "GBP",
"source_url": "https://example.16yun.cn/catalogue/a-light-in-the-attic_1000/index.html"
}然后在第二页列表选一本书,例如 In Her Wake,确认输出里确实有对应详情。这样可以检查第一页和第二页的商品来源;仍要结合发现清单和页面实际链接判断覆盖范围,不能因为恰好导出了 40 行就认定没有漏采。
商品字段里的 UPC 负责识别实体,source_url 负责回溯来源,SUMMARY 负责解释处理覆盖。把这三种信息分开保存,后面出现重复、字段变更或漏采时,才能知道该从哪一段链路查起。
常见问题:按发生的位置修复
启动时出现模块或 TypeScript 语法错误
先检查 node --version,再确认文件名是 main.mts。旧 Node 版本、误用 .js 文件或把示例改成 CommonJS,都可能导致示例无法按本文方式加载。
本例不需要在 package.json 中添加 "type": "module",因为 .mts 已明确模块类型。修复后重新执行 node main.mts;如果错误变成无法解析 crawlee 包,检查当前目录是否包含刚才安装生成的 node_modules,而不是继续调整选择器。
只有第一页商品,没有第二页
先看 SUMMARY.json 中的 completed_list_pages。如果只有 [1],检查终端是否有“找不到唯一的下一页链接”,以及最终失败记录中是否出现第二页地址。
在第一页 HTML 中定位分页区域,确认 li.next a 命中一个链接,其 href 解析后指向第二页。若你迁移的页面使用 a[rel="next"],需要同时修改代码中的 next 检查和下一页 enqueueLinks 选择器,只改其中一处会产生相互矛盾的规则。
还要检查 MAX_LIST_PAGES 是否仍为 2,以及是否误把 maxRequestsPerCrawl 调低。修改后重新采集,应该看到列表完成编号包含 1 和 2;若详情发现数仍少,再检查第二页的商品链接选择器。
列表成功,商品名称或价格却为空
查看最终失败记录中的详情地址,打开其网页源代码,搜索书名和价格字段。需要先区分两类情况:HTML 中有字段但规则没命中,或者响应根本不是预期详情。
如果 HTML 有字段,检查 .product_main h1、表格选择器和 Price (incl. tax) 的拼写;表格行名改变时,修改 fields.get(...) 的键,而不是扩大到整个页面取金额。如果收到登录提示或其他页面,应先解决访问条件。如果源码没有字段、浏览器执行 JavaScript 后才出现,则需要换用浏览器抓取器并重新设计等待条件,增加重试不能补出缺失 HTML。
修复后,失败地址应出现在完成集合中,missing_detail_urls 应相应减少;还要回到页面核对价格含义,不能只确认“不再报错”。
完成的详情地址比导出的商品多
先比较相关记录的 UPC。多个分类或带跟踪参数的地址可能指向同一本书,Crawlee 的请求去重和本文按 UPC 合并不是同一件事。请求去重控制地址是否再次调度,业务去重控制最后保留几条商品,参见 请求与队列说明。
如果确实是同一本书,本例只保留最后完成的记录,数量差异可以解释。如果 UPC 不同却被解析成相同值,说明字段规则出错,应修复 UPC 读取逻辑。迁移到有多个规格的商品站点时,还要先决定“一条记录”代表商品还是规格,不能继续无条件按商品编号合并。
程序退出了,为什么还要看失败清单
任务停止运行与所有数据成功提取是两种状态。failedRequestHandler 记录最终失败后,不会自动替你补齐那条商品;本文收尾代码因此检查缺失集合并设置非零退出状态,同时保留已取得的数据和失败原因。
如果只得到部分商品,先按失败 URL 修复访问或字段规则,再重新运行这个小任务。不要删掉失败检查,把文件存在当成完整交付;也不要只把重试次数调大,让固定的规则错误重复发生。
换成自己的商品目录时,按这个顺序修改
先选一个分类的两页列表,修改 START_URL,并确认下一页确实沿着预期分类前进。接着修改商品链接与下一页选择器,再修改详情中的编号、标题、价格字段及解析规则。每一步都保留“发现了哪些地址、成功处理了哪些地址、最终得到哪些业务商品”的对应关系。
当目标是单页列表时,把 MAX_LIST_PAGES 改为 1;当需要更多页时,先估算列表与详情总量,再调整页数和请求上限。不要仅删除范围限制后让任务遍历整个站点。
如果站点依赖动态渲染,需要重新考虑抓取器;如果任务需要中断后继续,需要重新考虑持久化;如果商品量大到不适合放进内存,需要换成能按业务键写入的存储。这些变化各自对应明确的问题,而本篇建立的字段含义、发现范围与结果检查方法仍然适用。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。