返回博客
指南
Mihai MaximLast updated on Mar 31, 20262 min read

《Rust 网页抓取入门指南》

《Rust 网页抓取入门指南》

Rust 适合用于网页抓取吗?

Rust 是一种专为速度和效率而设计的编程语言。与 C 或 C++ 不同,Rust 集成了包管理器和构建工具。它还拥有出色的文档以及一个友好的编译器,能提供有用的错误提示。虽然需要一段时间来适应其语法,但一旦掌握,你就会发现只需几行代码就能实现复杂的功能。使用 Rust 进行网页抓取是一次极具成就感的体验。 你可以使用功能强大的爬取库,它们会为你处理大部分繁重的工作。因此,你可以将更多时间投入到更有趣的部分,比如设计新功能。在本文中,我将带你逐步了解如何使用 Rust 构建一个网页爬取器。 

如何安装 Rust

安装 Rust 的过程非常简单。访问 Install Rust - Rust Programming Language (rust-lang.org),并按照适用于你操作系统的推荐教程进行操作。该页面会根据你使用的操作系统显示不同的内容。安装完成后,请确保打开一个全新的终端并运行 rustc --version。如果一切顺利,你应该能看到已安装的 Rust 编译器的版本号。

既然我们要构建一个网页爬虫,那就用 Cargo 创建一个 Rust 项目吧。Cargo 是 Rust 的构建系统和包管理器。如果您使用了 rust-lang.org 提供的官方安装程序,Cargo 应该已经安装好了。在终端中输入以下命令检查 Cargo 是否已安装:cargo --version。如果看到版本号,说明已安装! 如果看到错误提示(例如“命令未找到”),请查阅您所用安装方式的文档,以确定如何单独安装 Cargo。要创建项目,请导航至目标项目位置并运行 cargo new <项目名称>。

这是默认的项目结构:

  •   您需要在 .rs 文件中编写代码。
  •   您在 Cargo.toml 文件中管理依赖项。
  •   访问 crates.io:Rust 包注册表,查找 Rust 相关包。

使用 Rust 构建网络爬虫

现在让我们看看如何使用 Rust 构建一个爬虫。第一步是明确目标:我想提取什么数据?下一步是决定如何存储爬取的数据。大多数人会将其保存为 .json 格式,但你通常应考虑更适合自身需求的格式。 明确了这两点要求后,你就可以有信心地着手实现任何爬虫了。为了更好地说明这个过程,我建议我们构建一个小工具,从 COVID Live - Coronavirus Statistics - Worldometer(worldometers.info)网站中提取新冠数据。该工具应解析报告病例的表格,并将数据存储为 .json 格式。我们将在接下来的章节中共同创建这个爬虫。

使用 HTTP 请求获取 HTML

要提取表格,首先需要获取网页内的 HTML 内容。我们将使用“reqwest”库从网站获取原始 HTML。

首先,在 Cargo.toml 文件中将其添加为依赖项:

reqwest = { version = "0.11", features = ["blocking", "json"] }

然后定义目标 URL 并发送请求:

let url = "https://www.worldometers.info/coronavirus/";
let response = reqwest::blocking::get(url).expect("Could not load url.");

“blocking” 特性确保请求以同步方式执行。因此,程序将等待请求完成后再继续执行后续指令。 

let raw_html_string = response.text().unwrap();

使用 CSS 选择器定位数据

您已获取所有必要的原始数据。现在需要找到定位病例报告表格的方法。Rust 语言中用于此类任务的最流行库名为“scraper”。它支持通过 CSS 选择器进行 HTML 解析和查询。

将此依赖项添加到你的 Cargo.toml 文件中:

scraper = "0.13.0"

将以下模块添加到 main.rs 文件中。

use scraper::Selector;
use scraper::Html;

现在使用原始 HTML 字符串创建一个 HTML 片段:

let html_fragment = Html::parse_fragment(&raw_html_string);

我们将筛选出显示今日、昨日及前两日报告病例数的表格。

打开开发者控制台并识别表格 ID:

在撰写本文时,今天的 ID 为:“main_table_countries_today”。

另外两个表格 ID 分别为: “main_table_countries_yesterday” 和 “main_table_countries_yesterday2”

现在让我们定义一些选择器:

let table_selector_string = "#main_table_countries_today, #main_table_countries_yesterday, #main_table_countries_yesterday2";

let table_selector = Selector::parse(table_selector_string).unwrap();

let head_elements_selector = Selector::parse("thead>tr>th").unwrap();
 
let row_elements_selector = Selector::parse("tbody>tr").unwrap();
 
let row_element_data_selector = Selector::parse("td, th").unwrap();

将 table_selector_string 传递给 html_fragment 的 select 方法,以获取所有表格的引用:

let all_tables = html_fragment.select(&table_selector);

利用这些表格引用,创建一个循环来解析每个表格中的数据。

for table in all_tables{
   let head_elements = table.select(&head_elements_selector);
    for head_element in head_elements{
        //parse the header elements
    }

   let head_elements = table.select(&head_elements_selector);
   for row_element in row_elements{
    for td_element in row_element.select(&row_element_data_selector){
        //parse the individual row elements
    }
   }
}

解析数据

数据的存储格式决定了解析方式。本项目采用 .json 格式,因此我们需要将表格数据转换为键值对。我们可以使用表格的表头名称作为键,表格的行数据作为值。 

使用 .text() 函数提取表头并将其存储在 Vector 中:

//for table in tables loop
let mut head:Vec<String> = Vec::new();

let head_elements = table.select(&head_elements_selector);

for head_element in head_elements{
    let mut element = head_element.text().collect::<Vec<_>>().join(" ");
    element = element.trim().replace("\n", " ");
    head.push(element);
}


//head
["#", "Country, Other", "Total Cases", "New Cases", "Total Deaths", ...]

以类似方式提取行数据:

//for table in tables loop
let mut rows:Vec<Vec<String>> = Vec::new();

let row_elements = table.select(&row_elements_selector);

for row_element in row_elements{
 let mut row = Vec::new();
 for td_element in row_element.select(&row_element_data_selector){
     let mut element = td_element.text().collect::<Vec<_>>().join(" ");
     element = element.trim().replace("\n", " ");
     row.push(element);
 }
 rows.push(row)

}
//rows
[...
["", "World", "625,032,352", "+142,183", "6,555,767", ...]
...
["2", "India", "44,604,463", "", "528,745", ...]
...]

使用 zip() 函数建立表头与行值之间的映射关系:

for row in rows {
    let zipped_array = head.iter().zip(row.iter()).map(|(a, b)|                        
    (a,b)).collect::<Vec<_>>();
 }

//zipped_array
[
...
[("#", ""), ("Country, Other", "World"), ("Total Cases", "625,032,352"), ("New Cases", "+142,183"), ("Total Deaths", "6,555,767"), ...]
...
]

现在将这些 (键, 值) 对存储到 IndexMap 中:

serde = {version="1.0.0",features = ["derive"]}

indexmap = {version="1.9.1", features = ["serde"]} (add these dependencies)

use indexmap::IndexMap;

//use this to store all the IndexMaps 
let mut table_data:Vec<IndexMap<String, String>> = Vec::new();
for row in rows {
    let zipped_array = head.iter().zip(row.iter()).map(|(a, b)|                        
    (a,b)).collect::<Vec<_>>();
    let mut item_hash:IndexMap<String, String> = IndexMap::new();
    for pair in zipped_array{
        //we only want the non empty values
        if !pair.1.to_string().is_empty(){
            item_hash.insert(pair.0.to_string(), pair.1.to_string());
        }
    }
table_data.push(item_hash);

//table_data
[
...
{"Country, Other": "North America", "Total Cases": "116,665,220", "Total Deaths": "1,542,172", "Total Recovered": "111,708,347", "New Recovered": "+2,623", "Active Cases": "3,414,701", "Serious, Critical": "7,937", "Continent": "North America"}
,
{"Country, Other": "Asia", "Total Cases": "190,530,469", "New Cases": "+109,009", "Total Deaths": "1,481,406", "New Deaths": "+177", "Total Recovered": "184,705,387", "New Recovered": "+84,214", "Active Cases": "4,343,676", "Serious, Critical": "10,640", "Continent": "Asia"}
...
]

IndexMap 是存储表格数据的绝佳选择,因为它能保留 (key, value) 对的插入顺序。

数据序列化

既然可以利用表格数据创建类似 JSON 的对象,现在是时候将其序列化为 .json 文件了。开始之前,请确保已安装以下所有依赖项:

serde = {version="1.0.0",features = ["derive"]}
serde_json = "1.0.85"
indexmap = {version="1.9.1", features = ["serde"]}

将每个 table_data 存储在 tables_data 向量中:

let mut tables_data: Vec<Vec<IndexMap<String, String>>> = Vec::new();

For each table:
    //fill table_data (see previous chapter)
    tables_data.push(table_data);

为 tables_data 定义一个结构体容器:

 #[derive(Serialize)]
  struct FinalTableObject {
         tables: IndexMap<String, Vec<IndexMap<String, String>>>,
 }

实例化该结构体:

let final_table_object = FinalTableObject{tables: tables_data};

将结构体序列化为 .json 字符串:

let serialized = serde_json::to_string_pretty(&final_table_object).unwrap();

将序列化的 .json 字符串写入 .json 文件:

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

let path = "out.json";

    let mut output = File::create(path).unwrap();

    let result = output.write_all(serialized.as_bytes());

    match result {

          Ok(()) => println!("Successfully wrote to {}", path),

          Err(e) => println!("Failed to write to file: {}", e),

    }

好啦,大功告成。如果一切顺利,生成的 .json 文件应如下所示:

{
  "tables": [
    [ //table data for #main_table_countries_today
      { 
       "Country, Other": "North America",
       "Total Cases": "116,665,220",
       "Total Deaths": "1,542,172",
       "Total Recovered": "111,708,347",
       "New Recovered": "+2,623",
       "Active Cases": "3,414,701",
       "Serious, Critical": "7,937",
       "Continent": "North America"
      },
      ...
    ],
    [...table data for #main_table_countries_yesterday...],
    [...table data for #main_table_countries_yesterday2...],
  ]
}

你可以在 [Rust][一个简单的 <table> 抓取工具] (github.com) 找到该项目的完整代码

调整以适应其他用例

如果你一直跟到这里,你可能已经意识到这个抓取工具可以应用于其他网站。该抓取工具并不受限于特定的表格列数或命名规范,也不依赖于大量的 CSS 选择器。因此,要让它适用于其他表格应该不需要太多调整,对吧?让我们来验证一下这个理论。

我们需要一个用于 <table> 标签的选择器。

如果 class="wikitable sortable jquery-tablesorter",你可以将 table_selector 修改为:

let table_selector_string = ".wikitable.sortable.jquery-tablesorter";
let table_selector = Selector::parse(table_selector_string).unwrap();

该表格具有相同的 <thead> <tbody> 结构,因此没有必要更改其他选择器。

现在爬虫应该可以运行了。让我们试运行一下:

{
  "tables": []
}

用 Rust 进行网页抓取很有趣,不是吗? 

这怎么会失败呢? 

让我们深入探究一下:

找出问题所在最简单的方法是查看 GET 请求返回的 HTML:

let url = "https://en.wikipedia.org/wiki/List_of_countries_by_population_in_2010";


let response = reqwest::blocking::get(url).expect("Could not load url.");

et raw_html_string = response.text().unwrap();

let path = "debug.html";


let mut output = File::create(path).unwrap();

let result = output.write_all(raw_html_string.as_bytes());

GET请求返回的HTML与我们在实际网站上看到的有所不同。浏览器为JavaScript提供了运行环境,使其能够改变页面的布局。而在我们的爬虫中,我们获取的是未经修改的原始版本。

我们的 table_selector 无法正常工作,是因为“jquery-tablesorter”类是由 JavaScript 动态注入的。此外,你可以看到 <table> 的结构有所不同。缺少了 <thead> 标签。表格的表头元素现在位于 <tbody> 中的第一个 <tr> 内。因此,它们会被 row_elements_selector 捕获。

仅从 table_selector 中移除“jquery-tablesorter”还不够,我们还需要处理缺少 <tbody> 的情况:

let table_selector_string = ".wikitable.sortable";

 if head.is_empty() {
    head=rows[0].clone();
    rows.remove(0);
 }// take the first row values as head if there is no <thead>

现在让我们再试一次:

{
  "tables": [
    [
      {
        "Rank": "--",
        "Country / territory": "World",
        "Population 2010 (OECD estimate)": "6,843,522,711"
      },
      {
        "Rank": "1",
        "Country / territory": "China",
        "Population 2010 (OECD estimate)": "1,339,724,852",
        "Area (km 2 ) [1]": "9,596,961",
        "Population density (people per km 2 )": "140"
      },
      {
        "Rank": "2",
        "Country / territory": "India",
        "Population 2010 (OECD estimate)": "1,182,105,564",
        "Area (km 2 ) [1]": "3,287,263",
        "Population density (people per km 2 )": "360"
      },
      ...
     ]
]

这样就好多了!

总结

希望本文能为使用 Rust 进行网页抓取提供一个良好的参考。尽管 Rust 丰富的类型系统和所有权模型可能会让人有些不知所措,但它绝非不适合网页抓取。你会得到一个友好的编译器,它会不断为你指明正确的方向。你还会遇到大量编写精良的文档:《Rust 编程语言》- The Rust Programming Language (rust-lang.org)

构建网络爬虫并非总是直截了当的过程。你会面临 JavaScript 渲染、IP 封禁、验证码以及许多其他障碍。在 WebScraping API,我们为你提供应对这些常见问题的所有必要工具。 您是否好奇它是如何运作的?您可以在 WebScrapingAPI - 产品 页面免费试用我们的产品。或者您也可以通过 WebScrapingAPI - 联系 页面联系我们。我们非常乐意为您解答所有疑问!

关于作者
Mihai Maxim, 全栈开发工程师 @ WebScrapingAPI
Mihai Maxim全栈开发工程师

米海·马克西姆(Mihai Maxim)是 WebScrapingAPI 的全栈开发工程师,他在产品各领域均有贡献,并协助为该平台构建可靠的工具和功能。

开始构建

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

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