はじめまして、株式会社Core開発部の加藤です。
Google ChromeのHeadlessモードについて利用する機会がありましたので、macからHeadlessモードを利用してPHPからWebページのスクリーンショットを取る方法について実際に確認していった手順と合わせてまとめてみました。
前提条件
- Google Chromeがインストールされている事
- 必要なライブラリはcomposerで取得可能な事
なお、サンプルコードにつきましては最低限の処理しか書いておらず、エラー時の処理等は全く考慮しておりません。
流れを見るためと処理を簡単にするためにエラーチェックを除いていたり、お行儀の悪い書き方をしている部分も多々ありますので、あくまでPHPからのChromeの利用の仕方について概要を掴むためのサンプルと考えて頂ければと思います。
また、掲載のサンプルコードをご利用頂いた際に発生した問題につきましては当方では責任は負いかねます。
PHPを書く前に
headlessモードとのやり取りをするための準備について説明します。
chromeの起動
Google Chromeをheadlessモードで起動します。macの場合にはChromeのパスは下記となっています。
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome
Google Chromeをheadlessモード用のオプションを付けて起動をします。
$ /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --disable-gpu --headless --remote-debugging-port=9222 -crash-dumps-dir=/tmp/chrome-headless
ちょっと長いですが。各オプションは次のような意味になります。
--disable-gpu
暫定的に必要なフラグ、そのうち不要になる予定との事
--headless
Chromeをヘッドレスモードで実行する
--remote-debugging-port
DevToolとのWebSocket通信に利用するポート番号
-crash-dumps-dir
クラッシュ時のレポートを吐き出すディレクトリの指定。macのデフォルトだと/var/folders/qf/
下に作ろうとしてOperation not permitted
が発生するケースがあるため、権限のあるディレクトリに吐き出すようにしています。
devtoolとのやり取り
前述のコマンドでchromeをheadlessモードで起動したら http://127.0.0.1:9222/json にアクセスします。(portはremote-debugging-port
で指定したポートになります。)
すると
[ { "description": "", "devtoolsFrontendUrl": "/devtools/inspector.html?ws=127.0.0.1:9222/devtools/page/CF16FDDF33E1B9CBC0A2377C28B0C3A9", "id": "CF16FDDF33E1B9CBC0A2377C28B0C3A9", "title": "about:blank", "type": "page", "url": "about:blank", "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/CF16FDDF33E1B9CBC0A2377C28B0C3A9" } ]
のようなjsonが取得できます。
このwebSocketDebuggerUrl
という値がDevtoolとWebSocketでやり取りをするためのエンドポイントになります。
前述のオプションを付けて起動したChromeとの間でWebSocketを使ってDevtoolを操作し必要な情報を取得していくのが基本的な流れとなります。
WebSocketでの操作
textalk/websocketの利用
PHPでWebSocketを使うために https://github.com/Textalk/websocket-php を使わせて頂きました。
composerで追加します。
"require": { "textalk/websocket": "^1.2" }
WebSocketを使ってDevtoolとの通信
WebSocket経由でDevToolのAPIを叩いていきます。DevTool APIの仕様はこちら。
Chrome DevTools Protocol Viewer
Webpageを開くためにはPage.navigateAPIを呼ぶことになります。
スクリーンショットを取るためにはPage.captureScreenshotを利用する形になります。
試しにURLを開いてスクリーンショットを取る処理を作成してみます。
PHPのみで動かす場合にはChromeの立ち上げ制御が必要なのですが、デバッグのためにchromeの状態を見ながら処理をしていきたいので前述のコマンドで立ち上げているChromeに接続して処理をするようにしています。
Chromeをheadlessモードで立ち上げた状態で http://127.0.0.1:9222 にアクセスすると、HeadlessのChromeの状態をブラウザで確認ができます。
上記をブラウザで開いた状態で下記の処理を行うと実際にどういう表示がされているのか等をブラウザで見ながら確認ができます。
<?php require('vendor/autoload.php'); // Devtool endpointの取得 $curlcmd = "curl -s http://127.0.0.1:9222/json"; do { $json = json_decode(shell_exec($curlcmd)); } while (empty($json)); $endpoint = $json[0]->webSocketDebuggerUrl; // WebSocketクラス初期化 $client = new WebSocket\Client($endpoint); // main処理 // urlを開く $client->send(json_encode([ 'id' => 1, 'method' => 'Page.navigate', 'params' => ['url' => 'http://www.core-j.co.jp/'] ])); // captureを取る $client->send(json_encode([ 'id' => 2, 'method' => 'Page.captureScreenshot', 'params' => ['format' => 'png'] ])); $result = null; while($data = json_decode($client->receive(), true)){ if($data['id'] == 2){ $result = $data['result']; break; } } // 画像をファイルに出力 file_put_contents("test1.png", base64_decode($result["data"]));
解説
// Devtool endpointの取得 $curlcmd = "curl -s http://127.0.0.1:9222/json"; do { $json = json_decode(shell_exec($curlcmd)); } while (empty($json)); $endpoint = $json[0]->webSocketDebuggerUrl;
エンドポイントを取得しています。Chromeが立ち上がるまで少し時間がかかるので取れるまで繰り返しています。
// WebSocketクラス初期化 $client = new WebSocket\Client($endpoint);
取得したエンドポイントを指定してWebSocketクラスを作成します。
// urlを開く $client->send(json_encode([ 'id' => 1, 'method' => 'Page.navigate', 'params' => ['url' => 'http://www.core-j.co.jp/'] ]));
Page.navigateAPIをコールして指定したURLを開きます。idはintegerで指定する形になります。リクエストに対するレスポンスを識別するための値なので一意の値を設定して下さい。
// captureを取る $client->send(json_encode([ 'id' => 2, 'method' => 'Page.captureScreenshot', 'params' => ['format' => 'png'] ]));
Page.captureScreenshotAPIをコールしてページのキャプチャを取得します。
$result = null; while($data = json_decode($client->receive(), true)){ if($data['id'] == 2){ $result = $data['result']; break; } }
呼び出したAPIの結果を$client->receive
で取得していきます。Devtoolから受け取ったレスポンスはキューに溜まっている状態になっているのでwhileでcapture取得時に指定したidのレスポンスを取得します。 こちらからコールしたDevToolAPIからのレスポンスは{ "id":<指定したid>, "result":{<API毎のreturn object>} }
という形で返ってくるのでPage.captureScreenshot
の結果を取得するためにid
が2
のレスポンスを待って、戻り値を取得しています。
// 画像をファイルに出力 file_put_contents("test1.png", base64_decode($result["data"]));
Page.captureScreenshot
は画像データをbase64でエンコードして返却するのでデコードしてからファイルに出力しています。
取得した画像がこちら。
開いて即取得をしようとしたせいでちゃんとページを読み込みきれておらずに真っ白な画像になってしまっています。
http://127.0.0.1:9222
にアクセスして内容を確認すると下記の様に表示が出来ています。
表示が完了するまで待ってからスクリーンショットを取ってみます。
表示完了を受けるためにはDevToolからのPage通知を有効にします。また、Pageの通知はWebSocketを通じて通知されるのですがその際のデータはidを含んでいないためデータの確認時にはidがあるかどうかもチェックしてあげる様にしてあげないといけません。修正したソースは下記になります。
// main処理 // Page通知を有効にする $client->send(json_encode([ 'id'=>1, 'method'=>'Page.enable' ])); // urlを開く $client->send(json_encode([ 'id' => 2, 'method' => 'Page.navigate', 'params' => ['url' => 'http://www.core-j.co.jp/'] ])); $result = null; $frame_id = null; while($data = json_decode($client->receive(), true)){ if(!array_key_exists('id', $data)){ if($data['method'] == 'Page.frameStoppedLoading' && $data['params']['frameId'] === $frame_id){ // frameの読み込みが終わったのでキャプチャを取る $client->send(json_encode([ 'id' => 3, 'method' => 'Page.captureScreenshot', 'params' => ['format' => 'png'] ])); } }else{ if($data['id'] == 2){ $frame_id = $data['result']['frameId']; } if($data['id'] == 3){ $result = $data['result']; break; } } } // 画像をファイルに出力 file_put_contents("test2.png", base64_decode($result["data"]));
今度は、ちゃんと画像が出力されました。
ただ、スクロールバーが表示されておりページ全体のキャプチャは出来ていない状態になっています。 ブラウザのウィンドウサイズをページ全体が入るサイズに変更してキャプチャを撮る必要があります。 Page.getLayoutMetrics
を呼んでページ全体のサイズを取得してEmulation.setDeviceMetricsOverride
で表示領域をページ全体が表示出来るサイズに変更をします。
下記が修正したソースになります。
// main処理 // Network通知を有効にする $client->send(json_encode([ 'id'=>1, 'method'=>'Page.enable' ])); // urlを開く $client->send(json_encode([ 'id' => 2, 'method' => 'Page.navigate', 'params' => ['url' => 'http://www.core-j.co.jp/'] ])); $result = null; $frame_id = null; while($data = json_decode($client->receive(), true)){ if(!array_key_exists('id', $data)){ if($data['method'] == 'Page.frameStoppedLoading' && $data['params']['frameId'] === $frame_id){ break; } }else{ if($data['id'] == 2){ $frame_id = $data['result']['frameId']; } } } // 表示サイズの取得 $client->send(json_encode([ 'id' => 3, 'method' => 'Page.getLayoutMetrics' ])); $content_size = null; while($data = json_decode($client->receive(), true)){ if(array_key_exists('id', $data)){ if($data['id'] == 3){ $content_size = $data['result']['contentSize']; $client->send(json_encode([ 'id' => 4, 'method' => 'Emulation.setDeviceMetricsOverride', 'params' => [ 'width'=>$content_size['width'], 'height'=>$content_size['height'], 'deviceScaleFactor'=>0, 'mobile'=>false ] ])); } if($data['id'] == 4){ // リサイズ終わったのでキャプチャを取る $client->send(json_encode([ 'id' => 5, 'method' => 'Page.captureScreenshot', 'params' => ['format' => 'png'] ])); } if($data['id'] == 5){ $result = $data['result']; break; } } }
取れたスクリーンショットはこちら
ページ全体が取れました。
実は、今回のサンプルではページの読み込みを終了してすぐに取得しているので、フェードインしてくるようなテキストだったりiframe内のロードに時間がかかっている要素については表示出来ていません。
それらに全て対応しようとした場合にはキャプチャを取得する前にsleepで少し待ちを作ってみたり、iframeの読み込みに関してはPageの通知を有効にすると取得出来るPage.frameStartedLoading
とPage.frameStoppedLoading
の組で読み込み完了を待つ等といった対応を行う必要があります。
今回の対応ではページ全体のスクリーンショットの取得ということでここまでの対応としたいと思います。
参考:
ヘッドレス Chrome ことはじめ | Web | Google Developers
headlessなChromeをPHPで操作する(1) - Qiita