バックグラウンドで使うpuppeteer with Kotlin

## 背景

API で提供されていないけれどWEBサイト上では提供されている情報を取得したい場合スクレイピングを利用して情報の収集を行います。その中でも特にレンダリングのためにjavascriptを利用したサイトの場合、curlやwget、あるいはcheerioなどを利用した単純なアクセスでは網羅できません。そこで、Phantom.jsやpuppeteerと言ったブラウザを利用する仕組みを使ってスクレイピングを行う必要があります。

今回は作成中のサービスで、依存している外部サービスの情報の取得を行う必要があったので、puppeteerを利用し、さらにそろをAPIとしてサービス内部で利用できるようにGoogle AppEngine上で動作させたお話。あと、ついでにライブラリを利用する言語としてkotlinを使ったお話。

## どれを使うか

Reactでレンダリングされているサイトをスクレイピングしようと思ったので必然的にブラウザを利用したものである必要がありました。なんでも良いけど、そのまんまchromeを使っている[puppeteer](https://github.com/GoogleChrome/puppeteer)を選択。さらに、puppeteerには[$](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pageselector)と言ったcssセレクター用の関数が用意されていましたが、やっぱりjQueryライクな強力なセレクターがほしいと思ったので、[cheerio](https://github.com/cheeriojs/cheerio)も並行して使うことにしました。

また、このプロジェクトは自分の以前のエントリー[Full stack kotlin プロジェクトで開発をはじめて1週間くらいたった感想 - Qiita](https://qiita.com/numa08/items/ff760de9dda419f20371)と関連しているのですが、全てkotlinで実装を行っています。そのため、kotlin.jsを利用してこれらｒのライブラリへのアクセスを行います。

## どこで動かすのか

本当ならCloud Functionsあたりを使いたかったのですが実行環境にはchromiumのインストールが必要だったのでApp EngineのCustom Runtimeを使いました。自前でDockerfileを用意する必要があるのですが、puppeteerの[troubleshooting](https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md)にサンプルのDockerfileが用意されているので、流用して次のような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のコードから実行するには次のようにします。

```kotlin
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っぽいことができるようにします。

```kotlin
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から使いやすくするためのラッパーを作ります。

```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>
}
```

こうすることで次のようなコードを書くことができるようになります。

```kotlin
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のインターフェースを利用する必要があります。これについてはいい方法が思い浮かばなかったので「頑張って気をつける」スタイルになってしまっています。

```kotlin
// $
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で動かす感じの仕組みであればコスト計算も変わってくるので使えるかなと思い紹介しました。