RustでWebスクレイピング
概要
Rust(1.84.0)を用いてWebスクレイピングを行うためのライブラリを紹介します。
Webスクレイピングの流れ
Webスクレイピングとは、Webサイトから情報を抽出する技術のことを言います。
ここでは全体の手順を「クロール」、「パース」の2つに分けて考えることにします。
- クロール
Webサイトを巡回してHTMLなどを取得する\cdots - パース
取得したデータを加工し、欲しい情報を抽出する [1]\cdots
それぞれ順に説明します。
クロール
巡回したいWebサイトが決まっていて、また、何らかの操作を行う必要がないなら、クロールでRustを使う必要はないです。
curlを使って取得してもよいですし、それで不十分ならreqwestを使ってもよいでしょう。
そうでない場合、実際にWebブラウザを操作する必要があります。
今回は、Rust Headless Chromeを使用します。Rust Headless ChromeはChrome DevTools Protocolを利用してChromeを制御することができるライブラリです。
以下のトピックについて、実際のコードを提示しながら使い方を紹介します。
- ヘッドレスでない(headfulな)Chromeを使用する
- マウスやキーボードの操作を行う
- 通信をキャプチャする
- スクリーンショットを撮影する
この章では、Cargo.toml
を次のように設定することにします。
[dependencies]
headless_chrome = {git = "https://github.com/rust-headless-chrome/rust-headless-chrome", features = ["fetch"]}
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.137"
ヘッドレスでない(headfulな)Chromeを使用する
クローラーを開発するときには、ヘッドレスでない方がデバッグしやすくてよいという場合もあるでしょう。その場合は設定を変更して、ヘッドレスでないChromeを使用することができます。
LaunchOptionsBuilder
から、Chromeの起動オプションを変更できます。また、headless()
を使用して、Chromeをヘッドレスで起動するかどうかを選択できます。
以下のプログラムは、Chromeをヘッドレスでない状態で起動します。
use headless_chrome::{Browser, LaunchOptionsBuilder};
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let mut launch_options_builder = LaunchOptionsBuilder::default();
launch_options_builder.headless(false);
let _browser = Browser::new(launch_options_builder.build()?)?;
todo!();
}
マウスやキーボードの操作を行う
操作は、 Tab
に対して行うものと、Element
に対して行うものの2つに分かれます。
-
Tab
に対しての操作- キーボードの操作:
send_character()
,press_key()
- マウスの操作:
move_mouse_to_point()
,click_point()
- JavaScriptを使用する:
evaluate()
- キーボードの操作:
-
Element
に対しての操作-
Tab
側で、find_element()
等で要素を選択してから、以下の関数を呼び出せばよいです。 - マウスの操作:
click()
- JavaScriptを使用する:
call_js_fn()
[2]
-
以下のプログラムは、Zennのトップ画面を開いたあと、検索ボタンを押して検索画面を開き、Rust
と入力して検索を行い、その結果の画面に移動します。
use core::time;
use headless_chrome::Browser;
use std::{error::Error, thread::sleep};
fn main() -> Result<(), Box<dyn Error>> {
let browser = Browser::default()?;
let tab = browser.new_tab()?;
tab.navigate_to("https://zenn.dev/")?
.wait_until_navigated()?;
tab.find_element("#header-search")?.click()?;
tab.wait_until_navigated()?;
tab.find_element("#input-search-form")?.click()?;
tab.send_character("Rust")?;
tab.press_key("Enter")?.wait_until_navigated()?;
sleep(time::Duration::from_secs(3));
assert_eq!(tab.get_url(), "https://zenn.dev/search?q=Rust");
Ok(())
}
通信をキャプチャする
register_response_handling()
を使用して、HTTPレスポンスを取得することができます。
取得できる情報については、ResponseReceivedEventParams
やGetResponseBodyReturnObject
、もしくはChrome DevTools Protocolの対応した項目で確認できます。[3]
以下のプログラムは、最初のコマンドライン引数で与えられたURLへ移動し、移動が終わるまでに発生するHTTPレスポンスのURL [4] とbodyを出力します。(例えば、cargo run
が実行できる状態で、cargo run {URL}
と実行すればよいです。) [5]
use headless_chrome::Browser;
use serde::Serialize;
use std::{env, error::Error};
fn main() -> Result<(), Box<dyn Error>> {
let browser = Browser::default()?;
let tab = browser.new_tab()?;
tab.register_response_handling(
"test",
Box::new(move |response, fetch_body| {
let fetch_body = fetch_body();
if fetch_body.is_ok() {
let url = response.response.url;
let body = fetch_body.unwrap().body;
let response_info = ResponseInfo { url, body };
println!("{}", serde_json::to_string(&response_info).unwrap());
}
}),
)?;
let args = env::args().collect::<Vec<_>>();
tab.navigate_to(&args[1])?.wait_until_navigated()?;
Ok(())
}
#[derive(Serialize)]
struct ResponseInfo {
url: String,
body: String,
}
また、add_event_listener()
は通信のキャプチャに限らず更に汎用的に使用できます。
スクリーンショットを撮影する
capture_screenshot()
を使用して、スクリーンショットを撮影することができます。
以下のプログラムは、最初のコマンドライン引数で与えられたURLへ移動し、そこでスクリーンショットを撮影し、image.jpg
に保存します。
use headless_chrome::protocol::cdp::Page::CaptureScreenshotFormatOption;
use headless_chrome::Browser;
use std::{env, error::Error, fs::File, io::Write};
fn main() -> Result<(), Box<dyn Error>> {
let browser = Browser::default()?;
let tab = browser.new_tab()?;
let args = env::args().collect::<Vec<_>>();
tab.navigate_to(&args[1])?.wait_until_navigated()?;
let image = tab.capture_screenshot(CaptureScreenshotFormatOption::Jpeg, None, None, true)?;
let mut file = File::create("image.jpg")?;
file.write(&image)?;
Ok(())
}
パース
ここまでで、HTMLファイルを取得することができました。そこで、そのファイルから必要な情報を抽出しましょう。
今回は、scraperを使用します。
scraper
によるパースの手順
scraper
でパースを行う手順は、以下のようになります。
-
parse_document()
でHTMLをパースします。 - CSSセレクタを利用して、
select()
で要素を選択します。 -
ElementRef
から必要な情報を取得します。
実際にプログラムを書く
scraperは機能がシンプルなので、項目別に分けずに実装例を提示することにします。
Zennのトップ画面にあるTechの欄のトレンドの記事の情報を取得するプログラムを書いてみます。
執筆時点では、トップ画面は以下のようになっています。この画面からそれぞれの記事のタイトル、ユーザー名等を取得することにします。
以下、プログラムです。
[dependencies]
scraper = "0.22.0"
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.137"
use scraper::{Html, Selector};
use serde::Serialize;
use std::io::{self, prelude::*};
fn main() {
let (stdin, stdout) = (io::read_to_string(io::stdin()).unwrap(), io::stdout());
let mut buffer = io::BufWriter::new(stdout.lock());
let document = Html::parse_document(&stdin);
// https://zenn.dev/ のTechにある記事のセレクタ
let content_selector = Selector::parse(
"#tech-trend > div > div[class^=\"ArticleList_listContainer\"] > div > article > div",
)
.unwrap();
for element in document.select(&content_selector) {
let title = element
.select(&Selector::parse("a > h2").unwrap())
.next()
.expect("Failed to parse.")
.text()
.collect();
let user_name = element
.select(
&Selector::parse(
"div > div[class^=\"ArticleList_userInfo\"] > div[class^=\"ArticleList_userName\"] > a",
)
.unwrap(),
)
.next()
.expect("Failed to parse.")
.text()
.collect();
let url = element
.select(&Selector::parse("a").unwrap())
.next()
.expect("Failed to parse.")
.attr("href")
.unwrap()
.to_owned();
let url = format!("https://zenn.dev/{}", url);
let like_count = element
.select(
&Selector::parse(
"div > div[class^=\"ArticleList_userInfo\"] > div[class^=\"ArticleList_meta\"] > span",
)
.unwrap(),
)
.next()
.expect("Failed to parse.")
.text()
.collect::<String>()
.trim()
.parse()
.unwrap();
let article_info = ArticleInfo {
title,
user_name,
url,
like_count,
};
// JSONで結果を出力する
writeln!(buffer, "{}", serde_json::to_string(&article_info).unwrap())
.expect("Failed to output.");
}
}
#[derive(Serialize)]
struct ArticleInfo {
title: String,
user_name: String,
url: String,
like_count: u32,
}
curl -sS https://zenn.dev/ | cargo run --release -q
を実行して、以下の標準出力を得ました。画面の表示と整合性が取れていることが確認できました。
{"title":"ECSとRDSをやめて、AWSコストを9割削減しました","user_name":"がれっと","url":"https://zenn.dev//beenos_tech/articles/lambda-sqlite-application","like_count":253}
{"title":"React で Modal や Confirm の実装を簡単にする react-call というライブラリがアツい!!!","user_name":"きっちゃそ","url":"https://zenn.dev//ykicchan/articles/5415871c017b22","like_count":196}
{"title":"画面遷移の性質を事前に伝える重要性","user_name":"noppe","url":"https://zenn.dev//noppe/articles/f7a4ce82b9c975","like_count":96}
...
あとがき
本題とは離れますが、WebスクレイピングにRustを使うことの利点について少しだけ書きます。
Webスクレイピングを行うときには、Rustはあまり使われず、基本的には、Python, Ruby, JavaScript(TypeScript)などが使われるイメージがあります。
Rustをそれらの言語と比較したとき、良い部分はいくつかありますが、Webスクレイピングに限定すれば、型システムの恩恵が大きいと感じました。
特に大きいのは、どこが問題で処理が失敗しているのか分かりやすいプログラムをあまり意識せずに書ける点です。
Webスクレイピングは、その性質上、副作用のある関数がたくさん存在します。その結果、テストを行いにくく、また、デバッグが面倒になりがちです。Rustを用いると、その負担が軽減されたと感じました。
-
この記事では、HTMLファイルをパースすることを想定しています。クロールで取得したデータによっては、それ以外をパースする場合もあるでしょう。 ↩︎
-
恐らくドキュメントを見てもどう書けばよいのか分からないと思うのですが、Quick Startで
call_js_fn
が使用されているので、これを見るとよいです。 ↩︎ -
Network.responseReceived
,Network.Response
,Network.getResponseBody
あたりのことを指しています。 ↩︎ -
HTTPレスポンスのURLというのはあまり適切な表現ではないと思いますが、Network.Responseの
url
を取得していると解釈しています。 ↩︎ -
今回の主題ではないので、コマンドライン引数を雑に扱っています。きちんと扱いたい場合は、clap等のライブラリを使う方が望ましいと思います。 ↩︎
Discussion