ヘッドレスブラウザ(Chrome)を使ってSPAをスクレイピングする

## 一般的なスクレイピング手法とその問題点

スクレイピングというと、HTTPクライアントライブラリを用いてHTML取得し、HTML/XMLパーサーで解析するというのが一般的だと思います。

この手法の場合、以下の場合にうまく処理できません。

 - ターゲットのページがJavaScriptにより動的にDOMを操作する場合
 - HTML/XMLパーサーが取得したHTMLを正しく解釈できない場合(正しくないHTMLでもブラウザはなんとか処理するが、パーサーライブラリは正確なHTMLでないと処理できないことがある)

特に問題になるのは前者でしょう。最近のWebサイトではJavaScriptでDOMを操作することは珍しくなくなってきています。SPAであればなおさら難しく、もはやこういった手法によるスクレイピングは不可能でしょう。

## ヘッドレスブラウザによるスクレイピング

動的なDOMやパーサーがうまく解釈できないといった問題は、実際のブラウザを利用してスクレイピングすることで解決します。つまり、実際のブラウザがレンダリングした結果を、実際のブラウザのAPIを使って解析すれば良いのです。実際のブラウザでスクレイピングするためには、プログラムからブラウザを制御するためのAPIが必要があります。これを可能とする技術として、PhantomJS、Chrome DevTools Protocol(ヘッドレスChrome)、Selenium Web Driver、Electron APIなどがあります。それぞれ一長一短あると思いますが、ここではChrome DevTools Protocolを使いChromeを操作してスクレイピングする方法について説明したいと思います。

## Chrome DevTools Protocolについて

Chrome DevTools ProtocolとはChromeに備わっている開発者ツールの機能を実現するために作られているAPIです。Chromeの起動時に--debugging-portフラグを付けて起動することで、外部からWebSocket経由でその機能を呼び出せます。[プロトコルは公開されており](https://chromedevtools.github.io/devtools-protocol/tot/)、このプロトコルを扱うための[ライブラリ](https://github.com/cyrus-and/chrome-remote-interface)も存在します。このプロトコルを使うことで、プログラムからChromeをほぼ自由に操作することができます。

Chrome DevTools Protocolのドキュメント
https://chromedevtools.github.io/devtools-protocol/tot/

Chrome DevTools Protocolを操作するライブラリ(非公式)
https://github.com/cyrus-and/chrome-remote-interface

## Chromyについて

ChromyはNodeJSからChromeブラウザを操作するためのライブラリです。メソッドチェーンを使ったAPIを持っており、手軽にブラウザの操作を記述することができます。chrome-remote-interfaceをラップしてNightmareJSなどの既存のヘッドレスブラウザの操作ライブラリと同じようなインタフェースを提供するものです。

https://github.com/OnetapInc/chromy

### インストール

```bash
npm install --save chromy
```

### 基本的な処理の例

```js
const Chromy = require('chromy')
const chromy = new Chromy()
chromy.chain()
      // 指定のページを表示しロードが完了するのを待つ
      .goto('http://example.com')
      // ブラウザ内でJavaScriptを評価する
      .evaluate(_ => {
        return document.querySelectorAll('*').length
      })
      // 前のアクション(ブラウザ内での関数の評価)の結果を受け取り処理する
      .result(r => console.log(r))
      // アクションの終了
      .end()
      // エラー表示
      .catch(e => console.log(e))
      // ブラウザを閉じる
      .then(_ => chromy.close())
```

この例では、ブラウザ内でquerySelectorAll()を実行してノード数を取得しています。
指定したJavaScriptコードが実際にブラウザ内で動いてその結果をNodeJS側に戻せます。
当然ですが、evauate()に渡した関数内では、ブラウザ内で動作するあらゆるJavaScriptが実行可能です。

### SPAのスクレイピング

では、本題のSPAのスクレイピングをしてみます。GoogleがProgressive Webのサンプルとして公開しているストア風のサイトをスクレイピングしてみましょう。

https://shop.polymer-project.org/

このページはPolymerというフレームワークを使ったSPAで、大量のコンポーネントをダウンロードしてひとつのページを構成するので、通常の手法やおそらくPhantomJSでもスクレイピングは難しいでしょう。このため、通常はスクレイピングは諦めてAPIの解析をした方が早いという判断になるようなものです。

下記がこのサイトの男性服の一覧からスクレイピングしてデータを取得するサンプルです。

```js
const Chromy = require('chromy')

let chromy = new Chromy()
chromy.chain()
      .blockUrls(['*.ttf', '*.gif', '*.png', '*.jpg', '*.jpeg', '*.webp'])
      .goto('https://shop.polymer-project.org/list/mens_outerwear')
      .console(m => console.log(m))
      .wait(_ => {
        let app = document.body.querySelector('shop-app')
        if (app === null ||  app.shadowRoot === null) return false
        let list = app.shadowRoot.querySelector('shop-list')
        if (list === null || list.shadowRoot === null) return false
        let item = list.shadowRoot.querySelector('shop-list-item')
        if (item === null || item.shadowRoot === null) return false
        return item.shadowRoot.querySelector('.title').innerText.length > 0
      })
      .evaluate(_ => {
        let results = []
        let app = document.body.querySelector('shop-app')
        let list = app.shadowRoot.querySelector('shop-list')
        let items = list.shadowRoot.querySelectorAll('shop-list-item')
        items.forEach(item => {
          let root = item.shadowRoot
          let title = root.querySelector('.title').innerText
          let price = root.querySelector('.price').innerText
          results.push({title, price})
        })
        return results
      })
      .result(items => {
        items.forEach(item => {
          console.log(item)
        })
      })
      .end()
      .catch(e => console.log(e))
      .then(_ => chromy.close())
```

だいたい見ただけで何をしているか分かると思いますが、分かりにくそうなところを個別に解説します。

```js
.blockUrls(['*.ttf', '*.gif', '*.png', '*.jpg', '*.jpeg', '*.webp'])
```

今回のスクレイピング対象はテキストなので、画像のダウンロードはブロックすることでロード時間やトラフィックを節約しています。

```js
      .wait(_ => {
        let app = document.body.querySelector('shop-app')
        if (app === null ||  app.shadowRoot === null) return false
        let list = app.shadowRoot.querySelector('shop-list')
        if (list === null || list.shadowRoot === null) return false
        let item = list.shadowRoot.querySelector('shop-list-item')
        if (item === null || item.shadowRoot === null) return false
        return item.shadowRoot.querySelector('.title').innerText.length > 0
      })
```

この部分はドキュメント内にtitleというクラスを持ち、テキストを含むDOMノードが現れるまで、処理を止める処理です。SPAでは初期レンダリングではコンテンツがなく、JavaScriptによるDOM操作によって後から具体的なレンダリング内容を構築します。また、データはその後にAPIを用いて取得してきます。この処理はそれらのすべてが完了して、データが表示されるまで待つためのものです。

`shadowRoot` というプロパティはWeb開発に慣れた人でも知らないかもしれません。
このサイトで使われるPolymerというフレームワークはShadow DOMと呼ばれる、これまでのWebからすると少し特殊な機能を大量に使っています。これはコンポーネント化を行うための仕組みなのですが、コンポーネント内にある要素をquerySelectorやquerySelectorAllなどで取得するために必要な記述です。

### 最後に

スクレイピングが難しそうなSPAも、ヘッドレスブラウザを使えば簡単にスクレイピングできます。Chromeのヘッドレスブラウザであれば、PhantomJSと違い新しいWeb仕様だから対応できないといったこともあまりないでしょう。

ちなみにChromyは自分が開発中のOSSで徐々に人気がでてきています。少しでも気になったらぜひスターをお願いします！

https://github.com/OnetapInc/chromy