所有的爬虫思路都是一致的,模拟网站请求,然后解析提取想要的信息,有时候我们只需要分析网页的源代码即可,有时候我们还需要分析一些异步请求(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 selector,Name 表示标签名称,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"
}
]