headlessなChromeをPHPで操作する(1)

#headlessなChromeをPHPで操作する(1)
##1. スクリーンショットを撮る

この記事はこんな内容です。

* Headless Chromeでゴニョゴニョ自動で何かしたい
* けどnode.jsはチョット……
* Seleniumでも良いけど、他の方法でやってみたい

###意図

HeadlessなChromeの登場で、PhantomJSの開発終了も宣言されたとかされないとか。自動化界隈に吹く変化の風に乗るべく、Headless　Chromeを実際に操作してみよう…と思って色々サンプルなどを探してみると、node.jsでchrome-remote-interfaceを使った例か、Seleniumで使った例しかほとんど見当たらない…。

これでは、なかなか応用が効きづらいので、もう少し抽象的にHeadless Chromeを操作する方法を知りたい！という方のためにも、今回はあえてPHPを使った例をいくつか紹介していこうと思います。
もちろん、PHPに限らずWebSocketのクライアントさえあればどんな環境でも簡単に試せますので、適時自分の得意な環境に置き換えて見て下さい。

今回は、この手のサンプルの定番、スクリーンショットを取ってみます。コードまでの前置きが長いので、急いでいる方は、実際にPHPで操作してみる、まで飛んで下さい。

###環境など
* macOS sierra
* Google Chrome	60.0.3112.101 (Official Build) （64 ビット）

###Headless Chromeって何ぞ
Headless Chromeについては、下記記事が非常に詳しく、一度は皆さん見たことがあるんじゃないでしょうか。さよならXvfb！！

■ヘッドレス Chrome ことはじめ
https://developers.google.com/web/updates/2017/04/headless-chrome?hl=ja

### 早速使う

#### 起動

まずは、わかりやすく`alias chrome="/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"`という感じでエイリアスに追加しておきます。

その上で、下記コマンドでHeadless Chromeが起動します!

```
chrome --disable-gpu --remote-debugging-port=9222 --headless
```

DockにもChromeのアイコンが出てきますが、Headlessなのでもちろんウィンドウはありません。

#### リモートデバッグで準備体操

『いよいよコードで操作や！』と鼻息を荒らげる前に、ブラウザからリモートデバッグを使ってみると後々感じが掴みやすいので、体験してみます。!

HeadlessなChromeを立ち上げた状態で、別のブラウザから http://localhost:9222 へアクセスすると、タブ一覧が出てきます、

![一覧](https://qiita-image-store.s3.amazonaws.com/0/124712/bfcf5a7b-0f30-0b4e-fcdb-ef547411ad08.png)

URLを指定して開いていないので、about:blankなタブが1つだけあります。そこをクリックすると、普通のブラウザのように操作できます。例えば、インスペクタ側のアドレスバーにQiitaのURLを入力して、アクセスしてみると下図のように、普通にブラウザが操作できることがわかります。

![Qiitaを開く](https://qiita-image-store.s3.amazonaws.com/0/124712/16b3869f-8c1e-2a2f-0aa8-fbae4d6f0cfa.png)

もちろん、クリックや入力もできるので、ログインも普通にできますね。Headlessとはいえ、中身は普通のChromeと何ら変わりませんので、Ajaxっぽいところやゴリゴリのアニメーションなんかも動いています。

ここで意識しておきたいことは、**このブラウザ上での操作は全てWebSocketを使ったChrome DevTools Protocolで行われている**ということです！
※操作どころか、この見えている画すら、DevTools Protocolで送られてきています。

#### DevTools Protocolを覗く

ドキュメントを読むと、どうやらいろんなことが、DevTools Protocolで出来るらしい…ただ、サンプルがあまりなく、node.jsのchrome-remote-interfaceでラップされていて、他の言語の応用が効かないゾ…ということで、実際、どんなメッセージがやり取りされているかを確認してみましょう。

ChromeでWebSocketのやり取りを確認する方法は下記記事が参考になります。
http://qiita.com/k-yamada-github/items/dbf91b565de600e36217

といことで、Qiitaを開いている、先程のリモートデバッグでChromeのデベロッパツールを開き、WebSocketの送受信フレームを覗いてみましょう。

その上で、適当な場所をクリックしてみると…。

![WSフレーム確認](https://qiita-image-store.s3.amazonaws.com/0/124712/895fd380-0f0b-2e60-3015-a05f9956a2a0.png)

```json
{"id":217,"method":"Input.emulateTouchFromMouseEvent","params":{"type":"mouseReleased","x":427,"y":109,"modifiers":0,"timestamp":590.7019,"button":"left","clickCount":0}}
```
というようなJSONが送信されていることがわかります。そして、その戻り値として空のオブジェクトが返ってきていますね。

その他、inputボックスのようにフォーカスを合わすと、カーソルの点滅があるためか、定期的に`Page.screencastFrameAck`を呼んだり、`Page.screencastFrame`を受け取ったりしていることがわかります。

このあたりのメソッドや戻り値についての、詳細はDevTools Protocolのドキュメントは下記URLから。

■DevTools Protocol
https://chromedevtools.github.io/devtools-protocol/

これで、DevTools Protocolを使ってChromeを操作するイメージは出来ましたね。

ということで、プログラム的にChromeをGUIなしで操作して自動的に○○するには、次の手順を踏めば良さそうです。

1. HeadlessなChromeを起動する
2. WebSocketのエンドポイントに接続する
3. 以下、DevTools Protocol（json）を使って、操作と結果のやり取りの繰り返し　

ここまで理解できれば、あとはコードにするだけです！！

#### WSエンドポイントの確認方法

http://localhost:9222/json で確認できます。`webSocketDebuggerUrl`がソレです。

```json

[ {
   "description": "",
   "devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/a728a499-b18c-4118-a4dc-cbe5b87ec591",
   "id": "a728a499-b18c-4118-a4dc-cbe5b87ec591",
   "title": "about:blank",
   "type": "page",
   "url": "about:blank",
   "webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/a728a499-b18c-4118-a4dc-cbe5b87ec591"
} ]
```

### 実際にPHPで操作してみる

今回の目的は簡単な確認なので、以下、コードはかなり雑かもしれないです。

####WebSocketクライアントの準備

簡単のために、今回は[textalk/websocket-php](https://github.com/Textalk/websocket-php)を使います。

```
composer require "textalk/websocket:1.*"
```

と、composerでサクッとインストールします。PHP以外の方は適時適当なWebSocketクライアントを使うor実装して見て下さい。

####Chromeの起動と終了

別で起動したChromeに対して操作をしていっても問題ないですが、今回はPHPの中で起動します。
もう少しスマートなコードで書ければ良いのですが、まずは感じをつかむのが目的なのでご容赦ください。

```php:Chromeの起動
$cmd = '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --disable-gpu --headless --remote-debugging-port=9222';
exec($cmd . " > /dev/null &");
```

```php:Chromeの終了
exec('pkill -f "' . $cmd . '"');
```

####Endpointへの接続

curlでWebSocketのエンドポイントを取得し、接続します。do-whileについては、Chromeの起動から接続可能になるまで若干の時間が必要な為です。

```php:RemoteDebuggerに接続してエンドポイントのURIを取得する
//file_get_contentsだとHTTP/1.0で怒られる。PHPのcurlは面倒なのでコマンド実行でお茶を濁す。
do{
    $endpoints = json_decode(`curl -s http://localhost:9222/json`);
} while(empty($endpoints));

$endpoint = $endpoints[0]->webSocketDebuggerUrl;

$client = new Client($endpoint);
```

####WebSocketを使った操作

このまま、(1)指定したURLへ移動(2)キャプチャ、と動かすことになるのですが、単純にそういった処理を書くと、描画が終わる前にキャプチャを撮ってしまって真っ白な画像しか返ってきません。
そのため、描画が終わったことを確認してキャプチャを撮る必要があります。

DevTools Protocolはリクエスト-レスポンス的な動き以外に、適時イベントを送りつけてくれるナイスガイなので、そちらを利用します。

今回は、`Page.frameStoppedLoading`を使ってみます。ドキュメントを見ると

> Fired when frame has stopped loading.

とあるので、なんとなくこれで大丈夫そうですし、早速使ってみます。

Eventを受け取るには、各ドメインで`enable`メソッドを呼ぶ必要があります。

```php:Pageドメインのイベント受信を有効化
$client->send(json_encode([
    'id' => 1,
    "method" => 'Page.enable',
]));
```

続いて、ページの移動を行います。今回はQiitaのトップページのキャプチャを撮ってみます。

```php:Qiitaへ移動
$client->send(json_encode([
    'id' => 2,
    "method" => 'Page.navigate',
    "params" => ['url' => 'https://qiita.com']
]));
```

続いて、受信メッセージごとに処理を書いていきます。

`Page.navigate`の戻り値の`frameId`が、`Page.frameStoppedLoading`の対象になるので、まずはその戻り値を取得し、その後、`Page.frameStoppedLoading`を受け取るまで待機し。無事、受け取ったタイミングで、キャプチャを撮ってファイルに保存。というのが一連の流れになります。

なお、キャプチャ画像は、戻ってくる値がBase64でエンコードされた文字列なので、デコードして適当なファイルに保存します。

```php:キャプチャ完了まで
try {
    $frameId = null;
    while ($data = json_decode($client->receive())) {
        //Page.navigateに対応。対象frameIdが返ってくる
        if (@$data->id == 2) {
            $frameId = $data->result->frameId;
        }
        //最初のframeIdのLoadingが停止するまで待機
        if (@$data->method == 'Page.frameStoppedLoading' && @$data->params->frameId == $frameId) {
            //id3でキャプチャを呼ぶ
            $client->send(json_encode([
                'id' => 3,
                "method" => 'Page.captureScreenshot',
            ]));
        }
        //キャプチャ結果をキャッチ
        if (@$data->id == 3) {
            file_put_contents("capture.png", base64_decode($data->result->data));
            break;
        }
    }
} catch (\WebSocket\ConnectionException $e) {
}
```

結果、次のような画像が保存されます。

![結果](https://qiita-image-store.s3.amazonaws.com/0/124712/2707580d-eb47-b704-e2d0-1c76ab20fee7.png)

特に注目したいのが、FacebookやTwitterなど、ページ読み込み後にjsで非同期に動き、描画されるようなwidget類も正しく表示された上でスクリーンショットが撮れていることです。


#### コード全体

```php
<?php
require('vendor/autoload.php');
use WebSocket\Client;

$cmd = '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --disable-gpu --headless --remote-debugging-port=9222';
exec($cmd . " > /dev/null &");

//file_get_contentsだとHTTP/1.0で怒られる。PHPのcurlは面倒なのでコマンド実行でお茶を濁す。
do {
    $endpoints = json_decode(`curl -s http://localhost:9222/json`);
} while (empty($endpoints));

$endpoint = $endpoints[0]->webSocketDebuggerUrl;

$client = new Client($endpoint);
$client->send(json_encode([
    'id' => 1,
    "method" => 'Page.enable',
]));
$client->send(json_encode([
    'id' => 2,
    "method" => 'Page.navigate',
    "params" => ['url' => 'https://qiita.com']
]));


try {
    $frameId = null;
    while ($data = json_decode($client->receive())) {
        //Page.navigateに対応。対象frameIdが返ってくる
        if (@$data->id == 2) {
            $frameId = $data->result->frameId;
        }
        //最初のframeIdのLoadingが停止するまで待機
        if (@$data->method == 'Page.frameStoppedLoading' && @$data->params->frameId == $frameId) {
            //id3でキャプチャを呼ぶ
            $client->send(json_encode([
                'id' => 3,
                "method" => 'Page.captureScreenshot',
            ]));
        }
        //キャプチャ結果をキャッチ
        if (@$data->id == 3) {
            file_put_contents("capture.png", base64_decode($data->result->data));
            break;
        }
    }
} catch (\WebSocket\ConnectionException $e) {
}


exec('pkill -f "' . $cmd . '"');
```

###　まとめ

* Headless Chromeは　WebSocketでおしゃべりできる言語・環境がであれば何でも操作できる。
* 操作はDevTools Protocolを使う。その実態は、単純なJSONのやりとり。
* Eventを受け取って処理すると、sleep(3)のような不確かなコードを書かなくて済む。


今後は、自動化あるあるの、フォームの入力についても試していければと思います。

以上。

（追記 2019/12/2）続きを書きました
[headlessなChromeをPHPで操作する(2)](https://qiita.com/sarumonera/items/51688f3f4498d5c739d1)