Rust异步爬虫入门

简介

由于Rust语言自身的特性,使用Rust编写爬虫相比起使用Python等语言要繁琐许多。但是,Rust运行速度快、占用资源少的特点使得Rust非常适合执行大规模的爬虫任务。

这里以一个下载Quotes to Scrape网站上所有引语的爬虫为例,简单展现了如何编写一个Rust异步网络爬虫。[项目链接]

依赖

[dependencies]
once_cell = "1.18.0"
reqwest = "0.11.22"
scraper = "0.18.1"
tokio = { version = "1.34.0", features = ["macros", "rt-multi-thread", "sync"] }
url = "2.4.1"

客户端

static URL: Lazy<Url> = Lazy::new(|| Url::parse("https://quotes.toscrape.com/").unwrap());
static CLIENT: Lazy<Client> = Lazy::new(|| {
    use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
    let mut headers = HeaderMap::new();
    let user_agent = HeaderValue::from_static(
        r"Mozilla/5.0 (X11; Linux x86_64; rv:84.0) Gecko/20100101 Firefox/84.0",
    );
    headers.insert(USER_AGENT, user_agent);
    Client::builder().default_headers(headers).build().unwrap()
});

通过reqwest的客户端reqwest::Client,程序可以方便地向网络资源发送请求并接受响应。在异步爬虫中,由于协程数量众多,程序需要尽可能减小每个协程占用的内存资源。当每一个爬虫都使用相同的网络配置(例如请求头Headers)时,为节约程序消耗的系统资源,可以让所有爬虫协程使用同一个的客户端发送请求与接受响应。由于在Rust中异步的过程对其中用到的变量的生命周期有着严格的限制,因此我们使用lazy_static来创建一个全局静态客户端用于执行爬虫的任务。

reqwest中,客户端通过reqwest::ClientBuilderreqwest::Client::builder()进行配置,reqwest::Client::builder()函数创建一个reqwest::ClientBuilder的实例。可以配置的选项包括但不限于请求头与客户端的连接配置[文档链接]。在该样例程序中,客户端加入了User-Agent请求头。其他所需的请求头的类型与值可以通过事先通过浏览器或者查阅MDN文档获得。

下载

async fn download_quote_html(idx: usize) -> reqwest::Result<String> {
    let page_url = URL.join(&format!("page/{}/", idx)).unwrap();
    let res = CLIENT.get(page_url).send().await?;
    let html = res.text().await?;
    Ok(html)
}

下载页面之前,先根据网站的结构生成相应页面的URL,在使用客户端进行下载即可。通过使用异步函数来使下载的过程不会发生阻塞,在大规模爬虫中可以有效缩短工作时间,加快爬取速度。

使用CSS进行选择

static QUOTE: Lazy<Selector> = Lazy::new(|| Selector::parse(r#".quote"#).unwrap());
static TEXT: Lazy<Selector> = Lazy::new(|| Selector::parse(r#".text"#).unwrap());
static AUTHOR: Lazy<Selector> = Lazy::new(|| Selector::parse(r#".author"#).unwrap());
static TAG: Lazy<Selector> = Lazy::new(|| Selector::parse(r#".tag"#).unwrap());
fn parse_quote_html(page: Html) -> Vec<Quote> {
    page.select(&QUOTE)
        .map(|quote| Quote {
            text: quote.select(&TEXT).next().unwrap().inner_html(),
            author: quote.select(&AUTHOR).next().unwrap().inner_html(),
            tags: quote.select(&TAG).map(|e| e.inner_html()).collect(),
        })
        .collect()
}

从下载的页面的HTML中提取出所有的引语。编写选择器(scraper::Selector)需要CSS相关知识,具体可以参考W3School上的教程文档

主函数

#[tokio::main]
async fn main() {
    let pool = Arc::new(Semaphore::new(MAX_TASK));
    let (tx, mut rx) = mpsc::unbounded_channel::<Quote>();

    for page in 1..20 {
        let pool = Arc::clone(&pool);
        let tx = tx.clone();
        tokio::spawn(async move {
            let _permit = pool.acquire().await.unwrap();
            let text = download_quote_html(page).await.unwrap();
            let html = Html::parse_document(&text);
            let quotes = parse_quote_html(html);
            for quote in quotes.into_iter() {
                tx.send(quote).unwrap();
            }
        });
    }
    drop(tx);

    while let Some(quote) = rx.recv().await {
        println!("{:?}", quote);
    }
}

主函数首先创建了运行爬虫所需的异步运行时,用于限制并发数量的协程池和用于收集爬虫结果的管道。该程序对需要被爬取的每一个页面创建一个协程用以下载全部的引言,再将下载后的引言通过管道发送给消费者。主函数在启动全部的下载协程后开始收集各个协程发送的引言数据。当所有协程执行完毕后,接受端不再有可能收集到更多数据,程序退出。

运行时的配置取决于任务的需求以及机器的配置,通常情况下使用Tokio提供的默认配置即可。

线程池的大小根据网络状况和硬件限制等因素确定,对于异步协程爬虫而言,这个数值一般可以设置为一个较大的数字而又不会消耗很多系统资源,但协程的数量显然不应超过网站要求或承受能力的限制。

当协程数量较多时,视需求可以考虑将无限制的管道改成限流管道(tokio::sync::mpsc::channel()),防止协程下载过多的数据发生OOM等错误。

该程序出于演示目的并未进行任何错误处理,实际爬虫项目还需要在发生错误时进行记录或者从错误中恢复。