🏊

RustでWebスクレイピング

2025/01/24に公開

概要

Rust(1.84.0)を用いてWebスクレイピングを行うためのライブラリを紹介します。

Webスクレイピングの流れ

Webスクレイピングとは、Webサイトから情報を抽出する技術のことを言います。
ここでは全体の手順を「クロール」、「パース」の2つに分けて考えることにします。

  • クロール \cdots Webサイトを巡回してHTMLなどを取得する
  • パース \cdots 取得したデータを加工し、欲しい情報を抽出する [1]

それぞれ順に説明します。

クロール

巡回したいWebサイトが決まっていて、また、何らかの操作を行う必要がないなら、クロールでRustを使う必要はないです。
curlを使って取得してもよいですし、それで不十分ならreqwestを使ってもよいでしょう。

そうでない場合、実際にWebブラウザを操作する必要があります。
今回は、Rust Headless Chromeを使用します。Rust Headless ChromeはChrome DevTools Protocolを利用してChromeを制御することができるライブラリです。

以下のトピックについて、実際のコードを提示しながら使い方を紹介します。

  • ヘッドレスでない(headfulな)Chromeを使用する
  • マウスやキーボードの操作を行う
  • 通信をキャプチャする
  • スクリーンショットを撮影する

この章では、Cargo.tomlを次のように設定することにします。

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をヘッドレスでない状態で起動します。

src/main.rs
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つに分かれます。

以下のプログラムは、Zennのトップ画面を開いたあと、検索ボタンを押して検索画面を開き、Rustと入力して検索を行い、その結果の画面に移動します。

src/main.rs
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レスポンスを取得することができます。

取得できる情報については、ResponseReceivedEventParamsGetResponseBodyReturnObject、もしくはChrome DevTools Protocolの対応した項目で確認できます。[3]

以下のプログラムは、最初のコマンドライン引数で与えられたURLへ移動し、移動が終わるまでに発生するHTTPレスポンスのURL [4] とbodyを出力します。(例えば、cargo runが実行できる状態で、cargo run {URL}と実行すればよいです。) [5]

src/main.rs
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に保存します。

src/main.rs
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でパースを行う手順は、以下のようになります。

  1. parse_document()でHTMLをパースします。
  2. CSSセレクタを利用して、select()で要素を選択します。
  3. ElementRefから必要な情報を取得します。

実際にプログラムを書く

scraperは機能がシンプルなので、項目別に分けずに実装例を提示することにします。

Zennのトップ画面にあるTechの欄のトレンドの記事の情報を取得するプログラムを書いてみます。

執筆時点では、トップ画面は以下のようになっています。この画面からそれぞれの記事のタイトル、ユーザー名等を取得することにします。

執筆時点のトップ画面

以下、プログラムです。

Cargo.toml
[dependencies]
scraper = "0.22.0"
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.137"
src/main.rs
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を用いると、その負担が軽減されたと感じました。

脚注
  1. この記事では、HTMLファイルをパースすることを想定しています。クロールで取得したデータによっては、それ以外をパースする場合もあるでしょう。 ↩︎

  2. 恐らくドキュメントを見てもどう書けばよいのか分からないと思うのですが、Quick Startcall_js_fnが使用されているので、これを見るとよいです。 ↩︎

  3. Network.responseReceived, Network.Response, Network.getResponseBodyあたりのことを指しています。 ↩︎

  4. HTTPレスポンスのURLというのはあまり適切な表現ではないと思いますが、Network.Responseurlを取得していると解釈しています。 ↩︎

  5. 今回の主題ではないので、コマンドライン引数を雑に扱っています。きちんと扱いたい場合は、clap等のライブラリを使う方が望ましいと思います。 ↩︎

Discussion