Rust 是一种专为速度和效率而设计的编程语言。与 C 或 C++ 不同,Rust 集成了包管理器和构建工具。它还拥有出色的文档以及一个友好的编译器,能提供有用的错误提示。虽然需要一段时间来适应其语法,但一旦掌握,你就会发现只需几行代码就能实现复杂的功能。使用 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 - 联系 页面联系我们。我们非常乐意为您解答所有疑问!




