使用 Rust 爬取电影

671 阅读4分钟

所有的爬虫思路都是一致的,模拟网站请求,然后解析提取想要的信息,有时候我们只需要分析网页的源代码即可,有时候我们还需要分析一些异步请求(XHR)。

首先我们定义好爬取的电影结构

#[derive(Serialize, Deserialize, Debug, Default)]
pub struct MovieItem {
    pub name: Option<String>,         // 电影名称
    pub url: Option<String>,          // url 地址
    pub image_url: Option<String>,    // 导演
    pub director: Option<String>,     // 预览图片 url 地址
    pub desc: Option<String>,         // 简介
    pub publish_year: Option<String>, // 发布时间
    pub loc: Option<String>,          // 地区
    pub class: Option<String>,        // 分类
    pub from: String,                 // 网站来源
}

提供一个接口,由于 Rust 目前 trait 中不支持 async 方法,我们可以使用 async_trait 库,同时为了方便错误处理,我们使用 anyhow 提供的 Result 定义。

use anyhow::Result;
use async_trait::async_trait;

#[async_trait]
pub trait Spider {
    type Output;

    async fn search(&self, word: String) -> Result<Vec<Self::Output>>;
    fn name(&self) -> String;
}

获取网页源代码

通过浏览器查看源代码,我们发现需要的数据就在源代码中,所以我们只需要通过构造 url 链接,使用 reqwest 发送 HTTP 请求,然后读取响应体,从中解析需要的数据然后返回即可,代码如下:

// client.rs
use std::time::Duration;

use lazy_static::lazy_static;
use reqwest::Client;

lazy_static! {
    pub static ref CLIENT: Client = {
        reqwest::ClientBuilder::new()
            .connect_timeout(Duration::from_secs(5))
            .timeout(Duration::from_secs(5))
            .build()
            .expect("create client failed")
    };
}

// cupfox.rs
use super::client::CLIENT as client;
async fn get_html(page: usize, word: &str) -> Result<String> {
    let resp = client.get(Self::get_url(page, word)).send().await?;

    resp.text().await.map_err(|e| anyhow!(e))
}

解析数据

HTML 源代码中获取数据,有两种常见的方式,一种是通过正则匹配,一种是解析 HTML 结构,然后获取其中的元素,但是通过正则的方法比较复杂,在这里我们使用 select 库进行解析,通过 find 对元素进行定位,这里语法类似 css selectorName 表示标签名称,descendant 表示子元素,Class 表示标签的 class

async fn get_items_per_page(page: usize, word: &str) -> Result<Vec<MovieItem>> {
    let html = Self::get_html(page, word).await?;
    let doc = Document::from(html.as_str());

    let mut res = vec![];

    for dl in doc.find(Name("dl")) {
        let image_url = dl
        .find(Name("dt").descendant(Name("a")))
        .next()
        .map(|node| node.attr("data-original").unwrap_or_default())
        .map(Self::get_absolute_url);

        let title_node = dl
        .find(Name("dd").descendant(Name("h1").descendant(Name("a"))))
        .next();
        
        let title = title_node.map(|node| node.text());
        
        let url = title_node
        .map(|node| node.attr("href").unwrap_or_default())
        .map(Self::get_absolute_url);

        let li_text: Vec<String> = dl
        .find(
            Class("fed-part-rows")
            .and(Name("ul"))
            .descendant(Name("li")),
        )
        .map(|node| node.text())
        .map(|text| {
            text.splitn(2, ':')
            .last()
            .unwrap_or_default()
            .replace('\u{a0}', " ")
            .replace('\u{3000}', "")
            .trim()
            .to_string()
        })
        .collect();

        res.push(MovieItem {
            name: title,
            url,
            image_url,
            desc: li_text.get(6).map(|s| s.to_string()),
            director: li_text.get(1).map(|s| s.to_string()),
            publish_year: li_text.get(4).map(|s| s.to_string()),
            loc: li_text.get(3).map(|s| s.to_string()),
            class: li_text.get(2).map(|s| s.to_string()),
            from: "cupfox".to_string(),
        })
    }

    Ok(res)
}

对每页的数据实现了爬取,然后对所有页进行聚合即可,这里使用异步的方式,通过使用信号量控制请求数量,避免请求太快。

async fn search(&self, word: String) -> Result<Vec<Self::Output>> {
  let total_pages = self.get_total_pages(&word).await?;
  println!("total pages: {}", total_pages);
  let mut res = vec![];
  let mut handlers = vec![];
  let word = Arc::new(word);

  // 限制同时请求的个数
  let semaphore = Arc::new(Semaphore::new(self.permit));
  for page in self.offset..=total_pages.min(self.limit + self.offset) {
      let permit = semaphore.clone().acquire_owned().await?;
      let w = word.clone();
      handlers.push(tokio::spawn(async move {
          let res = Self::get_items_per_page(page, &w).await;
          drop(permit);
          res
      }));
  }

  for handler in handlers {
      res.extend(handler.await??);
  }

  Ok(res)
}

完整源码见 movie-search,输出结构为:

[
  {
    "name": "你好世界2020",
    "url": "https://www.cupfox.cc/cupfox/26309.html",
    "image_url": "https://pic.monidai.com/img/0ca1de34b991ef23126d8b167e7981fc.jpg",
    "director": "伊藤智彦",
    "desc": "在京都居住的内向男高中生直实(北村匠海 配音)的面前,突然出现从10年后穿越而来26岁的自己(松坂桃李 配音)。未来的直实告诉他,自己不久便会与琉璃(滨边美波 配音)相爱,可是之后烟花大会时她却会因为一场事故意外离世。 为了拯救爱人,16岁的直实卷入了这场现实与虚拟的记忆世界,经历了一系列超乎想象的事情。即使世界毁灭,我也想再见你一面。",
    "publish_year": "2020",
    "loc": "日本",
    "class": "动漫",
    "from": "cupfox"
  },
  {
    "name": "你好世界",
    "url": "https://www.cupfox.cc/cupfox/24748.html",
    "image_url": "https://pic.monidai.com/img/5e8ea040f25c8.jpg",
    "director": "内详",
    "desc": "在京都居住的内向男高中生直实(北村匠海 配音)的面前,突然出现从10年后穿越而来26岁的自己(松坂桃李 配音)。未来的直实告诉他,自己不久便会与琉璃(滨边美波 配音)相爱,可是之后烟花大会时她却会因为一场事故意外离世。为了拯救爱人,16岁的直实卷入了这场现实与虚拟的记忆世界,经历了一系列超乎想象的事情。即使世界毁灭,我也想再见你一面。",
    "publish_year": "2019",
    "loc": "日本",
    "class": "动漫",
    "from": "cupfox"
  }
]