Rustでスクレイピングを行いたいものの、どのクレートを選べばよいのか、Pythonとの違いは何か、法的に問題ないのかといった疑問を持つ方は多いのではないでしょうか。Rustはメモリ安全性と実行速度を両立できる言語であり、大量ページの収集や長時間稼働するクローラーの構築にも適しています。

この記事ではRustのHTTPクライアント「reqwest」、HTMLパーサー「scraper」、ヘッドレスブラウザ操作「headless_chrome」を軸に、実際に動作するコードを示しながらスクレイピングの方法を解説します。

Rustがスクレイピングに向いている理由

スクレイピングにはPython(BeautifulSoup / Scrapy)が広く使われていますが、Rustには以下のような強みがあります。

観点RustPython
実行速度C/C++に匹敵するネイティブコンパイルインタプリタ実行のため低速
メモリ効率所有権システムによりGC不要GCに依存、大規模処理でメモリ消費大
並行処理async/awaitとtokioで安全な非同期処理GILの制約がありマルチスレッドに制限
バイナリ配布シングルバイナリで依存なしPythonランタイムと仮想環境が必要
型安全性コンパイル時に型エラーを検出実行時エラーが起きやすい

特に数万ページ規模のクロールや、サーバー上で常時稼働させるバッチ処理では、Rustの省メモリ・高スループットが大きな利点です。

開発環境の準備

Rustをまだインストールしていない場合は、公式のrustupを使います。

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

プロジェクトの作成は以下のとおりです。

cargo new rust-scraper-demo
cd rust-scraper-demo

静的HTMLページのスクレイピング

静的なHTMLを取得して解析する基本パターンです。HTTPリクエストにはreqwest(v0.13)、HTML解析にはscraper(v0.25)を使います。

Cargo.tomlの設定

[package]
name = "rust-scraper-demo"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.13", features = ["blocking"] }
scraper = "0.25"

HTMLを取得してタイトルを抽出する

reqwest::blockingを使った同期処理の例です。スクリプトやCLIツールなど、非同期が不要な場面で手軽に使えます。

use reqwest::blocking::get;
use scraper::{Html, Selector};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let url = "https://www.rust-lang.org/";
    let response = get(url)?;
    let body = response.text()?;

    let document = Html::parse_document(&body);
    let selector = Selector::parse("title").unwrap();

    for element in document.select(&selector) {
        let title = element.text().collect::<String>();
        println!("タイトル: {}", title);
    }

    Ok(())
}

Selector::parseにはCSSセレクタを渡します。div.class-namea[href]など、ブラウザのDevToolsで確認したセレクタをそのまま使えるのがscraperの利点です。

複数要素の抽出とリンク一覧の取得

実際のスクレイピングでは、ページ内の特定の要素群をまとめて取得することが多いです。以下はリンクのテキストとURLを一覧取得する例です。

use reqwest::blocking::get;
use scraper::{Html, Selector};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let url = "https://www.rust-lang.org/";
    let body = get(url)?.text()?;
    let document = Html::parse_document(&body);

    let link_selector = Selector::parse("a[href]").unwrap();

    for element in document.select(&link_selector) {
        let href = element.value().attr("href").unwrap_or("");
        let text: String = element.text().collect();
        if !text.trim().is_empty() {
            println!("{} -> {}", text.trim(), href);
        }
    }

    Ok(())
}

element.value().attr("属性名")で任意のHTML属性を取得できます。classiddata-*属性の取得も同じ方法です。

非同期処理による効率的なスクレイピング

複数ページを巡回する場合、同期的に1ページずつ処理すると時間がかかります。tokioとreqwestの非同期APIを組み合わせることで、複数リクエストを並行して処理できます。

Cargo.tomlに非同期用の依存を追加

[dependencies]
reqwest = "0.13"
scraper = "0.25"
tokio = { version = "1", features = ["full"] }

複数URLを並行取得する

use reqwest::Client;
use scraper::{Html, Selector};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();
    let urls = vec![
        "https://www.rust-lang.org/",
        "https://crates.io/",
        "https://doc.rust-lang.org/book/",
    ];

    let mut handles = vec![];

    for url in urls {
        let client = client.clone();
        let url = url.to_string();
        let handle = tokio::spawn(async move {
            let resp = client.get(&url).send().await;
            match resp {
                Ok(r) => {
                    let body = r.text().await.unwrap_or_default();
                    let doc = Html::parse_document(&body);
                    let sel = Selector::parse("title").unwrap();
                    let title: String = doc
                        .select(&sel)
                        .next()
                        .map(|e| e.text().collect())
                        .unwrap_or_default();
                    println!("[{}] {}", url, title);
                }
                Err(e) => eprintln!("[{}] エラー: {}", url, e),
            }
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.await?;
    }

    Ok(())
}

tokio::spawnで各リクエストを独立タスクとして起動し、非同期に処理します。Clientはコネクションプーリングを内部で管理するため、clone()してもコネクションは共有されます。

リクエスト間隔を制御する

相手サーバーに負荷をかけないために、リクエスト間隔の制御が重要です。

use std::time::Duration;
use tokio::time::sleep;

async fn fetch_with_delay(client: &Client, url: &str) -> Result<String, reqwest::Error> {
    sleep(Duration::from_millis(500)).await;
    let resp = client.get(url).send().await?;
    resp.text().await
}

tokio::time::sleepを使えば非同期コンテキスト内でブロッキングせずに待機できます。スレッドを占有しない点がstd::thread::sleepとの違いです。

ヘッダー・Cookie・認証付きリクエスト

実際のスクレイピングでは、User-Agentの設定やCookieの管理が必要な場面があります。

カスタムヘッダーの設定

use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT, ACCEPT_LANGUAGE};

fn build_headers() -> HeaderMap {
    let mut headers = HeaderMap::new();
    headers.insert(USER_AGENT, HeaderValue::from_static("Mozilla/5.0 (compatible; MyBot/1.0)"));
    headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("ja,en;q=0.9"));
    headers
}

Client::builder().default_headers(build_headers()).build()のようにClient生成時に設定すると、全リクエストに共通のヘッダーが付与されます。

Cookie管理によるセッション維持

ログインが必要なサイトでは、Cookie Jarを有効にしてセッションを保持します。

use reqwest::blocking::Client;
use std::collections::HashMap;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .cookie_store(true)
        .build()?;

    // ログインリクエスト
    let mut params = HashMap::new();
    params.insert("username", "user");
    params.insert("password", "pass");

    client.post("https://example.com/login")
        .form(&params)
        .send()?;

    // ログイン後のページを取得(Cookieが自動送信される)
    let protected = client.get("https://example.com/dashboard")
        .send()?
        .text()?;

    println!("取得したページ長: {} bytes", protected.len());
    Ok(())
}

cookie_store(true)を指定すると、サーバーから返されたSet-Cookieヘッダーが自動で保存され、以降のリクエストで送信されます。

POSTリクエストとフォーム送信

GETリクエストだけでなく、検索フォームやAPIへのPOSTが必要な場面もあります。

use reqwest::blocking::Client;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new();

    // フォームデータの送信
    let params = [("q", "Rust scraping"), ("lang", "ja")];
    let resp = client.post("https://httpbin.org/post")
        .form(&params)
        .send()?;

    println!("ステータス: {}", resp.status());
    println!("レスポンス: {}", resp.text()?);

    Ok(())
}

JSON形式でリクエストボディを送信する場合は.json(&data)メソッドが使えます。Cargo.tomlreqwestjsonフィーチャーを有効にしてください。

JavaScriptで描画されるページのスクレイピング

SPAや動的コンテンツを持つサイトでは、HTMLだけを取得してもデータが含まれていません。headless_chromeクレート(v1.0)を使えば、Chromeブラウザをプログラムから操作してJavaScript実行後のDOMを取得できます。

Cargo.tomlの設定

[dependencies]
headless_chrome = "1.0"

ページのJavaScript実行後にHTMLを取得する

use headless_chrome::Browser;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let browser = Browser::default()?;
    let tab = browser.new_tab()?;

    tab.navigate_to("https://www.rust-lang.org/")?;
    tab.wait_until_navigated()?;

    // JavaScript実行後のHTMLを取得
    let html = tab.get_content()?;
    println!("HTML長: {} bytes", html.len());

    // 特定の要素のテキストを取得
    let element = tab.wait_for_element("h1")?;
    let text = element.get_inner_text()?;
    println!("h1テキスト: {}", text);

    Ok(())
}

Browser::default()はシステムにインストールされたChromeまたはChromiumを自動検出して起動します。Chrome DevTools Protocol(CDP)を介して操作するため、PuppeteerやPlaywrightと同等の操作が可能です。

スクリーンショットの保存

ページの状態を画像として保存する機能もあります。デバッグや証跡の記録に便利です。

use headless_chrome::Browser;
use headless_chrome::protocol::cdp::Page::CaptureScreenshotFormatOption;
use std::fs;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let browser = Browser::default()?;
    let tab = browser.new_tab()?;

    tab.navigate_to("https://www.rust-lang.org/")?;
    tab.wait_until_navigated()?;

    let screenshot = tab.capture_screenshot(
        CaptureScreenshotFormatOption::Png,
        None,
        None,
        true,
    )?;

    fs::write("screenshot.png", &screenshot)?;
    println!("スクリーンショットを保存しました");

    Ok(())
}

フォーム操作とクリックイベント

ログインフォームの入力やボタンクリックなど、ブラウザ操作が必要な場面にも対応できます。

use headless_chrome::Browser;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let browser = Browser::default()?;
    let tab = browser.new_tab()?;

    tab.navigate_to("https://example.com/login")?;
    tab.wait_until_navigated()?;

    // テキスト入力
    tab.wait_for_element("input[name='username']")?
        .click()?;
    tab.type_str("myuser")?;

    tab.wait_for_element("input[name='password']")?
        .click()?;
    tab.type_str("mypass")?;

    // ボタンクリック
    tab.wait_for_element("button[type='submit']")?
        .click()?;

    tab.wait_until_navigated()?;
    let content = tab.get_content()?;
    println!("ログイン後のHTML長: {} bytes", content.len());

    Ok(())
}

クレート選定ガイド

用途に応じて適切なクレートの組み合わせを選びます。

ユースケースHTTPクライアントHTMLパーサー補足
静的HTMLの取得と解析reqwest (blocking)scraper最もシンプルな構成
複数ページの並行取得reqwest (async) + tokioscrapertokioランタイムが必要
JSレンダリングが必要headless_chrome不要(DOM直接取得)Chromeの事前インストールが必要
低レベルHTML解析reqwesthtml5everDOMノードを直接操作したい場合
軽量・同期のみureqscraperreqwestより依存が少ない

主要クレートのバージョン情報(2026年2月時点)

クレート最新バージョン用途
reqwest0.13.1HTTPクライアント(async/blocking両対応)
scraper0.25.0CSSセレクタベースのHTMLパーサー
tokio1.49.0非同期ランタイム
headless_chrome1.0.17ヘッドレスブラウザ操作
html5ever0.38.0低レベルHTMLパーサー(scraperの内部依存)
serde + serde_json1.x取得データのJSON出力

エラーハンドリングとリトライ処理

本番運用のスクレイパーでは、ネットワークエラーやHTTPエラーへの対処が不可欠です。

use reqwest::blocking::Client;
use std::time::Duration;
use std::thread;

fn fetch_with_retry(
    client: &Client,
    url: &str,
    max_retries: u32,
) -> Result<String, Box<dyn std::error::Error>> {
    let mut last_error = None;

    for attempt in 0..max_retries {
        match client.get(url).send() {
            Ok(resp) => {
                let status = resp.status();
                if status.is_success() {
                    return Ok(resp.text()?);
                }
                if status.as_u16() == 429 || status.is_server_error() {
                    let wait = Duration::from_secs(2u64.pow(attempt));
                    eprintln!("[{}] ステータス{}{}秒後にリトライ", url, status, wait.as_secs());
                    thread::sleep(wait);
                    last_error = Some(format!("HTTP {}", status));
                    continue;
                }
                return Err(format!("HTTP {}", status).into());
            }
            Err(e) => {
                let wait = Duration::from_secs(2u64.pow(attempt));
                eprintln!("[{}] エラー: {}{}秒後にリトライ", url, e, wait.as_secs());
                thread::sleep(wait);
                last_error = Some(e.to_string());
            }
        }
    }

    Err(format!("{}回リトライしても失敗: {:?}", max_retries, last_error).into())
}

指数バックオフ(1秒 → 2秒 → 4秒…)を使うことで、サーバー側のレート制限(HTTP 429)や一時的な障害に対して段階的に間隔を広げて再試行します。

取得データのファイル出力

スクレイピングで取得したデータをCSVやJSONに出力する方法です。

CSV出力

[dependencies]
csv = "1"
serde = { version = "1", features = ["derive"] }
use csv::Writer;
use serde::Serialize;

#[derive(Serialize)]
struct Article {
    title: String,
    url: String,
}

fn save_to_csv(articles: &[Article], path: &str) -> Result<(), Box<dyn std::error::Error>> {
    let mut writer = Writer::from_path(path)?;
    for article in articles {
        writer.serialize(article)?;
    }
    writer.flush()?;
    Ok(())
}

JSON出力

use serde::Serialize;
use std::fs;

#[derive(Serialize)]
struct Article {
    title: String,
    url: String,
}

fn save_to_json(articles: &[Article], path: &str) -> Result<(), Box<dyn std::error::Error>> {
    let json = serde_json::to_string_pretty(articles)?;
    fs::write(path, json)?;
    Ok(())
}

serdeのSerializeマクロをderiveするだけで、構造体をそのままCSVやJSONに変換できます。Rustの型システムにより、データ構造の変更がコンパイル時に検出されるため、出力フォーマットの不整合が起きにくいのが利点です。

robots.txtの確認と法的注意点

スクレイピングを行う前に確認すべき法的・倫理的なポイントがあります。

robots.txtの確認方法

対象サイトの/robots.txtを確認し、クローラーに対するアクセス制限を確認します。

use reqwest::blocking::get;

fn check_robots_txt(domain: &str) -> Result<(), Box<dyn std::error::Error>> {
    let url = format!("{}/robots.txt", domain);
    let body = get(&url)?.text()?;
    println!("=== robots.txt ===\n{}", body);
    Ok(())
}

法的に注意すべき3つのポイント

1. 利用規約の確認 対象サイトの利用規約でスクレイピングが禁止されていないか確認します。規約違反は民事上の責任を問われる可能性があります。

2. サーバーへの負荷 過度なアクセスによってサーバーに障害を引き起こした場合、刑事責任(業務妨害)に問われた事例もあります。リクエスト間隔を適切に設定し、robots.txtCrawl-delayを遵守します。

3. 個人情報の取り扱い 個人情報保護法に基づき、氏名・メールアドレスなどの個人情報を収集する場合は利用目的の明示が必要です。特に要配慮個人情報については原則として本人の同意が求められます。

なお、日本政府(総務省)もWebスクレイピング技術を消費者物価指数(CPI)の測定に活用しており、技術そのものが違法というわけではありません。利用方法と対象に応じた適切な運用が求められます。出典: DX/AI研究所

実践的なスクレイパーの構成例

ここまでの要素を組み合わせた、実用的なスクレイパーの全体像を示します。Zennの記事一覧からタイトルとURLを取得する例です。

use reqwest::blocking::Client;
use scraper::{Html, Selector};
use std::time::Duration;
use std::thread;
use serde::Serialize;

#[derive(Serialize, Debug)]
struct Article {
    title: String,
    url: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .user_agent("Mozilla/5.0 (compatible; RustScraper/1.0)")
        .timeout(Duration::from_secs(30))
        .build()?;

    let base_url = "https://zenn.dev/topics/rust";
    let body = client.get(base_url).send()?.text()?;
    let document = Html::parse_document(&body);

    let article_selector = Selector::parse("article a").unwrap();
    let mut articles: Vec<Article> = Vec::new();

    for element in document.select(&article_selector) {
        if let Some(href) = element.value().attr("href") {
            let title: String = element.text().collect();
            let title = title.trim().to_string();
            if !title.is_empty() && href.starts_with("/") {
                articles.push(Article {
                    title,
                    url: format!("https://zenn.dev{}", href),
                });
            }
        }
    }

    // 重複除去
    articles.dedup_by(|a, b| a.url == b.url);

    for article in &articles {
        println!("{}: {}", article.title, article.url);
    }

    // JSON出力
    let json = serde_json::to_string_pretty(&articles)?;
    std::fs::write("articles.json", json)?;
    println!("\n{}件の記事を保存しました", articles.len());

    Ok(())
}

よくある質問(FAQ)

Q. Rustのスクレイピングはどのくらい速いですか?

Pythonと比較して、CPU処理(HTML解析)では5〜10倍程度高速になるケースが報告されています。特にtokioによる非同期I/Oと組み合わせた場合、ネットワーク待ち時間を重複させることで全体のスループットが大幅に向上します。

Q. PythonのBeautifulSoupに相当するクレートはありますか?

scraperクレートが最も近い存在です。CSSセレクタによる要素選択など、BeautifulSoupと似たAPIを持っています。crates.ioにはsoupというクレートもありますが、scraperのほうがメンテナンスが活発で利用者も多いです。

Q. headless_chromeを使うにはChromeのインストールが必要ですか?

はい。headless_chromeはChrome DevTools Protocolを介してChromeを操作するため、ChromeまたはChromiumがシステムにインストールされている必要があります。Docker環境ではChromiumを含むイメージを利用すると設定が容易です。

Q. reqwestのblockingとasyncのどちらを使うべきですか?

単発のスクリプトや少数ページの取得にはblockingが手軽です。複数ページを並行取得する場合や、Webサーバーと組み合わせる場合はasync版を選びます。async版を使うにはtokioなどの非同期ランタイムが必要です。

まとめ

Rustでのスクレイピングは、reqwest + scraperの組み合わせで静的HTMLの取得・解析を行い、JavaScript描画が必要な場合はheadless_chromeを追加する構成が基本です。tokioによる非同期処理を使えば複数ページの並行取得も効率的に行えます。

Pythonと比べると学習コストは高いものの、型安全性によるバグの早期発見、ネイティブバイナリによる高速実行、所有権システムによるメモリ安全性という恩恵を受けられます。定期実行やサーバー常駐型のクローラー、大規模なデータ収集基盤を構築する場合には、Rustの採用を検討する価値があります。