返回博客
指南
米海·马克西姆2022年10月17日阅读时间:10分钟

使用 Rust 进行网络抓取的新手指南

使用 Rust 进行网络抓取的新手指南

Rust 适合网络搜索吗?

Rust 是一种专为提高速度和效率而设计的编程语言。与 C 或 C++ 不同,Rust 具有集成的软件包管理器和构建工具。它还拥有出色的文档和友好的编译器,并提供有用的错误信息。熟悉语法确实需要一段时间。但一旦习惯了,你就会发现只需几行代码就能编写出复杂的功能。使用 Rust 进行网络搜刮是一种令人振奋的体验。你可以访问功能强大的搜索库,它们会为你完成大部分繁重的工作。因此,你可以把更多时间花在有趣的地方,比如设计新功能。在本文中,我将带你了解使用 Rust 构建网络搜索器的过程。 

如何安装 Rust

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

Since we will be building a web scraper, let’s create a Rust project with Cargo. Cargo is Rust’s build system and package manager. If you used the official installers provided by rust-lang.org, Cargo should be already installed. Check whether Cargo is installed by entering the following into your terminal:  cargo --version.  If you see a version number, you have it! If you see an error, such as command not found, look at the documentation for your method of installation to determine how to install Cargo separately. To create a project, navigate to the desired project location and run cargo new <project name>.

这是默认的项目结构:

用 Rust 构建网络搜索器

现在,让我们来看看如何使用 Rust 构建刮板。第一步是明确目的。我想提取什么?其次是决定如何存储搜索到的数据。大多数人将其保存为 .json,但一般来说,你应该考虑更适合你个人需求的格式。弄清了这两个要求,你就可以放心地实施任何刮擦工具了。为了更好地说明这一过程,我建议我们建立一个小型工具,从COVID Live - Coronavirus Statistics - Worldometer (worldometers.info)网站提取 Covid 数据。它应解析报告病例表并将数据存储为 .json。我们将在接下来的章节中一起创建这个 scraper。

通过 HTTP 请求获取 HTML

要提取表格,首先需要获取网页中的 HTML。我们将使用 "reqwest "板块/库从网站获取原始 HTML。

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

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

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

let url = "https://www.worldometers.info/coronavirus/";
let response = reqwest::blocking::get(url).expect("无法加载该网址。");

阻塞 "功能确保请求是同步的。因此,程序会等待请求完成,然后继续执行其他指令。 

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 的 HTML 代码片段

在撰写本文时,今天的 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 表头元素选择器 = Selector::parse("thead>tr>th").unwrap();
 
let 行元素选择器 = Selector::parse("tbody>tr").unwrap();
 
let 行元素数据选择器 = 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() 函数提取标题并将其存储在一个向量中:

//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"), ...]
...
]

现在,将 zipped_array(键、值)对存储到 IndexMap 中:

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

indexmap = {version="1.9.1", features = ["serde"]}  (添加这些依赖项)
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 是存储表数据的最佳选择,因为它保留了(键、值)对的插入顺序。

数据序列化

现在,您可以用表格数据创建类似 json 的对象,是时候将它们序列化为 .json 了。在开始之前,请确保您已经安装了所有这些依赖项:

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

将每个表数据存储在一个 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...],
  ]
}

You can find the whole code for the project at [Rust][A simple <table> scraper] (github.com)

进行调整以适应其他使用情况

如果你跟随我走了这么远,你可能已经意识到,你可以在其他网站上使用这个搜刮工具。该搜索器不受限于特定的表格列数或命名约定。此外,它也不依赖于许多 CSS 选择器。因此,要让它适用于其他表格,应该不需要太多的调整,对吗?让我们来验证一下这个理论。

维基百科中按人口排序的国家列表,包含国旗和人口密度两列

We need a selector for the <table> tag.

突出显示可排序维基百科表格元素的 HTML 代码片段

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

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

This table has the same <thead> <tbody> structure, so there is no reason to change the other selectors.

刮板现在应该可以工作了。让我们试运行一下:

{
  "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("无法加载该网址。");

let 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());
代码编辑器显示了维基百科人口统计表的HTML源代码,其中包含各列及国旗图标

GET 请求返回的 HTML 与我们在实际网站上看到的不同。浏览器为 Javascript 提供了一个运行环境,以改变页面布局。在我们的刮擦程序中,我们得到的是未经修改的版本。

Our table_selector did not work because the “jquery-tablesorter” class is injected dynamically by Javascript. Also, you can see that the <table> structure is different. The <thead> tag is missing. The table head elements are now found in the first <tr> of the <tbody>. Thus, they will be picked up by the row_elements_selector.

Removing “jquery-tablesorter” from the table_selector is not enough, we also need to handle the missing <tbody> case:

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 编程语言 - Rust 编程语言 (rust-lang.org)

构建网络搜刮工具并不总是一个简单的过程。您将面临 Javascript 渲染、IP 屏蔽、验证码和许多其他问题。在 WebScraping API,我们为您提供所有必要的工具来解决这些常见问题。您是否想知道它是如何工作的?您可以登录WebScrapingAPI - 产品免费试用我们的产品。您也可以通过WebScrapingAPI - 联系我们联系我们。我们非常乐意回答您的所有问题!

关于作者
米海·马克西姆,WebScrapingAPI 全栈开发工程师
米哈伊-马克西姆全栈开发工程师

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

开始构建

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

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