Core Tech Blog

株式会社Coreのエンジニアチームが日々習得した技術やTipsを公開するブログです

Google Chrome ヘッドレスをPHPから操作してWebページのキャプチャ画像を取得する

はじめまして、株式会社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の結果を取得するためにid2のレスポンスを待って、戻り値を取得しています。

// 画像をファイルに出力
file_put_contents("test1.png", base64_decode($result["data"]));

Page.captureScreenshotは画像データをbase64でエンコードして返却するのでデコードしてからファイルに出力しています。

取得した画像がこちら。

f:id:coreinc:20180322173010p:plain
表示が完了していない

開いて即取得をしようとしたせいでちゃんとページを読み込みきれておらずに真っ白な画像になってしまっています。

http://127.0.0.1:9222にアクセスして内容を確認すると下記の様に表示が出来ています。

f:id:coreinc:20180322173023p:plain
DevToolの表示確認

表示が完了するまで待ってからスクリーンショットを取ってみます。

表示完了を受けるためには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"]));

今度は、ちゃんと画像が出力されました。

f:id:coreinc:20180322173032p:plain
ページ全体が取れていない

ただ、スクロールバーが表示されておりページ全体のキャプチャは出来ていない状態になっています。 ブラウザのウィンドウサイズをページ全体が入るサイズに変更してキャプチャを撮る必要があります。 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;
        }
    }
}

取れたスクリーンショットはこちら

f:id:coreinc:20180322173039p:plain
ページ全体の取得

ページ全体が取れました。

実は、今回のサンプルではページの読み込みを終了してすぐに取得しているので、フェードインしてくるようなテキストだったりiframe内のロードに時間がかかっている要素については表示出来ていません。

それらに全て対応しようとした場合にはキャプチャを取得する前にsleepで少し待ちを作ってみたり、iframeの読み込みに関してはPageの通知を有効にすると取得出来るPage.frameStartedLoadingPage.frameStoppedLoadingの組で読み込み完了を待つ等といった対応を行う必要があります。

今回の対応ではページ全体のスクリーンショットの取得ということでここまでの対応としたいと思います。

参考:
ヘッドレス Chrome ことはじめ | Web | Google Developers
headlessなChromeをPHPで操作する(1) - Qiita