ちょっと話題の記事

Rustでブラウザを操作する「rust-headless-chrome」を試してみた

RustでChromeのヘッドレスブラウザを動かしてみた検証記事です
2022.04.21

こんにちは。MAD事業部のきんじょーです。

最近Rustの入門書を読んだので、何かに使ってみようと模索していました。 その中で、Rustでヘッドレスブラウザを動かすライブラリを検証したのでこの記事にまとめます。

ヘッドレスブラウザとは

ヘッドレスブラウザはGUIを持たないWebブラウザです。 一般的なブラウザと同じく、HTMLを解析しJavaScriptを実行できますが、GUIを持たないため、CLIやプログラム上から操作を行います。

主に以下のような用途で用いられます。

  • WebアプリケーションのE2Eテストの自動化
  • Webページのスクリーンショット取得
  • JavaScriptの自動テスト
  • Webページで行うワークフロー処理の自動化
  • Webページのクローリング

古くはPhantomJSCasperJSなどを用いていましたが、現在では主要なブラウザもネイティブでヘッドレスモードを搭載しています。

rust-headless-chromeとは

Chromeが提供するヘッドレスブラウザである「Headless Chrome」をRustで扱えるようにラップしたRust製のライブラリです。

Node.jsではPuppeteerが有名ですが、rust-headless-browserはRustにおけるその立ち位置のようです。Puppeteerと100%互換性はありませんが、ほとんどのブラウザテストやクローリングの用途では必要十分な機能を備えています。

試してみる

早速試してみます。 ソースコードは以下のリポジトリにあるので、興味のある方は参照ください。
joe-king-sh/rust-headless-chrome-example

環境構築

プロジェクト作成

% cargo init rust-headless-chrome-example && cd ./rust-headless-chrome-example

クレートのインストール

% cargo install cargo-edit
% cargo add headless-chrome
% cargo add failure

ビルド & 実行

% cargo run
Hello, world!

これで準備は完了です。

基本操作とキャプチャ取得

まずは、Google検索でDevelopersIOを検索し、キャプチャを撮ってみます。

fn search_devio() -> Fallible<()> {
    // ブラウザとタブの初期化
    let browser = Browser::default()?;
    let tab = browser.wait_for_initial_tab()?;
    tab.set_default_timeout(std::time::Duration::from_secs(200));

    // Googleを開く
    tab.navigate_to("https://www.google.com/")?;
    tab.wait_until_navigated()?;
    let jpeg_data = tab.capture_screenshot(ScreenshotFormat::JPEG(Some(75)), None, true)?;
    fs::write("screenshot.jpg", &jpeg_data)?;

    // 検索テキストボックスへフォーカス
    tab.wait_for_element("input[name=q]")?.click()?;
    // テキストボックスへ入力
    tab.type_str("DevelopersIO")?;
    let jpeg_data = tab.capture_screenshot(ScreenshotFormat::JPEG(Some(75)), None, true)?;
    fs::write("screenshot1-2.jpg", &jpeg_data)?;

    // 「I'm feeling lucky」ボタンを押下
    tab.wait_for_element("input[name=btnI]")?.click()?;
    sleep(Duration::from_secs(5));
    let jpeg_data = tab.capture_screenshot(ScreenshotFormat::JPEG(Some(75)), None, true)?;
    fs::write("screenshot2.jpg", &jpeg_data)?;

    Ok(())
}

実行した結果のスクリーンショットを確認します。

  1. Google.comを開いて open_google
  2. 「DevelopersIO」と入力し、「I'm feeling lucky」を押下 enter_search_text
  3. DevelopersIOへ画面遷移できています🎉 click_im_feeling_lucky

レスポンスの確認

サーバーからのレスポンスをすべてログ出力してみます。

fn check_response() -> Fallible<()> {
    // ブラウザとタブの初期化
    let browser = Browser::default()?;
    let tab = browser.wait_for_initial_tab()?;
    tab.set_default_timeout(std::time::Duration::from_secs(200));

    // レスポンスのハンドリングを有効化
    let responses = Arc::new(Mutex::new(Vec::new()));
    let responses2 = responses.clone();
    tab.enable_response_handling(Box::new(move |response, fetch_body| {
        sleep(Duration::from_millis(500));
        let body = fetch_body().unwrap();
        responses2.lock().unwrap().push((response, body));
    }))?;

    // Googleを開く
    tab.navigate_to("https://www.google.com/")?;
    tab.wait_until_navigated()?;

    let final_responses: Vec<_> = responses.lock().unwrap().clone();
    println!("{:#?}", final_responses);

    Ok(())
}

ログ出力結果(抜粋)

    (
        ResponseReceivedEventParams {
            request_id: "74514C369D122D292F3C51FD447AA7CA",
            loader_id: "74514C369D122D292F3C51FD447AA7CA",
            timestamp: 44441.675402,
            _type: Document,
            response: Response {
                url: "https://www.google.com/",
                status: 200,
                status_text: "",
                headers: {
                    "cache-control": "private, max-age=0",
                    "alt-svc": "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"",
                    "content-encoding": "gzip",
                    "expires": "-1",
                    "accept-ch": "Sec-CH-UA-Platform\nSec-CH-UA-Platform-Version\nSec-CH-UA-Full-Version\nSec-CH-UA-Arch\nSec-CH-UA-Model\nSec-CH-UA-Bitness",
                    "bfcache-opt-in": "unload",
                    "date": "Wed, 20 Apr 2022 16:02:12 GMT",
                    "p3p": "CP=\"This is not a P3P policy! See g.co/p3phelp for more info.\"",
                    "x-frame-options": "SAMEORIGIN",
                    "x-xss-protection": "0",
                    "content-type": "text/html; charset=UTF-8",
                    "server": "gws",
                    "content-length": "43536",
                },
                headers_text: None,
                mime_type: "text/html",
                request_headers: None,
                request_headers_text: None,
                connection_reused: false,
                connection_id: 13,
                remote_ip_address: Some(
                    "142.250.207.4",
                ),
                remote_port: Some(
                    443,
                ),
                from_disk_cache: Some(
                    false,
                ),
                from_service_worker: Some(
                    false,
                ),
                from_prefetch_cache: Some(
                    false,
                ),
                encoded_data_length: 859,
                protocol: Some(
                    "h2",
                ),
            },
            frame_id: Some(
                "F3DFBAF78A8BDF57378165F1B3021E2F",
            ),
        },
        GetResponseBodyReturnObject {
            body: "<!doctype html><html itemscope=\"\" itemtype=\"http://schema.org/WebPage\" lang=\"ja\"><head><meta charset=\"UTF-8\"><meta content=\"/images/branding/googleg/1x/googleg_standard_color_128dp.png\" itemprop=\"image\">....中略...</body></html>",
            base64_encoded: false,
        },
    ),

サーバーからのレスポンスを詳細に確認することができました。

インターセプト

最後に、リクエストのインターセプトを試してみます。

まずは、インターセプトしたリクエストのレスポンスを手作りして返却するため、以下のクレートをインストールします。

% cargo add tiny_http
% cargo add base64

実装を進めます。

fn intercept_request() -> Fallible<()> {
    // ブラウザとタブの初期化
    let browser = Browser::default()?;
    let tab = browser.wait_for_initial_tab()?;
    tab.set_default_timeout(std::time::Duration::from_secs(200));

    // インターセプトする対象のパターンを指定
    let patterns = vec![RequestPattern {
        url_pattern: Some("https://www.google.com/"),
        resource_type: None,
        interception_stage: Some("Request"),
    }];

    // インターセプトを有効化
    tab.enable_request_interception(
        &patterns,
        Box::new(|_transport, _session_id, intercepted| {
            if intercepted.request.url == "https://www.google.com/" {
                println!("intercept!");
                let body = "This request was intercepted!";
                let js_response = tiny_http::Response::new(
                    200.into(),
                    vec![tiny_http::Header::from_bytes(
                        &b"Content-Type"[..],
                        &b"application/javascript"[..],
                    )
                    .unwrap()],
                    body.as_bytes(),
                    Some(body.len()),
                    None,
                );

                let mut wrapped_writer = Vec::new();
                js_response
                    .raw_print(&mut wrapped_writer, (1, 2).into(), &[], false, None)
                    .unwrap();

                let base64_response = base64::encode(&wrapped_writer);

                RequestInterceptionDecision::Response(base64_response)
            } else {
                println!("continue!");
                RequestInterceptionDecision::Continue
            }
        }),
    )?;

    // Googleを開く
    tab.navigate_to("https://www.google.com/")?;
    tab.wait_until_navigated()?;

    sleep(Duration::from_secs(5));
    let jpeg_data = tab.capture_screenshot(ScreenshotFormat::JPEG(Some(75)), None, true)?;
    fs::write("intercepted_screenshot.jpg", &jpeg_data)?;

    Ok(())
}

実行結果 intercepted_request www.google.comにリクエストが送信される前に、リクエストがインターセプトされて、手動で設定したレスポンスが返却されてきているのがわかります。 何らかの事情で、とあるサーバーへのリクエストだけモックしたい時になど使えそうです。

まとめ

というわけで、Rustでヘッドレスブラウザを操作する「rust-headless-chrome」を検証してみました。 Rustにまだあまり慣れていないため、実装するのに少しハードルが高く感じましたが、ヘッドレスブラウザ自体は、E2EテストやWebクローリング、日々のルーティンワーク自動化など、やはり色々使い道はあるなと感じました。

この記事が少しでも誰かの役に立てば幸いです。

以上、MAD事業部のきんじょーでした。