RustでGoogle Chromeのヘッドレスモードを動かします。
いきさつ
現在、弊大学の学生ポータル(Web)のフロントエンドを改善する名目でいろいろやっているのですが、
当然APIなどは公開されていないためWebページを取得してDOMを解析、データを独自に整形して表示するといういわゆる「Webスクレイピング」の技術が必要となりました。
ここで、Webスクレイピングとなると大きく二つの選択肢があるかと思います。
- Webページをcurlなどで直接取得して解析する
この方式はなんといっても軽いことが特徴ですが、複雑な動きをするWebサイト(SPAや複雑なリダイレクトを必要とするサイト)のスクレイピングには利用できません。
いやすごく頑張ればできなくはないけど - ブラウザをそのまま使う
そのままというよりは、今回のヘッドレスモードのChromeやPhantomJSなどといった選択肢です。
ブラウザと同等の動きをするので複雑なリダイレクトを簡単に処理できる反面、画像やスタイルシートといったスクレイピングには不要なデータの取得も発生するため一般的には遅くなります。ただこれは設定で無効にできることもあるのであまり差は出ないと思います。
学生ポータルとなるとログイン処理が伴うため後者の方法を採用しています。言語は今をときめくRustを採用しています。
Headless Chrome on Rust
Headless Chromeについて簡単に説明すると、バージョン62からGoogle Chromeに正式に搭載された、GUIなしで動くモードのことです。ChromeとはChrome DevTools Protocolに従って通信を行うので、プラットフォームは問わずChromeが動けばどの環境でも同様のコードで動かすことが可能です。DevTools Protocolではデータ形式にJSON、通信経路にWebSocketを利用します。
JavaScriptには駆動用のライブラリが存在するのですが、例によって(?)Rustにはライブラリが存在しないため製作をしました。ただし実際に汎用ライブラリとして使えるように切り出せていないので公開はまだ先になりそうです。
JavaScript as Query
Headless Chromeにはページをコントロールするための様々なメソッドが用意されていますが、ほとんどがexperimentalとなっており試験的にベータ版を入れていないと使えなくなっています。今回の場合は対象ユーザーが一般的な大学生になるので特殊な依存性というのはできれば排除したいです。
そこで使えるのがJavaScriptです。Headless ChromeにはJavaScriptをページ内で非同期に実行し、その結果を返してくれるメソッドが存在します(Runtime.execute
)。例えば
Array.prototype.map.call(document.querySelectorAll("#gnav ul li"), x => x.textContent.trim())
などというスクリプトをページ内で実行すればid=gnav
なコンテナのリストの要素の字面をすべて取得することができます。うちではこれを利用してページ内の様々な情報を取得しています。
JavaScriptを利用する利点は
-
querySelector
が使える(DOM処理が楽) - 値を返却する前にいくらか処理を行うことができる
というとこです。うまい具合に通信情報を減らすことができればスクレイパーの高速化が見込めます(たぶん)。
難点は何といっても型による保証が得られないことです。実行時にしかフォーマットが正しいかどうかわからないのでどのデシリアライザを使ったとしても実行時エラーにせざるを得なくて精神的につらいです。
この難点を解決すべく、うちのプロジェクトでは内部的にjsqueryというちっちゃなライブラリを使用しています。JavaScriptと同名のメソッドを使えつつ、返り値や引数に型情報を付加しているので思想としてはほぼTypeScriptと同様なんですが、違いはjsqueryはRustの文法で書けるという点です。マクロでもないのでしっかりRLSによる補完も効きます。例えば上記のスクリプトはjsqueryでは次のように書けます。
jsq::Document.query_selector_all("#gnav ul li".to_owned()).map_auto("x", jsqCustomExpr!([jsq::types::String] "x.textContent.trim()"))
素のJavaScriptよりもかなり記述量が多くなってしまいますが、たとえばquerySelectorAll
の結果に対して間違えて.textContent
を適用して実行時エラーになる心配がなくなります。また、JavaScriptはquerySelectorAll
の結果に対して.map
とつなげることができず、Array.prototype.map
をcall
する必要があります。同じリストデータなのにソースの型によって記述方法が変わるのは厄介なのでjsqueryではここの部分を吸収しています。
フレームの取り扱い
弊大学では学生からみえるポータルシステムが2系統あって、時間割など一番にほしいデータは旧システムにアクセスしないと取得できないのですが、そのページはフレームを利用していて若干仕組みが複雑になっています。
Headless Chromeで普通にJavaScriptを実行するとトップレベルドキュメント内にしか作用しないため、各フレーム内のデータにアクセスするには若干手順を踏みます。
Chromeの各フレームは一つ以上の実行コンテキストを持ち、そのコンテキストではフレーム内のドキュメントが見えます。フレーム別のコンテキストは生成されるとRuntime.executionContextCreated
イベントが飛んできますのでそれを捕捉します。このイベントのauxデータに、フレームコンテキストである場合はframeId
という文字列データが存在しているはずなので、それが目的のフレームのIDと一致していれば目的のフレームを操作するためにコンテキストのid
を利用することができます。目的のフレームのIDはPage.frameNavigated
イベントで通知されますので記録しておきましょう。実行時にコンテキストを指定するにはRuntime.evaluate
のparams.contextId
に指定します。
Runtime.executionContextCreated
とPage.frameNavigated
のどちらが先に飛んでくるかがちょっとわからないので、どんな順番で飛んできても動作できるようにしておくのがいいと思われます。実装例としてはPctg-x8/DigitalCampus2017P:src/remote_campus.rsくらいしか出せないのですが、重要な部分だけ抜き出すとこんな感じの実装でとりあえず動いています。
// フレームIDと対応する実行コンテキストを格納(self.main_frameとself.menu_frameの型)
#[derive(Debug, PartialEq, Eq)]
pub enum ScriptContextState { Unloaded, Empty(String), Context(String, u64) }
...
// イベント受け取りループ
SessionEventLoop!(self.remote.session;
{
// Page.frameNavigatedの捕捉
page::FrameNavigatedOwned => |e: page::FrameNavigatedOwned|
{
self.remote.session.dispatch_frame_navigated(&e.borrow());
// name=MainFrameかname=MenuFrameで分岐して、それぞれにフレームIDを格納
// もしすでに実行コンテキストが登録済みで、かつフレームIDがそれと違う場合は
// なかったこと(Empty)にする、という実装になっています(navigated)
match e.frame.name.as_ref().map(|s| s as &str)
{
Some("MainFrame") => { self.ctx_main_frame.navigated(e.frame.id); },
Some("MenuFrame") => { self.ctx_menu_frame.navigated(e.frame.id); },
_ => ()
}
};
// Runtime.executionContextCreatedの捕捉
runtime::ExecutionContextCreated => |e: runtime::ExecutionContextCreated|
{
if let Some(aux) = e.context.aux_data
{
if let Some(fid) = aux.get("frameId").and_then(JValue::as_str)
{
// 両方のフレームに対してコンテキストの適用を試みる
// 未登録時(Unloaded)はとりあえず渡されたペアで登録(Context)しておいて、
// Empty/Contextの場合はフレームIDを照合してコンテキストIDを格納する、
// という実装になっています(try_attach_context)
self.ctx_main_frame.try_attach_context(fid, e.context.id);
self.ctx_menu_frame.try_attach_context(fid, e.context.id);
}
}
};
// Runtime.executionContextDestroyedの捕捉
// 実行コンテキストが削除された際に飛ぶ
runtime::ExecutionContextDestroyed => |e: runtime::ExecutionContextDestroyed|
{
// 対応するコンテキストをEmptyに
if Some(e.execution_context_id) == self.ctx_main_frame.contextid() { self.ctx_main_frame.detach_context(); }
if Some(e.execution_context_id) == self.ctx_menu_frame.contextid() { self.ctx_menu_frame.detach_context(); }
};
// Runtime.executionContextsClearedの捕捉
// 実行コンテキストがすべて消えた際に(フレームページのリロードなど)飛ぶ
runtime::ExecutionContextsCleared => |_|
{
// すべてのコンテキストをEmptyに
self.ctx_main_frame.detach_context();
self.ctx_menu_frame.detach_context();
};
// Page.frameStoppedLoadingOwnedの捕捉
// フレームのロードが完了し、onloadイベントの実行完了で飛ぶ
page::FrameStoppedLoadingOwned => |e: page::FrameStoppedLoadingOwned|
{
// MainFrameとMenuFrameの双方の完了を待つ
main_completion = main_completion || self.ctx_main_frame.frameid() == Some(&e.frame_id);
menu_completion = menu_completion || self.ctx_menu_frame.frameid() == Some(&e.frame_id);
main_completion && menu_completion
}
});
おしまい
フレーム使われるとスクレイパーが複雑になるのではやく滅びてほしいです。