TypeScript网页爬虫:使用Cheerio和Node的类型化提取
Web Data Collection Specialist
TL;DR:
- Node 22 直接使用
node --experimental-strip-types运行 TypeScript,因此爬虫无需构建步骤和打包工具。 fetch内置于运行时,使得cheerio成为解析 HTML 和 CSS 选择的唯一依赖项。- 类型化你提取的记录使爬虫可维护:编译器在使用重命名字段时标记错误,而不是在数据已经下游传送后才标记。
- 类型描述的是你期望的结构,而不是你接收到的页面——客户端渲染的页面仍然会返回一个有效的响应,但解析为零条记录。
- Scrapeless 通用抓取 API 首先渲染页面,而相同的不变
cheerio选择器随后返回所有 10 条记录。 - 从 Scrapeless 免费计划 开始,并将支点示例指向你自己的目标。
TypeScript 在爬虫中的地位只因一个原因:你提取的数据有一个结构,而这个结构会漂移。一个网站重命名字段,一个选择器开始返回空字符串,而一个普通的 JavaScript 爬虫会默默地将损害带入任何消费它的地方。一个类型化的记录将其转化为编译错误。
最近改变的是设置成本。Node 22 原生移除类型,因此没有 tsc 步骤,没有打包工具,也没有 ts-node 在依赖树中。
你需要的
在 2026 年进行 TypeScript 网络抓取需要 Node 22 或更高版本和一个依赖。下面的版本是这些示例运行时的版本:
| 组件 | 版本 | 作用 |
|---|---|---|
| Node.js | 22.22.3 | 运行时、原生 fetch、原生类型剥离 |
cheerio |
1.2.0 | HTML 解析和 CSS 选择器 |
fetch 内置于运行时,因此不需要导入也不需要 HTTP 库。cheerio 提供了一个类似 jQuery 的 API 用于解析文档,这是 Node 生态系统中服务器端 HTML 查询的最接近标准的东西。它根据 HTML 解析规范 来解析,而不是将标记当作文本来匹配模式。
安装
创建项目并添加这一个依赖:
bash
mkdir ts-scraper && cd ts-scraper
npm init -y
npm pkg set type=module
npm install cheerio@1.2.0
type=module 设置是重要的:下面的示例使用了顶级 await,这需要 ES 模块语法。
提取类型化记录
首先声明结构,然后使提取产生该结构。编译器会对此进行约束:
typescript
import * as cheerio from "cheerio";
interface Quote {
text: string;
author: string;
tags: string[];
}
const res = await fetch("https://quotes.toscrape.com/");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const $ = cheerio.load(await res.text());
const quotes: Quote[] = $("div.quote")
.map((_, el) => ({
text: $(el).find("span.text").text(),
author: $(el).find("small.author").text(),
tags: $(el).find("a.tag").map((_, t) => $(t).text()).get(),
}))
.get();
console.log(`解析的 quotes 数量: ${quotes.length}`);
console.log(JSON.stringify(quotes[0], null, 2));
运行它时没有构建步骤:
bash
node --experimental-strip-types static.ts
text
解析的 quotes 数量: 10
{
"text": "“我们所创造的世界是我们思维的过程。没有改变我们的思维,世界是无法改变的。”",
"author": "阿尔伯特·爱因斯坦",
"tags": [
"改变",
"深思",
"思维",
"世界"
]
}
其中有三件事情在真正发挥作用。
quotes 上的 Quote 注释使 .map() 回调进行了类型检查。返回缺少 tags 的对象,或者拼写为 tag,错误将在该行出现,而不是在任何消费数组的地方后来显示为 undefined。
res.ok 是人们跳过的检查。fetch 在 404 或 403 上不会抛出异常——它会正常解析并将 ok 设置为 false,错误页面解析为零条匹配,正如一个空结果一样。表现如此的状态类定义在 HTTP 语义规范 中。
嵌套的 .map(...).get() 是 cheerio 将选择转换为真正数组的习惯用法。内部调用收集标签字符串,因此 tags 作为 string[] 到达,而不是作为 cheerio 对象。
类型帮助到此为止
类型描述的是你期望的记录,而不是你接收到的页面。fetch 和 cheerio 都不会运行 JavaScript,因此在一个在浏览器中构建内容的页面上,选择器不会匹配任何内容,类型由一个空数组满足。
上面的站点在 /js/ 上发布了相同数据的客户端渲染双胞胎。指向它的同样解析代码:
typescript
import * as cheerio from "cheerio";
const res = await fetch("https://quotes.toscrape.com/js/");
如果 (!res.ok) throw new Error(HTTP ${res.status});
const html = await res.text();
const $ = cheerio.load(html);
console.log(html 字节数: ${html.length});
console.log(解析的引用: ${$("div.quote").length});
```text
html 字节数: 5806
解析的引用: 0
请求成功,res.ok 为真,解析了 5,806 个有效 HTML 字符,而没有出现任何问题。 Quote[] 是一个完全类型安全的空数组。这是值得设计应对的失败模式,因为类型系统或 HTTP 层的任何内容都不会报告它——引用标记是在脚本运行后写入 DOM 的。
先渲染,再解析
Scrapeless 通用抓取 API 通过在云浏览器中渲染页面并返回生成的 HTML 来填补这个空白,因此 TypeScript 端保持一个类型化的 fetch 调用。
设置你的密钥:
bash
export SCRAPELESS_API_KEY="您的_api_key_在这里"
只有抓取层发生了变化——Quote 接口和选择器代码与第一个示例相同:
typescript
import * as cheerio from "cheerio";
interface Quote {
text: string;
author: string;
tags: string[];
}
const res = await fetch("https://api.scrapeless.com/api/v2/unlocker/request", {
method: "POST",
headers: {
"x-api-token": process.env.SCRAPELESS_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
actor: "unlocker.webunlocker",
input: {
url: "https://quotes.toscrape.com/js/",
js_render: true,
headless: true,
},
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const envelope: { data: string } = await res.json();
const $ = cheerio.load(envelope.data);
const quotes: Quote[] = $("div.quote")
.map((_, el) => ({
text: $(el).find("span.text").text(),
author: $(el).find("small.author").text(),
tags: $(el).find("a.tag").map((_, t) => $(t).text()).get(),
}))
.get();
console.log(`html 字节数: ${envelope.data.length}`);
console.log(`解析的引用: ${quotes.length}`);
console.log(`第一位作者: ${quotes[0]?.author}`);
text
html 字节数: 8940
解析的引用: 10
第一位作者: 阿尔伯特·爱因斯坦
同一页面,同样的选择器,同样的接口。记录数从 0 变为 10,负载从 5,806 增长到 8,940 字符,唯一的区别是获取 HTML 的层次。
这个调用中的两个 TypeScript 细节值得复制。将信封注释为 { data: string } 可以阻止 await res.json() 在文件的其余部分传播 any,这正是类型安全通常在抓取器中泄漏的地方。而 quotes[0]?.author 尊重数组索引可能为 undefined 的事实——在启用 noUncheckedIndexedAccess 时,编译器对此有要求。
data 字段将渲染文档作为字符串保存,这就是它直接传入 cheerio.load 的原因。渲染选项在 Scrapeless 文档 中covered,而相同的 js_render 行为在 JS 渲染指南 中有进一步探讨。
排查故障
.ts 文件上的 ERR_UNKNOWN_FILE_EXTENSION。 缺少 --experimental-strip-types 标志,或者 Node 版本低于 22。类型剥离在加载时移除注释;它不进行类型检查,因此当你想获得编译器的看法时,请单独运行 tsc --noEmit。
Cannot use import statement outside a module。 包缺少 "type": "module"。顶层 await 需要 ES 模块。
类型剥离拒绝枚举或参数属性。 这些构造生成实际的运行时代码,而不是可擦除的,因此剥离无法处理它们。使用字符串文字的联合体代替枚举,并明确分配构造函数字段。
在浏览器中匹配选择器但在脚本中不匹配。 应与查看源代码进行比较,而不是检查器。检查器显示脚本运行后的 DOM,这不是 fetch 接收到的——首先打印响应长度,就像上面的示例一样。
在将其指向实时目标之前,请检查网站的条款及其 /robots.txt 指令,这符合 机器人排除协议标准,并将收集限制在网站可舒适提供的公共数据量内。
结论
TypeScript 为抓取器提供了一份合同:声明记录,编译器会告诉你提取何时不再满足它。借助 Node 22 本机剥离类型和内置的 fetch,该合同只需一个依赖项且无需构建步骤。
无法告诉你的是,您获取的页面是否包含数据。这一检查必须是明确的——一个类型良好的空数组就是客户端渲染页面返回的结果,它看起来与没有结果的页面完全相同。衡量差异是一种值得保持的习惯:服务器渲染了 10 条记录,JavaScript 双胞胎显示 0 条记录,一旦在 cheerio 看到之前有东西渲染了页面,又是 10 条记录。
从 Scrapeless 免费计划开始 针对您自己的目标运行渲染步骤,并在您确定工作规模时查看当前的 Scrapeless 定价。
常见问题解答
问:我需要编译 TypeScript 才能使用它进行抓取吗?
不需要。Node 22 及更高版本可以直接使用 node --experimental-strip-types 运行 .ts 文件,这会在加载时移除类型注释。这意味着没有构建步骤,也不需要打包器来进行抓取。类型剥离不会检查类型,因此当您希望编译器实际验证时,请在 CI 中运行 tsc --noEmit。
问:我应该使用哪个 HTML 解析库与 TypeScript 配合使用?
cheerio 涵盖了大部分工作——它使用符合 HTML 规范的解析器进行解析,并提供带有 TypeScript 定义的 jQuery 风格选择器 API。当内容通过脚本写入 DOM 时,没有任何解析器能独立恢复,因此请寻找无头浏览器或渲染 API。
问:在 Node 中,fetch 在 404 时会抛出异常吗?
不会,这会让人感到困惑。fetch 对任何 HTTP 响应都会正常解析,仅在网络级故障时会拒绝,所以您必须自己检查 res.ok。没有该检查,错误页面会解析为零匹配,无法与确实没有结果的页面区分开来。
问:当响应为 JSON 时,我该如何保持类型的准确性?
在边界处进行注释。await res.json() 返回 any,因此将其分配给如 const envelope: { data: string } 的类型变量,可以阻止 any 在文件的其他部分传播。对于不可信的上游数据,请使用模式库在运行时进行验证,而不是仅依赖注释。
问:TypeScript 能否抓取在浏览器中渲染的页面?
不能单独完成。该语言对 JavaScript 是否执行没有影响——fetch 返回服务器发送的字节,上面的示例显示这些字节在客户端渲染页面上解析为零记录。渲染必须在别处进行,无论是您操作的无头浏览器,还是通过返回已渲染 DOM 的 API。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



