In this Article

rust web scraping

In this Article

RustによるWebスクレイピングとは、Rust言語とクレートのエコシステムを使ってWebページを取得・解析する手法です。ガベージコレクターを使わず、予測可能な性能とメモリ安全性を得られるため、継続的に、あるいは大量に動かすクローラーに適しています。

このガイドでは、reqwest HTTPクライアントとHTML解析クレートのscraperを中心に、実践的なコード例で解説します。プロジェクトのセットアップと基本的な取得から、CSSセレクター、構造化データモデル、JSON・CSVへのエクスポート、ページネーション、再試行とバックオフを使ったエラー処理、tokioによる非同期並行処理、プロキシ設定、JavaScriptを使うサイト向けのヘッドレスブラウザーへのフォールバックまでを扱います。すべてのコード例はコンパイルして実行できます。

DataImpulseは、195か国で9,000万件超の住宅用、モバイル、データセンターIPアドレスを提供する、倫理的なプロキシプロバイダーです。有効期限のないトラフィックをGBあたり1ドルから従量課金で提供しており、Webスクレイピング、広告検証、価格監視、市場調査、複数アカウントの管理に利用できます。

重要な事実

  • コアスタック: RustによるWebスクレイピングでは通常、HTTPリクエストにreqwest、HTML解析にscraper、非同期並行処理にtokio、構造化データのエクスポートにserdeを使います。
  • 最適なプロキシの種類: 実際の利用者が使うIPをローテーションする住宅用プロキシ。
  • 価格: GBあたり1ドルからの従量課金制。有効期限はなく、サブスクリプションも不要です。
  • カバレッジ: 195か国で9,000万件以上の、倫理的に調達されたIP。
  • 信頼性: 成功率は 99.51%、G2 では 5 点満点中 4.8 と評価されています。
  • プロトコルとターゲティング: HTTP、HTTPS、SOCKS5 (国ターゲティングを含む)。
Rust フェッチからエクスポートまでの Web スクレイピング パイプライン

Web スクレイピングに Rust を使用する理由は何ですか?

Rustは、素早いプロトタイピングよりも、速度、少ないメモリ使用量、信頼性が重要なWebスクレイピングに適しています。コンパイル済みバイナリはハードウェアに近い層で動作し、借用チェッカーは並行処理のバグを本番ではなくコンパイル時に検出します。

こうした特性は、次のような場面で役立ちます。

  • スループット: コンパイル済みのRustクローラーは、インタープリター型言語より少ないCPU・メモリのオーバーヘッドで大量のページを処理できるため、長時間ジョブのインフラコストを抑えられます。
  • 安全な同時実行: 非同期ランタイムのtokioを使えば数千件のリクエストを同時に実行でき、型システムがタスク間のデータ競合を防ぎます。
  • 予測可能なリソース使用: ガベージコレクターがないためレイテンシが安定し、数時間から数日間動き続けるクローラーに向いています。

その一方で、学習曲線は急で、コンパイルにも時間がかかります。そのためRustは、一度きりのスクリプトよりも大規模に運用するプロジェクトで真価を発揮します。どの言語でも基本原則は同じです。robots.txtを尊重し、リクエスト頻度を調整し、一般的なルールに従いましょう。 Webスクレイピングのベストプラクティス。そうすれば、クローラーはサイトに配慮した動作を続けられます。

Rust スクレイピング プロジェクトはどのように設定しますか?

新しいCargoパッケージを作成し、Cargo.tomlで必要なクレートを宣言すれば、Rustスクレイピングプロジェクトをセットアップできます。CargoはRustのビルドツール兼パッケージマネージャーで、依存関係の解決とプロジェクトのコンパイルを行います。

まずcargo new rust-scraperを実行してプロジェクトを作成し、生成されたCargo.tomlを編集します。以下の依存関係で、HTTP、解析、非同期処理、シリアル化、CSV出力、ストリーム、使いやすいエラー処理まで、このガイドで必要なものを網羅できます。

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

[dependencies]
reqwest = { version = "0.12", features = ["json", "socks"] }
scraper = "0.20"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
csv = "1.3"
futures = "0.3"
anyhow = "1"
thiserror = "1"

reqwestはリクエストを処理します。socks機能でSOCKS5プロキシを有効にし、json機能でJSON用ヘルパーを追加します。scraperはCSSセレクターでHTMLを解析し、tokioは非同期ランタイムを提供します。serdeとserde_jsonはデータをシリアル化し、csvはスプレッドシート形式で出力し、futuresは並行処理用のストリームコンビネーターを提供します。anyhowとthiserrorはエラー処理を簡潔にします。最初のリクエストを作る前にcargo buildを実行し、必要なものをダウンロードしてコンパイルしてください。

reqwest と scraper を使用してページを取得して解析するにはどうすればよいですか?

reqwestでGETリクエストを送り、レスポンス本文をscraperに渡してCSSセレクターで検索します。次の例では、tokio上で非同期にページを取得し、すべてのH1要素のテキストを出力します。

use scraper::{Html, Selector};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let url = "https://example.com";
    let body = reqwest::get(url).await?.text().await?;

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

    for element in document.select(&heading) {
        let text: String = element.text().collect();
        println!("{}", text.trim());
    }
    Ok(())
}

処理の流れは同じです。reqwest::getがレスポンスを返し、.text()が本文を文字列として読み込み、Html::parse_documentがその文字列を検索可能なツリーに変換します。?演算子はネットワークまたはデコードのエラーをmainまで伝播します。一致した要素に対して.text()を呼び出すとテキストノードを集められるので、トリムして利用します。この生HTML方式は高速ですが、参照できるのはサーバーが返すマークアップだけです。後からブラウザーがJavaScriptで描画するコンテンツは取得できません。

CSS セレクターで要素をターゲットにするにはどうすればよいですか?

標準のCSSセレクター文字列をSelector::parseに渡して要素を指定します。ブラウザーコンソールで使うのとまったく同じ書き方です。scraperクレートは、タグ、クラス、ID、属性一致、子孫セレクター、構造擬似クラスに対応しています。

use scraper::Selector;

// Match by tag
let links = Selector::parse("a").unwrap();

// Match by class
let prices = Selector::parse(".price").unwrap();

// Match by id
let main = Selector::parse("#main-content").unwrap();

// Match by attribute value
let external = Selector::parse("a[rel=\"nofollow\"]").unwrap();

// Descendant path (a title inside a product card)
let titles = Selector::parse("div.product h2.title").unwrap();

// Direct child plus pseudo-class
let first_row = Selector::parse("table > tbody > tr:first-child").unwrap();

解析には多少のコストがかかるため、ループ内で毎回解析せず、各セレクターは一度だけ作成して再利用します。一致しない可能性がある場合は、.unwrap()ではなく.next()でフォールバックを用意すると、要素内のフィールドが欠けていても処理全体がパニックになりません。ブラウザーの要素ピッカーで対象ページを調べると、安定したクラス名や属性を見つけやすくなります。

スクレイピングされたデータを JSON および CSV にエクスポートするにはどうすればよいですか?

スクレイピングしたデータは、各レコードをSerializeを派生した構造体としてモデル化してエクスポートします。その構造体のスライスをserde_jsonでJSONへ、またはcsvクレートでCSVへ書き出します。型付きモデルを定義すると、抽出と出力の整合性を保ちやすくなり、コードも分かりやすくなります。

まずレコードをモデル化し、各コンテナー要素からフィールドを取り出します。

use scraper::{Html, Selector};
use serde::Serialize;

#[derive(Debug, Serialize)]
struct Product {
    name: String,
    price: String,
    url: String,
}

fn parse_products(html: &str) -> Vec<Product> {
    let document = Html::parse_document(html);
    let card = Selector::parse("div.product").unwrap();
    let name_sel = Selector::parse("h2.title").unwrap();
    let price_sel = Selector::parse("span.price").unwrap();
    let link_sel = Selector::parse("a").unwrap();

    let mut products = Vec::new();
    for card_el in document.select(&card) {
        let name = card_el
            .select(&name_sel)
            .next()
            .map(|e| e.text().collect::<String>().trim().to_string())
            .unwrap_or_default();
        let price = card_el
            .select(&price_sel)
            .next()
            .map(|e| e.text().collect::<String>().trim().to_string())
            .unwrap_or_default();
        let url = card_el
            .select(&link_sel)
            .next()
            .and_then(|e| e.value().attr("href"))
            .unwrap_or_default()
            .to_string();

        products.push(Product { name, price, url });
    }
    products
}

ProductSerializeを派生しているため、同じ構造体を追加作業なしで両方のエクスポーターに渡せます。

use std::fs::File;
use std::io::Write;

fn export_json(products: &[Product]) -> anyhow::Result<()> {
    let json = serde_json::to_string_pretty(products)?;
    let mut file = File::create("products.json")?;
    file.write_all(json.as_bytes())?;
    Ok(())
}

fn export_csv(products: &[Product]) -> anyhow::Result<()> {
    let mut writer = csv::Writer::from_path("products.csv")?;
    for product in products {
        writer.serialize(product)?;
    }
    writer.flush()?;
    Ok(())
}

serde_jsonのto_string_prettyは人間が読みやすいJSONを作成し、csvクレートのserializeは各構造体を、フィールド名をヘッダーにした行へ変換します。スクレイピング、JSON、CSVで同じ型付きモデルを再利用すれば、対象サイトの変更に合わせてRustパイプラインを保守しやすくなります。

ページネーションループを使用して複数のページをスクレイピングするにはどうすればよいですか?

ページ番号または次ページのURLをたどって複数ページをスクレイピングします。単一のreqwestクライアントを再利用すれば、リクエスト間で接続をプールできます。各URLを作成して取得し、前節の関数で解析します。ページから結果が返らなくなったら停止します。

use scraper::Html;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let client = reqwest::Client::new();
    let mut all_products = Vec::new();

    for page in 1..=10 {
        let url = format!("https://example.com/products?page={}", page);
        let body = client.get(&url).send().await?.text().await?;
        let products = parse_products(&body);

        // Stop when a page returns no results
        if products.is_empty() {
            break;
        }
        all_products.extend(products);

        // Pause between requests to stay polite
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }

    println!("Collected {} products", all_products.len());
    Ok(())
}

reqwest::Client::new()で作ったクライアントをループの外で使い回すと、TCP接続を再利用できるため、リクエストごとに新しいクライアントを作るより大幅に高速です。空の結果を確認すれば、最終ページに達した時点で自然にループを終了できます。また、サーバーに負荷をかけないよう、リクエストの間に短い待機を入れます。番号付きページではなく次へリンクを使うサイトでは、各反復で次ページのアンカーのhrefを取得し、リンクがなくなるまで追跡します。

バックオフによるエラーと再試行はどのように処理しますか?

取得関数から型付きエラーを返し、指数バックオフで一時的なエラーを再試行します。ネットワークはタイムアウトし、サーバーは一時的なステータスコードを返すことがあります。堅牢なスクレイパーは、単発の障害を致命的なものではなく想定内の事象として扱います。

thiserrorクレートを使うと、reqwestのエラーから自動変換される簡潔なエラー列挙型を定義できます。

use thiserror::Error;

#[derive(Error, Debug)]
enum ScrapeError {
    #[error("request failed: {0}")]
    Request(#[from] reqwest::Error),

    #[error("unexpected status code: {0}")]
    Status(reqwest::StatusCode),

    #[error("selector matched no elements")]
    Empty,
}

async fn fetch_page(client: &reqwest::Client, url: &str) -> Result<String, ScrapeError> {
    let response = client.get(url).send().await?;
    if !response.status().is_success() {
        return Err(ScrapeError::Status(response.status()));
    }
    Ok(response.text().await?)
}

#[from]属性により、reqwestのエラーは?演算子を通じてScrapeError::Requestへ自動変換されます。型付きエラーに加え、リクエストを再試行ループで囲みます。再試行のたびに待機時間を長くします。

use std::time::Duration;

async fn fetch_with_backoff(client: &reqwest::Client, url: &str) -> anyhow::Result<String> {
    let max_attempts = 5;
    for attempt in 1..=max_attempts {
        match client.get(url).send().await {
            Ok(resp) if resp.status().is_success() => {
                return Ok(resp.text().await?);
            }
            Ok(resp) => {
                eprintln!("attempt {attempt}: status {}", resp.status());
            }
            Err(err) => {
                eprintln!("attempt {attempt}: {err}");
            }
        }
        // Exponential backoff: 0.5s, 1s, 2s, 4s, 8s
        let backoff = Duration::from_millis(500 * 2u64.pow(attempt - 1));
        tokio::time::sleep(backoff).await;
    }
    anyhow::bail!("giving up on {url} after {max_attempts} attempts")
}

指数バックオフ(ここでは0.5秒、1秒、2秒、4秒、8秒)は、レート制限中または一時的に過負荷のサーバーに連続して負荷をかけず、回復の時間を与えます。再試行とローテーションプロキシを組み合わせると、さらに効果的です。あるIPでブロックまたはスロットリングされたリクエストでも、別の出口IPからの次回試行なら成功する可能性があります。

tokio で同時リクエストを実行するにはどうすればよいですか?

URLのリストを非同期ストリームに変換し、buffer_unorderedを適用して同時リクエストを実行します。これにより、一度に送信するリクエスト数を制限できます。Rustのスクレイパーでtokioとfuturesを使う際、これは安全かつ特に効果の大きい性能改善手段です。

use futures::stream::{self, StreamExt};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let client = reqwest::Client::new();
    let urls: Vec<String> = (1..=50)
        .map(|n| format!("https://example.com/item/{}", n))
        .collect();

    let bodies = stream::iter(urls)
        .map(|url| {
            let client = client.clone();
            async move {
                let text = client.get(&url).send().await?.text().await?;
                Ok::<_, reqwest::Error>((url, text))
            }
        })
        .buffer_unordered(10); // at most 10 requests in flight

    bodies
        .for_each(|result| async {
            match result {
                Ok((url, body)) => println!("{}: {} bytes", url, body.len()),
                Err(err) => eprintln!("error: {}", err),
            }
        })
        .await;

    Ok(())
}

ここではstream::iterがURLリストをストリームに変換します。.mapはURLごとに非同期リクエストを1件作成し、buffer_unordered(10)は最大10件を同時実行して、完了した順に結果を返します。reqwestのClientは共有接続プールをArcで保持しているため、タスクごとにクローンしても低コストです。同時実行数を調整して、対象サイトへの負荷と速度のバランスを取ります。10から20は妥当な出発点で、さらに大きな並行数とプロキシプールを組み合わせれば、リクエストを多数のIPに分散できます。

Rust スクレイピング トラフィックをプロキシ経由でルーティングするにはどうすればよいですか?

reqwestクライアントを構築し、reqwest::Proxy.basic_authでゲートウェイと認証情報を設定すれば、トラフィックをプロキシ経由で送れます。IPをローテーションしてリクエストを送ると、負荷を分散でき、地域限定コンテンツにもアクセスしやすくなり、レート制限を受けにくくなります。IPのローテーションは、 ブロックされずにスクレイピング

use reqwest::Proxy;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Authenticated gateway; rotates the exit IP on each request
    let proxy = Proxy::all("http://gw.dataimpulse.com:823")?
        .basic_auth("login", "password");

    let client = reqwest::Client::builder()
        .proxy(proxy)
        .timeout(std::time::Duration::from_secs(30))
        .build()?;

    let body = client
        .get("https://httpbin.org/ip")
        .send()
        .await?
        .text()
        .await?;

    println!("{}", body);
    Ok(())
}

loginpasswordを自分の認証情報に置き換え、必要に応じてネットワーク用のポートを調整します。プロキシが407ステータスでリクエストを拒否する場合は、ほとんどが認証情報の欠落または誤りです。 HTTP エラー 407と、 プロキシ認証では、解決方法を説明しています。DataImpulseは 居住用プロキシ, データセンタープロキシ、と モバイルプロキシをHTTP、HTTPS、SOCKS5で提供し、ローテーションセッションとスティッキーセッションに対応しています。国ターゲティングは標準で利用できますが、州、都市、ZIP、ASNのターゲティングは有料オプションです。

JavaScript の重いページをどのようにスクレイピングしますか? Rust が正しい選択なのはどのような場合ですか?

reqwestとscraperはサーバーが返す生のHTMLしか扱わず、スクリプトも実行しません。JavaScriptを多用するページは、実際のヘッドレスブラウザーを操作してスクレイピングします。コンテンツがクライアント側の描画後にしか現れない場合は、WebDriverプロトコルを使うthirtyfourや、DevTools Protocol経由でChromeを操作するchromiumoxideなどのブラウザー自動化クレートを使用します。

use thirtyfour::prelude::*;

#[tokio::main]
async fn main() -> WebDriverResult<()> {
    let caps = DesiredCapabilities::chrome();
    let driver = WebDriver::new("http://localhost:9515", caps).await?;

    driver.goto("https://example.com").await?;

    // Wait for client-side content to render, then read it
    let heading = driver.find(By::Css("h1")).await?;
    println!("{}", heading.text().await?);

    driver.quit().await?;
    Ok(())
}

thirtyfourの例では、起動中のchromedriverに接続してページを読み込み、要素が描画されるのを待ってからテキストを取得します。この方法は通常のHTTP取得よりはるかに負荷が高いため、本当に必要なページだけに使い、他は高速なreqwest経路を維持してください。詳しくは次のガイドを参照してください。 動的WebページのスクレイピングJavaScript を使用した Web スクレイピング。以下の表に、主なクレートと役割をまとめます。

クレート 役割 JavaScriptを実行 主な用途
reqwest HTTP クライアント いいえ 生の HTML および JSON API の取得
scraper HTMLパーサー いいえ CSSセレクターによるデータ抽出
thirtyfour WebDriverクライアント はい 完全なブラウザー自動化とフォーム操作
chromiumoxide DevTools Protocol はい 高速ヘッドレス Chrome 制御

言語を選ぶ際は、持続的な高性能と少ないリソース使用が必要ならRust、素早い開発とScrapyやPlaywrightなど充実したスクレイピングエコシステムを重視するならPythonが向いています。よくある進め方は、Pythonで抽出ルールを試作し、ロジックが固まって処理量が増えた段階で性能上重要な部分をRustへ移すことです。どちらを選んでも、ネットワーク層はパーサーと同じくらい重要です。倫理的に調達されたプロキシは、大規模ジョブの信頼性を支えます。

主要な Rust クレートとそのスクレイピングの役割

よくある質問

Web スクレイピングに最適な Rust クレートはどれですか?

最も一般的な組み合わせは、HTTPリクエスト用のreqwestと、CSSセレクターでHTMLを解析するscraperです。非同期並行処理にはtokio、データのエクスポートにはserdeを使います。サイトでJavaScriptによる描画が必要な場合は、thirtyfourやchromiumoxideなどのブラウザー自動化クレートを追加します。

Rust は JavaScript でレンダリングされたページをスクレイピングできますか?

reqwestとscraperだけではできません。扱えるのはサーバーが返す生のHTMLだけで、スクリプトは実行されません。クライアント側で描画されたコンテンツをスクレイピングするには、thirtyfourなどのWebDriverクレート、またはchromiumoxideなどのDevTools Protocolクレートを介してヘッドレスブラウザーを操作します。

Rust でスクレイピングされたデータをエクスポートするにはどうすればよいですか?

各レコードをserdeのSerializeを派生した構造体としてモデル化し、その構造体のスライスをserde_jsonでJSONへ、またはcsvクレートでCSVへ書き出します。型付きモデルを1つ使えば、抽出ロジックを重複させずに両形式へ出力できます。

サイトに過負荷をかけずに同時リクエストを実行するにはどうすればよいでしょうか?

URLs を非同期ストリームに変換し、一度に実行されるリクエストの数を制限する固定制限付きのbuffer_unowned を適用します。約 10 ~ 20 個の同時リクエストから始めて、より高い同時実行性と回転プロキシ プールを組み合わせて、負荷を多くの IPs 全体に分散します。

Rust Web スクレイピングにはプロキシが必要ですか?

小規模なジョブでは必須ではありません。しかし大規模になると、プロキシはリクエストの分散や地域限定コンテンツへのアクセスに役立ちます。reqwest::Proxyとbasic_authで設定し、ローテーションする住宅用またはモバイルIPを使うと、レート制限やブロックを抑えられます。

DataImpulse が適切ではないのはどのような場合ですか?

静的ISPプロキシ、フルマネージドのスクレイピングAPI、または銀行・政府サイトへのアクセスが必要な場合、DataImpulseは適した選択肢ではありません。同社は公開データの収集やコンテンツへのアクセス向けに、住宅用、モバイル、データセンターのプロキシをローテーションして提供することに注力しています。

倫理的なプロキシを使用して確実にスクレイピング

Rustスクレイパーで取得、解析、エクスポートまで正常に行えるようになったら、信頼できるプロキシネットワークが大規模運用を支えます。DataImpulseは、倫理的に調達されたIPをGBあたり1ドルからの従量課金制で提供しており、トラフィックに有効期限はありません。 アカウントを作成するを作成すれば、数分で最初のリクエストをルーティングできます。


Share article: