rust web scraping

Rust 网页抓取是指借助 Rust 编程语言及其 crate 生态系统获取并解析网页。Rust 无需垃圾回收器即可提供可预测的性能和内存安全性,因此特别适合持续运行或处理大规模任务的爬虫。

本指南以 reqwest HTTP 客户端和 scraper 解析库为核心,采用实战代码优先的方式讲解。内容涵盖项目配置、最小化抓取、CSS 选择器、结构化数据模型、JSON 和 CSV 导出、分页、带重试与退避的错误处理、基于 tokio 的异步并发、代理配置,以及适用于 JavaScript 密集型网站的无头浏览器方案。每段代码均可编译运行。

DataImpulse 是一家注重合规与道德采购的代理服务商,在 195 个国家和地区提供超过 9000 万个住宅、移动和数据中心 IP 地址。采用按量付费模式,价格每 GB 1 美元起,流量永不过期,适用于网页抓取、广告验证、价格监控、市场研究和多账号管理。

主要事实

  • 核心堆栈: Rust 网页抓取通常使用 reqwest HTTP 客户端搭配 scraper crate 解析 HTML,再配合 tokio 实现异步并发、serde 导出结构化数据。
  • 最佳代理类型: 轮换住宅代理,使用来自真实用户设备的 IP。
  • 价格: 每 GB 1 美元起,即用即付,流量不过期,无需订阅。
  • 覆盖范围: 覆盖 195 个国家和地区,提供超过 9000 万个道德采购的 IP。
  • 可靠性: 99.51% 的成功率,在 G2 上评分为 4.8 分(满分 5 分)。
  • 协议和目标: 支持 HTTP、HTTPS 和 SOCKS5,并可定位目标国家和地区。
Rust 从获取到导出的网络抓取管道

为什么用 Rust 进行网页抓取?

当速度、低内存占用和可靠性比快速原型更重要时,Rust 很适合网页抓取。其编译后的二进制程序可高效接近硬件运行,借用检查器还能在编译阶段而非生产环境中发现整类并发错误。

这些特性在常见场景中尤为有价值:

  • 吞吐量: 与解释型语言相比,编译后的 Rust 爬虫能以更低的 CPU 和内存开销处理大量页面,从而降低长期任务的基础设施成本。
  • 安全并发: 异步运行时 tokio 支持数千个并发请求,类型系统则可防止任务之间发生数据竞争。
  • 可预测的资源使用: 没有垃圾回收器意味着延迟更稳定,有助于爬虫连续稳定运行数小时乃至数天。

代价是学习曲线更陡、编译时间更长,因此 Rust 更适合大规模、长期运行的项目,而非一次性脚本。无论使用哪种语言,都应遵循同样的基本原则:尊重 robots.txt、限制请求频率,并遵循网页抓取最佳实践,让你的爬虫成为合规的网络参与者。

如何搭建 Rust 网页抓取项目?

创建新的 Cargo 包,并在 Cargo.toml 中声明所需 crate,即可搭建 Rust 网页抓取项目。Cargo 是 Rust 的构建工具和包管理器,能够解析依赖并编译项目。

运行 cargo new rust-scraper 创建项目,然后编辑生成的 Cargo.toml。以下依赖涵盖本指南的全部内容:HTTP、解析、异步、序列化、CSV 输出、流处理以及易用的错误处理。

[package]
name = "rust-scraper"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.12", features = ["json", "socks"] }
scraper = "0.20"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
csv = "1.3"
futures = "0.3"
anyhow = "1"
thiserror = "1"

其中,reqwest 负责处理请求(socks 功能启用 SOCKS5 代理,json 功能提供 JSON 辅助功能),scraper 用 CSS 选择器解析 HTML,tokio 提供异步运行时,serde 和 serde_json 用于数据序列化,csv 用于写入表格文件,futures 提供并发流组合器,anyhow 与 thiserror 用于简化错误处理。编写第一个请求前,先运行 cargo build 下载并编译依赖。

如何用 reqwest 和 scraper 获取并解析页面?

使用 reqwest 发送 GET 请求获取页面,再将响应正文交给 scraper,通过 CSS 选择器查询内容。下面的示例在 tokio 中异步运行,获取页面并打印每个 H1 元素的文本。

use scraper::{Html, Selector};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let url = "https://example.com";
    let body = reqwest::get(url).await?.text().await?;

    let document = Html::parse_document(&body);
    let heading = Selector::parse("h1").unwrap();

    for element in document.select(&heading) {
        let text: String = element.text().collect();
        println!("{}", text.trim());
    }
    Ok(())
}

流程始终相同:reqwest::get 返回响应,.text() 将响应正文读为字符串,Html::parse_document 再把字符串转换为可查询的树。? 运算符会将网络或解码错误传播给 main。对匹配元素调用 .text() 可收集其文本节点,随后即可修剪并使用这些文本。这种直接处理原始 HTML 的方法很快,但它只能看到服务器返回的标记,无法看到浏览器随后用 JavaScript 渲染的内容。

如何使用 CSS 选择器定位元素?

将标准 CSS 选择器字符串传给 Selector::parse,即可定位目标元素,用法与浏览器控制台类似。scraper crate 支持标签、类、id、属性匹配、后代路径和结构伪类。

use scraper::Selector;

// Match by tag
let links = Selector::parse("a").unwrap();

// Match by class
let prices = Selector::parse(".price").unwrap();

// Match by id
let main = Selector::parse("#main-content").unwrap();

// Match by attribute value
let external = Selector::parse("a[rel=\"nofollow\"]").unwrap();

// Descendant path (a title inside a product card)
let titles = Selector::parse("div.product h2.title").unwrap();

// Direct child plus pseudo-class
let first_row = Selector::parse("table > tbody > tr:first-child").unwrap();

每个选择器应只解析一次并重复使用,不要在循环内反复解析,尽管单次解析开销很小。当选择器可能匹配不到元素时,优先使用带默认值的 .next(),而不是对元素调用 .unwrap(),以免缺失字段导致整个程序 panic。在浏览器开发者工具中检查目标页面,是找到稳定类名和属性的最快方式。

如何将抓取数据导出为 JSON 和 CSV?

可将每条记录建模为派生 Serialize 的结构体,再用 serde_json 将结构体切片写入 JSON,或用 csv crate 写入 CSV,从而导出抓取数据。类型化模型能让提取与输出保持一致,也让代码更易读。

首先,对记录进行建模并从每个容器元素中提取字段:

use scraper::{Html, Selector};
use serde::Serialize;

#[derive(Debug, Serialize)]
struct Product {
    name: String,
    price: String,
    url: String,
}

fn parse_products(html: &str) -> Vec<Product> {
    let document = Html::parse_document(html);
    let card = Selector::parse("div.product").unwrap();
    let name_sel = Selector::parse("h2.title").unwrap();
    let price_sel = Selector::parse("span.price").unwrap();
    let link_sel = Selector::parse("a").unwrap();

    let mut products = Vec::new();
    for card_el in document.select(&card) {
        let name = card_el
            .select(&name_sel)
            .next()
            .map(|e| e.text().collect::<String>().trim().to_string())
            .unwrap_or_default();
        let price = card_el
            .select(&price_sel)
            .next()
            .map(|e| e.text().collect::<String>().trim().to_string())
            .unwrap_or_default();
        let url = card_el
            .select(&link_sel)
            .next()
            .and_then(|e| e.value().attr("href"))
            .unwrap_or_default()
            .to_string();

        products.push(Product { name, price, url });
    }
    products
}

由于 Product 派生了 Serialize,同一个结构体可直接为两个导出器提供数据:

use std::fs::File;
use std::io::Write;

fn export_json(products: &[Product]) -> anyhow::Result<()> {
    let json = serde_json::to_string_pretty(products)?;
    let mut file = File::create("products.json")?;
    file.write_all(json.as_bytes())?;
    Ok(())
}

fn export_csv(products: &[Product]) -> anyhow::Result<()> {
    let mut writer = csv::Writer::from_path("products.csv")?;
    for product in products {
        writer.serialize(product)?;
    }
    writer.flush()?;
    Ok(())
}

serde_json 的 to_string_pretty 会生成便于阅读的 JSON,csv crate 的 serialize 则将每个结构体映射为一行,并根据字段名生成表头。抓取、JSON 和 CSV 共用同一种类型模型,可让 Rust 管道在目标网站变化时更易维护。

如何使用分页循环抓取多个页面?

可通过循环页码或下一页 URL 抓取多个页面,并复用同一个 reqwest 客户端以在请求之间复用连接。构造每个 URL 后获取页面,使用上一节的函数解析,并在页面不再返回结果时停止。

use scraper::Html;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let client = reqwest::Client::new();
    let mut all_products = Vec::new();

    for page in 1..=10 {
        let url = format!("https://example.com/products?page={}", page);
        let body = client.get(&url).send().await?.text().await?;
        let products = parse_products(&body);

        // Stop when a page returns no results
        if products.is_empty() {
            break;
        }
        all_products.extend(products);

        // Pause between requests to stay polite
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }

    println!("Collected {} products", all_products.len());
    Ok(())
}

在循环外只创建一次 reqwest::Client::new(),即可复用 TCP 连接,明显快于每次请求都新建客户端。到达最后一页时,空结果检查会自然结束循环;短暂暂停能拉开请求间隔,避免压垮服务器。对于使用下一页链接而非页码的网站,请在每次迭代中提取下一页锚点的 href 并持续跟随,直至链接消失。

如何通过退避处理错误和重试?

可让抓取函数返回类型化错误,并使用指数退避重试暂时性故障。网络会超时,服务器也会返回临时状态码,因此健壮的爬虫应将单次故障视为可预期情况,而非致命错误。

thiserror crate 可用于定义简洁的错误枚举,并自动从 reqwest 错误转换:

use thiserror::Error;

#[derive(Error, Debug)]
enum ScrapeError {
    #[error("request failed: {0}")]
    Request(#[from] reqwest::Error),

    #[error("unexpected status code: {0}")]
    Status(reqwest::StatusCode),

    #[error("selector matched no elements")]
    Empty,
}

async fn fetch_page(client: &reqwest::Client, url: &str) -> Result<String, ScrapeError> {
    let response = client.get(url).send().await?;
    if !response.status().is_success() {
        return Err(ScrapeError::Status(response.status()));
    }
    Ok(response.text().await?)
}

#[from] 属性表示 reqwest 错误会通过 ? 运算符自动转换为 ScrapeError::Request。除了类型化错误外,还应将请求放入重试循环,并在每次尝试后延长等待时间:

use std::time::Duration;

async fn fetch_with_backoff(client: &reqwest::Client, url: &str) -> anyhow::Result<String> {
    let max_attempts = 5;
    for attempt in 1..=max_attempts {
        match client.get(url).send().await {
            Ok(resp) if resp.status().is_success() => {
                return Ok(resp.text().await?);
            }
            Ok(resp) => {
                eprintln!("attempt {attempt}: status {}", resp.status());
            }
            Err(err) => {
                eprintln!("attempt {attempt}: {err}");
            }
        }
        // Exponential backoff: 0.5s, 1s, 2s, 4s, 8s
        let backoff = Duration::from_millis(500 * 2u64.pow(attempt - 1));
        tokio::time::sleep(backoff).await;
    }
    anyhow::bail!("giving up on {url} after {max_attempts} attempts")
}

指数退避(此处为 0.5 秒、1 秒、2 秒、4 秒、8 秒)能让受速率限制或短暂过载的服务器有时间恢复,避免持续施压。结合轮换代理效果更佳:在一个 IP 上被拦截或限制的请求,可能会在下一次使用不同出口 IP 时成功。

如何使用 tokio 运行并发请求?

将 URL 列表转换为异步流并应用 buffer_unordered,即可运行并发请求;它会限制同时处理的请求数量。这是提升 Rust 爬虫性能最有效的手段之一,tokio 加上 futures crate 可确保并发安全。

use futures::stream::{self, StreamExt};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let client = reqwest::Client::new();
    let urls: Vec<String> = (1..=50)
        .map(|n| format!("https://example.com/item/{}", n))
        .collect();

    let bodies = stream::iter(urls)
        .map(|url| {
            let client = client.clone();
            async move {
                let text = client.get(&url).send().await?.text().await?;
                Ok::<_, reqwest::Error>((url, text))
            }
        })
        .buffer_unordered(10); // at most 10 requests in flight

    bodies
        .for_each(|result| async {
            match result {
                Ok((url, body)) => println!("{}: {} bytes", url, body.len()),
                Err(err) => eprintln!("error: {}", err),
            }
        })
        .await;

    Ok(())
}

这里,stream::iter 将 URL 列表转换为流,.map 为每个 URL 发起异步请求,buffer_unordered(10) 最多同时运行十个请求,并在完成后产出结果。每个任务克隆客户端的成本很低,因为 reqwest 的 Client 通过 Arc 共享连接池。可调整并发上限以平衡速度与目标网站负载:10 到 20 是合理起点;更高并发应配合代理池,将请求分散到多个 IP。

如何通过代理路由 Rust 抓取流量?

使用 reqwest::Proxy 构建 reqwest 客户端,并通过 .basic_auth 附加网关与凭据,即可让流量经由代理转发。轮换 IP 能分散负载、访问特定地理位置的内容,并降低被速率限制的概率。轮换 IP 是避免抓取被拦截的有效手段之一。

use reqwest::Proxy;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Authenticated gateway; rotates the exit IP on each request
    let proxy = Proxy::all("http://gw.dataimpulse.com:823")?
        .basic_auth("login", "password");

    let client = reqwest::Client::builder()
        .proxy(proxy)
        .timeout(std::time::Duration::from_secs(30))
        .build()?;

    let body = client
        .get("https://httpbin.org/ip")
        .send()
        .await?
        .text()
        .await?;

    println!("{}", body);
    Ok(())
}

请将 loginpassword 替换为自己的凭据,并按所需代理网络调整端口。若代理以 407 状态拒绝请求,几乎总是因为凭据缺失或有误;HTTP 错误 407 指南及代理身份验证教程说明了修复方法。DataImpulse 提供住宅代理数据中心代理移动代理,支持 HTTP、HTTPS 和 SOCKS5,并提供轮换与粘性会话。国家和地区定位包含在服务中,州、城市、ZIP 和 ASN 定位则为付费附加项。

如何抓取 JavaScript 密集型页面?何时该选择 Rust?

对于 JavaScript 密集型页面,应驱动真实的无头浏览器进行抓取,因为 reqwest 和 scraper 只能看到服务器返回的原始 HTML,无法执行脚本。当内容仅在客户端渲染后出现时,可使用浏览器自动化 crate,例如 thirtyfour(使用 WebDriver 协议)或 chromiumoxide(通过 DevTools Protocol 控制 Chrome)。

use thirtyfour::prelude::*;

#[tokio::main]
async fn main() -> WebDriverResult<()> {
    let caps = DesiredCapabilities::chrome();
    let driver = WebDriver::new("http://localhost:9515", caps).await?;

    driver.goto("https://example.com").await?;

    // Wait for client-side content to render, then read it
    let heading = driver.find(By::Css("h1")).await?;
    println!("{}", heading.text().await?);

    driver.quit().await?;
    Ok(())
}

thirtyfour 示例会连接到运行中的 chromedriver、加载页面、等待元素渲染并读取文本。这种方法比普通 HTTP 获取开销大得多,因此只应将其用于确有需要的页面,其他页面仍使用快速的 reqwest 路径。若想深入了解这一权衡,请参阅抓取动态网页使用 JavaScript 进行网页抓取。下表概述主要 crate 的组合方式:

crate 角色 运行 JavaScript 最适合
reqwest HTTP 客户端 获取原始 HTML 和 JSON API
scraper HTML 解析器 通过 CSS 选择器提取数据
thirtyfour WebDriver客户端 是的 完整的浏览器自动化与表单操作
chromiumoxide DevTools Protocol 是的 快速控制无头 Chrome

在语言选择上,若需要大规模持续性能和较低资源占用,请选择 Rust;若更看重开发速度及更丰富的抓取生态系统(如 Scrapy 和 Playwright),请选择 Python。常见做法是先用 Python 为提取规则制作原型,待逻辑稳定、规模增长后,再将性能关键路径迁移到 Rust。无论选择何种语言,网络层都与解析器同样重要;道德采购的代理可提升大型任务的可靠性。

Rust关键箱子及​​其刮擦作用

常见问题

哪些 Rust crate 最适合网页抓取?

最常见的组合是用 reqwest 发起 HTTP 请求、用 scraper 解析 HTML 和使用 CSS 选择器。再添加 tokio 实现异步并发、serde 导出数据;当网站需要 JavaScript 渲染时,可添加 thirtyfour 或 chromiumoxide 等浏览器自动化 crate。

Rust 可以抓取 JavaScript 渲染的页面吗?

仅靠 reqwest 和 scraper 不行,因为它们只能看到服务器返回的原始 HTML,无法执行脚本。要抓取客户端渲染的内容,可通过 WebDriver crate(如 thirtyfour)或 DevTools Protocol crate(如 chromiumoxide)驱动无头浏览器。

如何导出 Rust 爬取的数据?

将每条记录建模为派生 serde 序列化功能的结构体,然后用 serde_json 将结构体切片写入 JSON,或用 csv crate 写入 CSV。同一种类型化模型可服务两种格式,无需重复提取逻辑。

如何在不使站点超载的情况下运行并发请求?

将 URL 转换为异步流,并应用设置固定上限的 buffer_unordered,以限制同时运行的请求数。可从大约 10 到 20 个并发请求开始;更高的并发应搭配轮换代理池,将负载分散到多个 IP。

我需要 Rust 网络抓取代理吗?

小规模任务未必需要,但在大规模分发请求和访问特定地理位置内容时,代理非常重要。使用 reqwest::Proxy 和 basic_auth 配置代理,并使用轮换住宅或移动 IP,以减少速率限制和拦截。

何时不适合使用 DataImpulse?

如果你需要静态 ISP 代理、完全托管的抓取 API,或需要访问银行与政府网站,DataImpulse 并不适合。它专注于通过轮换住宅、移动和数据中心代理收集公开数据及访问内容。

使用道德代理可靠地进行抓取

当 Rust 爬虫已能稳定地获取、解析和导出数据后,可靠的代理网络可帮助它持续大规模运行。DataImpulse 提供按量付费、道德采购的 IP,价格每 GB 1 美元起,流量永不过期;你可以创建账户,几分钟内即可通过代理发送第一个请求。


Share article: