背景
API で提供されていないけれどWEBサイト上では提供されている情報を取得したい場合スクレイピングを利用して情報の収集を行います。その中でも特にレンダリングのためにjavascriptを利用したサイトの場合、curlやwget、あるいはcheerioなどを利用した単純なアクセスでは網羅できません。そこで、Phantom.jsやpuppeteerと言ったブラウザを利用する仕組みを使ってスクレイピングを行う必要があります。
今回は作成中のサービスで、依存している外部サービスの情報の取得を行う必要があったので、puppeteerを利用し、さらにそろをAPIとしてサービス内部で利用できるようにGoogle AppEngine上で動作させたお話。あと、ついでにライブラリを利用する言語としてkotlinを使ったお話。
どれを使うか
Reactでレンダリングされているサイトをスクレイピングしようと思ったので必然的にブラウザを利用したものである必要がありました。なんでも良いけど、そのまんまchromeを使っているpuppeteerを選択。さらに、puppeteerには$と言ったcssセレクター用の関数が用意されていましたが、やっぱりjQueryライクな強力なセレクターがほしいと思ったので、cheerioも並行して使うことにしました。
また、このプロジェクトは自分の以前のエントリーFull stack kotlin プロジェクトで開発をはじめて1週間くらいたった感想 - Qiitaと関連しているのですが、全てkotlinで実装を行っています。そのため、kotlin.jsを利用してこれらrのライブラリへのアクセスを行います。
どこで動かすのか
本当ならCloud Functionsあたりを使いたかったのですが実行環境にはchromiumのインストールが必要だったのでApp EngineのCustom Runtimeを使いました。自前でDockerfileを用意する必要があるのですが、puppeteerのtroubleshootingにサンプルのDockerfileが用意されているので、流用して次のようなDockerfileを作りました。
FROM launcher.gcr.io/google/nodejs
RUN apt-get update && apt-get install -y wget --no-install-recommends \
&& wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
&& sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
&& apt-get update \
&& apt-get install -y google-chrome-unstable \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get purge --auto-remove -y curl \
&& rm -rf /src/*.deb
COPY ./ /app/
RUN npm --unsafe-perm install --production
また、troubleshooting.mdにありますが、chromiumの起動オプションに--no-sandbox
,--disable-setuid-sandbox
というオプションを指定する必要があります。これをkotlin.jsのコードから実行するには次のようにします。
val browser = Puppeteer.launch(object {}.also { it: dynamic ->
it.args = arrayOf("--no-sandbox", "--disable-setuid-sandbox")
}).await()
object {}.also
とかやってるのがキモいっちゃキモいですね。動的にdynamic
なオブジェクトをkotlin.jsで作る方法があるのかは未調査です。
また、await
というメソッドが登場しますが、これは後で紹介するPromise
の拡張メソッドです。
ちょっとしたハマりどころ
async/awaitについて
node.jsのasync/awaitに似ているとしてkotlinのcoroutineがあるわけですが、これを利用してPromiseの扱いを簡単にします。
まず、suspend
なメソッドを使ってPromiseを生成できるようにします。またPromiseに対してawait
メソッド追加してawaitっぽいことができるようにします。
suspend fun <T> Promise<T>.await(): T = suspendCoroutine { cont ->
then({ cont.resume(it) }, { cont.resumeWithException(it) })
}
fun <T> async(x: suspend () -> T): Promise<T> {
return Promise { resolve, reject ->
x.startCoroutine(object : Continuation<T> {
override val context = EmptyCoroutineContext
override fun resume(value: T) {
resolve(value)
}
override fun resumeWithException(exception: Throwable) {
reject(exception)
}
})
}
}
次に Puppeteerのインターフェースをkotlinから使いやすくするためのラッパーを作ります。
@Suppress("FunctionName")
@JsModule("puppeteer")
external object Puppeteer {
class Page {
fun goto(url: String, options: dynamic): Promise<dynamic>
fun waitFor(element: String, options: dynamic): Promise<dynamic>
fun waitFor(num: Int): Promise<dynamic>
fun content(): Promise<dynamic>
fun click(selector: dynamic): Promise<dynamic>
fun close(): Promise<dynamic>
fun evaluate(pageFunction: Function<dynamic>): Promise<dynamic>
}
class Browser {
fun newPage(): Promise<Page>
fun close(): Promise<dynamic>
fun wsEndpoint(): String
}
fun launch(options: dynamic): Promise<Browser>
}
こうすることで次のようなコードを書くことができるようになります。
async {
val browser = Puppeteer.launch(object {}.also { it: dynamic ->
it.devtools = true
it.args = arrayOf("--no-sandbox", "--disable-setuid-sandbox")
}).await()
try {
val page = browser.newPage().await()
// ページを読み込む
page.goto(targetUrl, object {}.also { it: dynamic -> it.timeout = 10 * 1000 }).await()
page.waitFor(ARTICLES, object {}.also { it: dynamic -> it.timeout = 10 * 1000 }).await()
// この時点のURLをフェッチする
val content = page.content().await()
} finally {
browser.close().await
}
}
いい感じですね。
cheerioとpuppeteerのセレクターの取扱
HTMLのタグのパースはcheerioでやるけれどボタンのクリックなんかはpuppeteerのインターフェースを利用する必要があります。これについてはいい方法が思い浮かばなかったので「頑張って気をつける」スタイルになってしまっています。
// $
val doll = cheerio.load(content)
val directions = doll(DIRECTIONS).find("div[id^=section-directions-trip]")
// page.click は cheerio のオブジェクトは渡せないので、 `forEach` は使えない
val routes = 0.until(directions.length as Int).mapNotNull { i ->
if (i > 0) {
// 2番目以降の要素は expand しないと値段などの情報がない
page.click("div#section-directions-trip-$i").await()
}
ここはkotlinとかjsとか関係なく発生する気をつけポイントかなと思いました。
まとめ
kotlinを使ってPuppeteerを利用しcheerioを使ってスクレイピングをする方法を紹介しました。なお、結局この構成はpuppeteerをバックエンドで動かすとけっこうコストがかかるのでやめようかなぁと思ってます。ただ、cronで動かす感じの仕組みであればコスト計算も変わってくるので使えるかなと思い紹介しました。