数据采集,生态工具最完整、成熟的,笔者认为莫过于 Python
了,特别是其 Scrapy
库的强大和成熟,是很多项目和产品的必选。笔者以前在大数据项目中,数据采集部分,也是和团队同事一起使用。不管从工程中的那个视觉来说,笔者认为 scrapy
都是完全满足的。
本文是使用 Rust
生态中的数据采集相关 crate
进行数据采集的实践,是出于这样的目的:新的项目中,统一为 Rust
技术栈;想尝试下 Rust
的性能优势,是否在数据采集中也有优势。
所以, 本文更多的仅是 Rust
生态实践而言,并非是 Rust
做数据采集相比 Python
有优势。
好的,我们从头开始进行一次数据采集的完整实践,以站点 https://this-week-in-rust.org/
为目标,采集所有的 Rust 周报
。
创建项目
我们使用 cargo
,创建一个新项目。本项目我们要使用 Rust 的异步运行时 async-std
,HTTP 客户端库 reqwest
,数据采集库 scraper
,以及控制台输出文字颜色标记库 colored
。我们在创建项目后,一并使用 cargo-edit
crate 将它们加入依赖项:
代码语言:javascript复制关于
cargo-edit
的安装和使用,请参阅文章《构建 Rust 异步 GraphQL 服务:基于 tide async-graphql mongodb(1)- 起步及 crate 选择》
cargo new rust-async-crawl-example
cd ./rust-async-crawl-example
cargo add async-std reqwest scraper colored
成功执行后,Cargo.toml
文件清单的 dependencies
区域将有上述 4 个 crate。但是对于 async-std
,本次实践中,我们进使用其 attributes
特性;对于 reqwest
,我们则要启用其 blocking
特性。我们修改 Cargo.toml
文件,最终为如下内容:
[package]
name = "rust-crawl-week"
version = "0.1.0"
authors = ["zzy <linshi@budshome.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-std = { version = "1.9.0", features = ["attributes"] }
reqwest = { version = "0.11.2", features = ["blocking"] }
scraper = "0.12.0"
colored = "2.0.0"
简要设计
数据采集,我们必定不会局限于一个站点。所以,我们参考 Python 中的库 scrapy
的思路,每个具体的爬虫,对应一个站点。因此,我们组织文件结构为 main.rs
是执行入口;sites.rs
或者 sites
模块,是具体各自站点爬中位置。本例中,我们只是对站点 https://this-week-in-rust.org/
进行采集,所以将其编写在 sites.rs
文件中。实际的项目产品中,推荐使用 sites
模块,里面包含以各自站点命名的具体爬虫。
对于采集结果,我们要通过输出接口,将其输入到控制台、数据库、文档(文本、excel 等)。这些输出和写入的接口,也需要是在统一的位置,以便于后续扩展。
本实例中,我们将其打印输出到控制台。并在打印时,对于不同的站点、标题,以及 url 链接进行着色。
因此,本实践实例中,工程结构最终为:
此时,我们还未编译构建,所以没有 Cargo.lock
文件和 target
目录。您如果跟随本文实践,cargo build
后,会产生它们。下文不再说明。
main.rs
数据采集入口文件,其代码要尽可能简单和简洁。
代码语言:javascript复制mod sites;
#[async_std::main]
async fn main() {
// this-week-in-rust.org
match sites::this_week_in_rust_org().await {
Ok(site) => println!("{:#?}", site),
Err(_) => eprintln!("Error fetching this-week-in-rust.org data."),
}
// 其它站点
// ……
}
对于多个站点,我们逐次增加即可,这样有利于简单的后续扩展。这儿需要再次说明:本例中,我们只是对站点 https://this-week-in-rust.org/
进行采集,所以将其编写在 sites.rs
文件中。实际的项目产品中,推荐使用 sites
模块,里面包含以各自站点命名的具体爬虫。
注意:
println!("{:#?}", site)
,控制台输出时,我们已经对其采用了 Rust 中默认最美观易读的输出方式。之所以标注此代码,是因为对于第一次不够“人类工程学”的显示方式,我们后面要进行迭代。
sites.rs
第一次编码,采集数据并输出
首先,我们要定义两个结构体,分别表示站点信息,以及采集目标数据的信息(本例为标题、url 链接)。
代码语言:javascript复制#[derive(Debug)]
pub struct Site {
name: String,
stories: Vec<Story>,
}
#[derive(Debug)]
struct Story {
title: String,
link: Option<String>,
}
对于目标数据的采集,我们的思路很简单,三步走:
- 获取 HTML 文档;
- 萃取数据标题;
- 萃取数据 url 链接。
我们定义这三个方法,并在具体的站点爬虫 this_week_in_rust_org
中,进行调用:
use reqwest::{blocking, Error};
use scraper::{ElementRef, Html, Selector};
use std::result::Result;
pub async fn this_week_in_rust_org() -> Result<Site, Error> {
let s = Selector::parse("div.col-md-12 a").unwrap();
let body = get_html("https://this-week-in-rust.org/blog/archives/index.html").await?;
let stories = body
.select(&s)
.map(|element| Story {
title: parse_title(element),
link: parse_link(element),
})
.collect();
let site = Site {
name: "this-week-in-rust.org".to_string(),
stories,
};
Ok(site)
}
async fn get_html(uri: &str) -> Result<Html, Error> {
Ok(Html::parse_document(&blocking::get(uri)?.text()?))
}
fn parse_link(element: ElementRef) -> Option<String> {
let mut link: Option<String> = None;
if let Some(link_str) = element.value().attr("href") {
let link_str = link_str.to_owned();
link = Some(link_str);
}
link
}
fn parse_title(element: ElementRef) -> String {
element.inner_html()
}
这段代码是很易读的,代码既是最好的文档。注意获取 HTML 文档的函数 get_html
和 爬虫调用函数 this_week_in_rust_org
是异步的,而萃取链接函数 parse_link
和萃取标题函数 parse_title
则不是。因为具体的萃取,是在一个数据解析进程中执行的,异步与否笔者认为意义不大。当然,您如果有兴趣,可以改为异步函数,进行性能对比。
第一次编码完成,我们编译、运行看看部分输出结果:
安装依赖较多,如果时间较长,请配置 Cargo 国内镜像源。
这个输出数据是 json 格式的,并且文字也没有颜色区分。对于其它输入调用接口,非常合适。比如数据库和导出文档等。但是对于人眼阅读来说,则有些不够友好,我们希望输出就是标题和其链接就可以了。
第二次编码,输出数据格式优化
第一次编码中,我们使用的是 Rust 默认的 Display
trait。我们要实现自定义输出数据格式,也就是需要对 Site
和 Story
2 个结构体实现自定义 Display
trait。
use colored::Colorize;
use std::fmt;
impl fmt::Display for Site {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(f, "{}", self.name.blue().bold())?;
for story in &self.stories {
writeln!(f, "{}", story)?;
}
Ok(())
}
}
impl fmt::Display for Story {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self.link {
Some(link) => write!(f, "t{}ntt({})", self.title.green(), link),
None => write!(f, "t{}", self.title),
}
}
}
此时,我们 main.rs
中的打印,甚至不需要指定 Display
方式的:
mod sites;
#[async_std::main]
async fn main() {
// this-week-in-rust.org
match sites::this_week_in_rust_org().await {
Ok(site) => println!("{}", site),
Err(_) => eprintln!("Error fetching this-week-in-rust.org data."),
}
// 其它站点
// ……
}
我们现在看看输出结果:
人眼阅读,这种方式合适一些,并且 url 链接,可以直接点击。
感兴趣的朋友,可以参阅 github 完整代码仓库。
谢谢您的阅读!