使用 reqwest 和 scraper 的 Rust 网页抓取:实用指南
Expert in Web Scraping Technologies
TL;DR:
- 在2026年的Rust网页抓取中运行两个crate:
reqwest用于HTTP请求,scraper用于CSS选择器解析,tokio驱动异步运行时。 - 编译器强制你处理每一个失败路径,因此一个可以构建的Rust抓取器通常是一个能够应对格式错误的标记和非200响应的抓取器。
tokio::spawn将逐页抓取器变为并发抓取器只需五行代码,本指南在五页中运行,获得50条记录。reqwest和scraper都不执行JavaScript,因此客户端渲染的页面返回的标记解析为零条记录。- Scrapeless通用抓取API首先渲染页面并返回HTML,而相同未更改的
scraper代码解析为10条记录。 - 从Scrapeless免费计划开始,并对你的目标运行pivot示例。
Rust在抓取工作中出现是因为:抓取器通常是数据管道的一部分,不间断地对没人控制的标记运行数小时。一个借用检查的二进制文件,没有垃圾回收器的暂停,并在每个边界上显式处理Result,非常适合这个工作。
本指南创建一个使用当前crate版本的工作抓取器,运行它,然后准确显示纯Rust栈停止的地方——以测量数字而非警告。
你需要的东西
Rust网页抓取需要三个crate和一个稳定的工具链。以下版本是在撰写时< a href="https://crates.io" rel="nofollow">Rust社区crate注册表上的当前发布版本,并且这里的每个示例都是在这些版本上编译和运行的:
| Crate | Version | Job |
|---|---|---|
reqwest |
0.13.4 | HTTP客户端 |
scraper |
0.27.0 | HTML解析和CSS选择器 |
tokio |
1.53.0 | 异步运行时 |
serde_json |
1 | API响应的JSON处理 |
scraper crate封装了html5ever,这是在Servo中使用的相同解析引擎,因此它遵循HTML解析规范,而不是将标记视为要正则表达式匹配的文本。这在实际页面中很重要,因为未闭合的标签是正常的。
安装
创建项目并添加依赖:
bash
cargo new rust-scraper
cd rust-scraper
然后在Cargo.toml中声明依赖块:
toml
[package]
name = "rust-scraper"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.13.4", features = ["json"] }
scraper = "0.27.0"
tokio = { version = "1.53.0", features = ["macros", "rt-multi-thread"] }
serde_json = "1"
reqwest上的json功能引入请求和响应序列化。tokio上的macros和rt-multi-thread功能使#[tokio::main]属性工作。
抓取和解析页面
一个完整的Rust抓取器比其名声所暗示的要短。这一抓取器抓取一个服务器渲染的页面,选择每个引用容器,并从每个容器中提取两个字段:
rust
use scraper::{Html, Selector};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let body = reqwest::get("https://quotes.toscrape.com/")
.await?
.error_for_status()?
.text()
.await?;
let document = Html::parse_document(&body);
let quote_sel = Selector::parse("div.quote").unwrap();
let text_sel = Selector::parse("span.text").unwrap();
let author_sel = Selector::parse("small.author").unwrap();
let mut count = 0;
for quote in document.select("e_sel) {
let text = quote.select(&text_sel).next().map(|e| e.inner_html());
let author = quote.select(&author_sel).next().map(|e| e.inner_html());
if let (Some(t), Some(a)) = (text, author) {
if count == 0 {
println!("第一条引用: {} — {}", t, a);
}
count += 1;
}
}
println!("解析的引用数量: {}", count);
Ok(())
}
运行后会输出:
text
第一条引用: “我们所创造的世界是我们思维的过程。没有改变我们的思维,就无法改变它。” — 阿尔伯特·爱因斯坦
解析的引用数量: 10
那段代码中的三个细节承载着大部分重量。
error_for_status()将4xx或5xx响应转换为Err,而不是让你将错误页面解析为数据。没有它,403的正文会变成零个选择器匹配,看起来与空的结果集相同。那些状态类别之间的区别在HTTP语义规范中定义。
Selector::parse 是可失败的,因为选择器字符串是在匹配时不进行解释而是编译的。在循环外编译选择器——而不是每个元素都编译——就是为何嵌套的 select 调用保持便宜。语法遵循 W3C 选择器级别 4 规范。
从 main 中传播出的 ? 操作符将 Box<dyn std::error::Error> 变成了一个实用的返回类型。每个可失败的调用都会回溯到相同的位置,而该过程在失败时以非零状态退出——当抓取程序从调度器运行时这很有用。
并发抓取页面
Rust 的并发特性是这里使用它的主要理由,而 tokio::spawn 就是体现这一点的地方。每个页面成为一个独立的任务,所有任务共享一个连接池客户端,并按顺序收集结果:
rust
use scraper::{Html, Selector};
async fn page_quote_count(client: &reqwest::Client, page: u32) -> Result<usize, reqwest::Error> {
let url = format!("https://quotes.toscrape.com/page/{page}/");
let body = client.get(&url).send().await?.error_for_status()?.text().await?;
let quote_sel = Selector::parse("div.quote").unwrap();
Ok(Html::parse_document(&body).select("e_sel).count())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let tasks: Vec<_> = (1..=5)
.map(|page| {
let client = client.clone();
tokio::spawn(async move { (page, page_quote_count(&client, page).await) })
})
.collect();
let mut total = 0;
for task in tasks {
let (page, result) = task.await?;
let count = result?;
println!("page {page}: {count} quotes");
total += count;
}
println!("total quotes across 5 pages: {total}");
Ok(())
}
执行结果:
text
page 1: 10 quotes
page 2: 10 quotes
page 3: 10 quotes
page 4: 10 quotes
page 5: 10 quotes
total quotes across 5 pages: 50
reqwest::Client 便宜,是因为克隆时共享底层连接池。每个任务创建一个新客户端会每次打开一个新池并丢弃这一好处。保持页面范围限制在目标可以舒适提供的大小——并发是完成已知工作负载的工具,而不是发出尽可能多的同时请求。
准备将其指向渲染服务器端的目标了吗?创建一个免费的 Scrapeless 账户,并保留你已有的解析代码。
reqwest 和 scraper 的局限
这两个 crate 均不执行 JavaScript。reqwest 返回服务器发送的字节,而 scraper 正好解析这些字节——因此在一个在浏览器中构建内容的页面上,选择器什么也匹配不到。
这是可测量的而非理论的。上述使用的网站在 /js/ 路径上发布了相同数据的客户端渲染版本。将相同的解析逻辑应用于它:
rust
use scraper::{Html, Selector};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let body = reqwest::get("https://quotes.toscrape.com/js/")
.await?
.error_for_status()?
.text()
.await?;
let document = Html::parse_document(&body);
let quote_sel = Selector::parse("div.quote").unwrap();
let count = document.select("e_sel).count();
println!("html bytes: {}", body.len());
println!("quotes parsed: {}", count);
Ok(())
}
text
html bytes: 5808
quotes parsed: 0
请求成功。状态为 200。解析器在 5,808 字节的有效 HTML 上正常工作,但其中没有包含任何引用元素——它们是在脚本运行后写入 DOM 的。只检查 HTTP 失败的抓取程序将其视为空页面而不是缺少能力,这就是先前调用 error_for_status() 是必要但不足的原因。
使用通用抓取 API 渲染页面
Scrapeless 通用抓取 API 通过在云浏览器中渲染页面并返回生成的 HTML 来填补这一空白,这意味着 Rust 端保持为简单的 HTTP 调用。将 js_render 设置为 true,则响应体包含脚本后 DOM。
使用环境中的密钥进行身份验证:
bash
export SCRAPELESS_API_KEY="your_api_key_here"
然后只需替换抓取层——下面的选择器代码与第一个示例相同:
rust
use scraper::{Html, Selector};
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let api_key = std::env::var("SCRAPELESS_API_KEY")?;
let payload = json!({
"actor": "unlocker.webunlocker",
"input": {
"url": "https://quotes.toscrape.com/js/",
"js_render": true,
"headless": true
}
});
let envelope: serde_json::Value = reqwest::Client::new()
.post("https://api.scrapeless.com/api/v2/unlocker/request")
.header("x-api-token", api_key)
.json(&payload)
.send()
.await?
.error_for_status()?
.json()
.await?;
let body = envelope["data"].as_str().unwrap_or_default();
let document = Html::parse_document(body);
let quote_sel = Selector::parse("div.quote").unwrap();
let text_sel = Selector::parse("span.text").unwrap();
let author_sel = Selector::parse("small.author").unwrap();
let mut count = 0;
for quote in document.select("e_sel) {
let text = quote.select(&text_sel).next().map(|e| e.inner_html());
let author = quote.select(&author_sel).next().map(|e| e.inner_html());
if let (Some(t), Some(a)) = (text, author) {
if count == 0 {
println!("第一句名言: {} — {}", t, a);
}
count += 1;
}
}
println!("HTML字节数: {}", body.len());
println!("解析的名言数量: {}", count);
Ok(())
}
text
第一句名言: “我们所创造的世界是我们思维的结果。没有改变我们的思维,就无法改变它。” — 阿尔伯特·爱因斯坦
HTML字节数: 8985
解析的名言数量: 10
同一个页面,相同的选择器,相同的库版本。唯一的变化是哪个层级获取了HTML,当有效记录数量从0变为10时,传输的字节从5,808增加到8,985字节——区别在于脚本写入DOM的名言标记。
响应作为JSON信封到达,渲染的文档在data中作为字符串,这就是为什么示例首先解析为serde_json::Value并将data传递给Html::parse_document。渲染选项的详细信息可以在Scrapeless文档中找到,而相同的js_render行为在JS渲染指南中有更深入的讨论。
故障排除
选择器在您可以在浏览器中看到的页面上匹配不到任何内容。 首先打印响应长度,如上面的JS示例所示。几千字节但没有匹配通常意味着内容是客户端渲染的,而不是选择器错误。查看页面源代码,而不是检查器——检查器显示的是脚本运行后的DOM,这并不是reqwest接收到的内容。
Selector::parse在启动时崩溃。 选择器字符串是无效的CSS。伪元素和一些jQuery风格的扩展不属于规范的一部分,因此无法编译。
构建在TLS后端上失败。 reqwest编译了一个TLS栈;在一个最小的容器上,系统需要一个C工具链和CMake可用,或者您可以通过功能标志切换到纯Rust的rustls后端。
inner_html()返回标记而不是文本。 该方法返回元素内部的所有内容,包括标签。使用text()并在一个字段能够包含嵌套元素时收集片段。
在针对实时目标指向任何这些内容之前,请检查网站的条款及其/robots.txt指令,它遵循机器人排除协议标准。确保收集公共数据,并保持在目标可以舒适提供的量内。
结论
Rust为您提供了一个明确处理失败并且无太多程序随意并行化的抓取器——reqwest用于传输,scraper用于选择器,tokio用于并发,以及一个不会让您忽视Result的编译器。该堆栈彻底覆盖了服务器渲染的页面。
但它不执行JavaScript,这一差距的代价是无声的零,而不是错误。测量它是有用的习惯:在服务器渲染的页面上10条记录,在客户端渲染的页面上0条,最后在Rust解析之前有10条记录得以渲染。
从Scrapeless免费计划开始,以便针对您自己的目标运行渲染步骤,并在给工作定价时查看当前的Scrapeless定价。
常见问题
问:哪个Rust库最适合网络抓取?
reqwest与scraper配合使用即可处理大部分工作。reqwest使用异步优先的API处理HTTP请求,scraper则提供对html5ever解析树的CSS选择器查询。当页面在客户端构建其内容时,才需要使用无头浏览器库或渲染API。
问:与Python相比,Rust适合网页抓取吗?
当抓取程序运行时间较长、并发运行或无人值守时,Rust是一个不错的选择,因为它没有垃圾回收暂停,并且编译器强制处理每个错误路径。对于一次性提取,Python在生态系统广度和迭代速度上仍然占优。解析概念在两者之间直接转移。
问:reqwest能执行JavaScript吗?
不能。reqwest是一个HTTP客户端,只返回服务器发送的字节。在客户端渲染的页面上,这意味着有效的HTML,但其中没有内容——上面的示例将5,808个字节解析为零条记录。渲染必须在其他地方进行,或是在返回渲染后的DOM的API中。
问:我需要async和tokio来实现一个简单的抓取程序吗?
不一定——reqwest在一个特性标志下提供一个阻塞客户端,这对于单个顺序请求是可以的。一旦你抓取多个页面,异步客户端就值得引入tokio依赖,因为在这里tokio::spawn将顺序抓取转换为并发抓取。
问:我如何保持Rust爬虫在网站变化时不崩溃?
对结构进行断言,而不是盲目信任。在提取字段之前,检查容器选择器是否匹配合理数量的元素,并将突然出现的零视为失败,而不是空结果。在启动时编译选择器也能立即显示无效的CSS,而不是在爬取过程中。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



