はじめに

PHPアプリケーションからWebアプリケーションに対してアクセスすることは多々あります。もっともよくあるケースはREST APIなどのテキストのみを返すWebアプリケーションへのアクセスかと思います。しかしまれにGoogle ChromeやFirefoxを操作したときのようにJavaScriptの動作を伴ったアクセスを行いたいときもありますよね。

PHPにはV8jsという拡張モジュールもあるようですが、今回は PHP PhantomJS を利用してその要件を実現することにします。

PHP PhantomJSとは

PHP PhantomJSは別途インストールしたPhantomJSをPHPから使うためのインターフェイスを提供してくれるライブラリです。PhantomJSはいわゆる「ヘッドレスブラウザ」というものでGUIを持たないブラウザです。

当エントリでは基礎知識としてPhantomJSを簡単に触ったあとにPHP PhantomJSの使用方法を提示していきます。

インストール

composer.jsonを作成し以下のように記述します。

// composer.json
{
    "config": {
        "bin-dir": "bin"
    },
    "scripts": {
        "post-install-cmd": [
            "PhantomInstaller\\Installer::installPhantomJS"
        ],
        "post-update-cmd": [
            "PhantomInstaller\\Installer::installPhantomJS"
        ]
    }
}

その後composerコマンドを実行することでPHP PhantomJSがインストールされ、指定したbin-dirディレクトリ配下に最新のphantomjsもインストールされます。

$ composer require "jonnyw/php-phantomjs:4.*"
$ tree bin
bin
└── phantomjs

PhantomJSを使う

まずはPhantomJSの挙動を確認してみます。

Hello World

phantomjsの実行するJSスクリプトファイルhello.jsを作成します。

// hello.js
console.log('hello phantomjs');
phantom.exit();

そして以下のように実行するとhello phantomjsと表示されます。簡単ですね。

$ $APP/bin/phantomjs hello.js
hello phantomjs

JavaSriptが実行されるWebページの操作

次は以下のHTMLを取得してその内容を出力したいと思います。
ブラウザにおいてHTMLがDOMとしてロード完了したら#contentの内容としてhello phantomjsという文字列を注入するようなJavaScriptのコードが書かれています。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
    <div id="content"></div>
</body>
<script type="text/javascript">
  (function () {
    document.addEventListener("DOMContentLoaded", function() {
      document.querySelector("#content").innerHTML = 'hello phantomjs';
    });
  })();
</script>
</html>

PhantomJSで取得する前にPHPで取得してみます。PHPスクリプトは以下のようになるでしょうか。

<?php
// get_hello_phantomjs.php
$url = 'file:///path/to/hello_phantomjs.html';
echo file_get_contents($url);

実行することでHTMLが取得できますね。

$ php get_hello_phantomjs.php
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
    <div id="content"></div>
</body>
<script type="text/javascript">
  (function () {
    document.addEventListener("DOMContentLoaded", function() {
      document.querySelector("#content").innerHTML = 'hello phantomjs';
    });
  })();
</script>
</html>

では、PhantomJSで実行するJSスクリプトget_hello_phantomjs.jsを書いてみましょう。
初見の記述もあるかと思いますがやっていることはなんとなくわかると思います。

// get_hello_phantomjs.js

var page = require('webpage').create();
var url = 'file:///path/to/hello_phantomjs.html';

page.open(url, function(status) {
    var html = page.evaluate(function () {
        return document.documentElement.outerHTML;
    });

    console.log(html);

    phantom.exit();
});

実行結果は以下です。#contenthello phantomjsという文字列が挿入されている形で出力されました。JavaScriptが実行されているのがわかりますね。WebKitのアルゴリズムでレンダリングされているためHTML構造も元のファイルとは若干異なっています。

$ $APP/bin/phantomjs get_hello_phantomjs.js
<html><head>
<meta charset="utf-8">
</head>
<body>
    <div id="content">hello phantomjs</div>

<script type="text/javascript">
  (function () {
    document.addEventListener("DOMContentLoaded", function() {
      document.querySelector("#content").innerHTML = 'hello phantomjs';
    });
  })();
</script>

</body></html>

get_hello_phantomjs.jsにはpageオブジェクトやphantomオブジェクトなど見慣れないものが出てきました。これらPhantomJSの提供するAPIについてはAPI : PhantomJSに詳しく記述してあります。(詳しくないものもいくつかありますが :ghost:

PHP PhantomJSを使う

PhantomJSの挙動がなんとなくわかったところでPHP PhantomJSの使用に移りましょう。 PHP PhantomJSはデフォルトでWebページをPDFとして出力したりスクリーンキャプチャを取得したりなどいろいろな機能を持っています。
しかし、当エントリではとりあえずは先ほどの例と同じようにhello_phantomjs.htmlのコンテントを表示してみることにします。

<?php
// get_hello_phantomjs-with-php_phantomjs.php

use JonnyW\PhantomJs\Client;

$client = Client::getInstance();

$request  = $client->getMessageFactory()->createRequest();
$response = $client->getMessageFactory()->createResponse();

$url = 'file:///path/to/hello_phantomjs.html';
$request->setUrl($url);

$client->send($request, $response);
echo $response->getContent();

上記のように記述することで先ほど$APP/bin/phantomjs get_hello_phantomjs.jsとしたときと同様の結果を得られます。 #contenthello phantomjsという文字列が挿入されている形で出力されていますね。

$ php get_hello_phantomjs-with-php_phantomjs.php
<head>
<meta charset="utf-8">
</head>
<body>
    <div id="content">hello phantomjs</div>

<script type="text/javascript">
  (function () {
    document.addEventListener("DOMContentLoaded", function() {
      document.querySelector("#content").innerHTML = 'hello phantomjs';
    });
  })();
</script>

PHP PhantomJSの処理

PHP PhantomJSの主な処理は以下の3つになります。

  1. PhantomJSが実行するJSスクリプトファイルを作成
  2. 1.で作成したファイルをPhantomJSで実行
  3. 2.の実行結果を取得

よってphp get_hello_phantomjs-with-php_phantomjs.phpの際もJSスクリプトファイルは作成されています。
PhpStormなどのステップ実行が可能なIDEを使用している方は \JonnyW\PhantomJs\Procedure\Procedure::run にブレークポイントを設定してデバッグ実行してみてください。

procedure_php_-_lisket-serpscraper_-____projects_lisket_libs_lisket-serpscraper_

$executableという変数に、あるファイルへのパスが設定されていることが確認できます。
これをテキストエディタで開いてみます。ちょっと長いですが引用します。

/**
 * Set up page and script parameters
 */
var page       = require('webpage').create(),
    system     = require('system'),
    response   = {},
    debug      = [],
    logs       = [],
    procedure  = {};

/**
 * Global variables
 */


/**
 * Define width & height of capture
 */


/**
 * Define paper size.
 */


/**
 * Define viewport size.
 */

var viewportWidth  = 0,
    viewportHeight = 0;

if(viewportWidth && viewportHeight) {
    
    debug.push(new Date().toISOString().slice(0, -5) + ' [INFO] PhantomJS - Set viewport size ~ width: ' + viewportWidth + ' height: ' + viewportHeight);
    
    page.viewportSize = {
        width: viewportWidth,
        height: viewportHeight
    };
}




/**
 * Define custom headers.
 */

var headers = [];

page.customHeaders = headers ? headers : {};



/**
 * Page settings
 */

page.settings.resourceTimeout = 5000;



/**
 * On resource timeout
 */
page.onResourceTimeout = function (error) {
    
response        = error;
response.status = error.errorCode;


};

/**
 * On resource received
 */
page.onResourceReceived = function (resource) {
    
if(!response.status) {
    response = resource;
}


};

/**
 * Handle page errors
 */
page.onError = function (msg, trace) {
    
var error = {
    message: msg,
    trace: []
};

trace.forEach(function(t) {
    error.trace.push(' -> ' + (t.file || t.sourceURL) + ': ' + t.line + (t.function ? ' (in function ' + t.function + ')' : ''));
});

logs.push(error);


};

/**
 * Handle global errors
 */
phantom.onError = function(msg, trace) {
        
var stack = [];

trace.forEach(function(t) {
    stack.push(' -> ' + (t.file || t.sourceURL) + ': ' + t.line + (t.function ? ' (in function ' + t.function + ')' : ''));
});

response.status  = 500;
response.content = msg;
response.console = stack;

system.stdout.write(JSON.stringify(response, undefined, 4));
phantom.exit(1);


};

/**
 * Open page
 */
page.open ('file:///path/to/hello_phantomjs.html', 'GET', '', function (status) {
    
var delay = 0;

if(!delay) {
    return procedure.execute(status);
}

debug.push(new Date().toISOString().slice(0, -5) + ' [INFO] PhantomJS - Delaying page render for ' + delay + ' second(s)');

window.setTimeout(function () { 

    debug.push(new Date().toISOString().slice(0, -5) + ' [INFO] PhantomJS - Rendering page after delaying for ' + delay + ' second(s)');
    procedure.execute(status); 

}, (delay * 1000));


});

/**
 * Execute procedure
 */
procedure.execute = function (status) {
    if (status === 'success') {

    try {

        response.content = page.evaluate(function () {
            return document.getElementsByTagName('html')[0].innerHTML
        });

    } catch(e) {

        response.status  = 500;
        response.content = e.message;
    }
}

response.console = logs;

system.stderr.write(debug.join('\\n') + '\\n');
system.stdout.write(JSON.stringify(response, undefined, 4));

phantom.exit();

};

page.open ('file:///path/to/hello_phantomjs.html', 'GET', '', function (status) {とあるようにPHPで指定したURLがJSスクリプトファイルに埋め込まれているのがわかりますね。
またHandle page errorsExecute procedureといったコメントがあると思います。これはのちほどまた出てきますので覚えておいてください。
このようにPHP PhantomJSは内部的にJSファイルを作成して実行しています。

この例ではレンダリングされたWebページの内容すべてを取得するというだけの簡単な要件でしたが、もう少し複雑なことをしたいときはどうすればよいでしょうか?
PHP PhantomJSではカスタムJSスクリプトファイルを作成して対応することができます。

次項以降ではfile:///path/to/hello_phantomjs.htmlにおける#contentの内容のみを取得するという要件を、カスタムJSスクリプトファイルを作成することで実現したいと思います。

カスタムJSスクリプトファイルの作成

PHP PhantomJSはカスタムJSスクリプトファイルの作成方法を2つ提供しています。「部分的なスクリプトの埋め込み」と「カスタムテンプレートの作成」です。

部分的なスクリプトの埋め込み

実装は簡単ですがその分できることに制約があります。

実行されるJSスクリプトファイルはデフォルトテンプレートをもとに作成されます。
デフォルトテンプレートには[% autoescape false %]といったTwigの宣言が確認できますね。PHP PhantomJSはTwigを利用してJSスクリプトファイルを作成しているのです。

Handle page errorsExecute procedureといったコメントがここでも確認できますね。そして[[ engine.load('page_on_error') ]][[ engine.load( 'procedure_' ~ procedure_type ) ]]といった記述もあります。これらがプリセットされたブロックです。 このブロックに所望の処理を埋め込むことがでPHP PhantomJSの提供するデフォルトの挙動をカスタマイズすることができるのです。

たとえばpage_open.partialというブロックはページが開いたときに実行される処理、page_on_error.partialはページがエラーになったときに実行される処理、などです。こちらにすべてのブロックの説明が載っています。
今回はprocedure_default.partialブロックに処理を埋め込んでみます。

1. 埋め込み処理ファイルの設置

埋め込み処理ファイル設置用のディレクトリを作成してprocedure_default.partialという名前でファイルを生成して実行権限を付与します。この例のようにファイル名はブロック名と同じにする必要があります。

$ mkdir procedures; cd procedures
$ touch procedure_default.partial
$ chmod +x procedure_default.partial

2. 埋め込み処理ファイルの記述

responsedebugなどはデフォルトテンプレートの冒頭で作成されているオブジェクトです。標準出力に出力されたJSON文字列の内容が、のちに示すPHPのスクリプト内の$responseオブジェクトとして変換されます。

/** procedure_default.partial */

response.content = page.evaluate(function () {
    return document.querySelector("#content").innerHTML;
});

response.console = logs;

system.stderr.write(debug.join('\\n') + '\\n');
system.stdout.write(JSON.stringify(response, undefined, 4));

phantom.exit();

3. PHPから使う

PHPスクリプトget_hello_phantomjs-with-php_phantomjs_partial_script.phpを以下のように作成します。

(a)において、埋め込み処理ファイルをロードするディレクトリのパスを指定してプロシージャローダと呼ばれるものを作成します。
(b)において、(a)で作成したプロシージャローダをHTTPクライアントに追加します。

<?php
// get_hello_phantomjs-with-php_phantomjs_partial_script.php

use JonnyW\PhantomJs\Client;
use JonnyW\PhantomJs\DependencyInjection\ServiceContainer;


$location = __DIR__.'/procedures';

$serviceContainer = ServiceContainer::getInstance();
$procedureLoader =
    $serviceContainer->get('procedure_loader_factory')->createProcedureLoader($location); // (a)


$client = Client::getInstance();
$client->getProcedureLoader()->addLoader($procedureLoader); // (b)

$request  = $client->getMessageFactory()->createRequest();
$response = $client->getMessageFactory()->createResponse();

$url = 'file:///path/to/hello_phantomjs.html';
$request->setUrl($url);

$client->send($request, $response);

echo $response->getContent();

4. 実行

うまくいきました。

$ php get_hello_phantomjs-with-php_phantomjs_partial_script.php
hello phantomjs

IDEでステップ実行して、生成されるスクリプトファイルを確認してみます。

 :

/**
 * Open page
 */
page.open ('file:///path/to/hello_phantomjs.html', 'GET', '', function (status) {
    
var delay = 0;

if(!delay) {
    return procedure.execute(status);
}

 :

/**
 * Execute procedure
 */
procedure.execute = function (status) {
    response.content = page.evaluate(function () {
    return document.querySelector("#content").innerHTML;
});

response.console = logs;

system.stderr.write(debug.join('\\n') + '\\n');
system.stdout.write(JSON.stringify(response, undefined, 4));

phantom.exit();

さきほどの「デフォルト」のスクリプトファイルとほとんど違いはありません。URLも埋め込まれていますね。 異なるのはExecute procedureとコメントされているブロックにprocedure_default.partialファイルで定義した処理がそのまま埋め込まれているところです。
このように基本的にデフォルトのスクリプトを使いつつ、固有の処理を行いたい箇所に独自処理を埋め込めるのが「部分的なスクリプトの埋め込み」です。

カスタムテンプレートの作成

ここまでご覧になった方ならこちらの理解も難しくないと思います。
「部分的なスクリプトの埋め込み」で使用したデフォルトテンプレートにあたるスクリプトを自分で書いてしまおうというものです。これを以降はカスタムテンプレートと呼ぶことにします。

1. カスタムテンプレートの設置

今回は埋め込み処理ファイルと同じ場所に設置しました。ファイル名は任意で構いませんが必ず.procという拡張子にして実行権限を付与してください。

$ cd procedures
$ touch get_hello_phantomjs.proc
$ chmod +x get_hello_phantomjs.proc

2. カスタムテンプレートの記述

PhantomJSを単体で使ったときのスクリプトファイルget_hello_phantomjs.jsにいくらか手を加えたものをカスタムテンプレートget_hello_phantomjs.procとして記述します。

特筆すべきは'{{ input.getUrl() }}'です。PHPで指定したURLがここに埋め込まれることになります。
デフォルトで埋め込めるパラメータはこちらに記載してあります。また、今回は取り上げませんが独自のパラメータを渡すこともできます。詳しくはドキュメントをご覧ください。

/** get_hello_phantomjs.proc */

var page = require('webpage').create();
+ var system = require('system');
+ var response = {};
- var url = 'file:///path/to/hello_phantomjs.html';

- page.open(url, function(status) {
+ page.open('{{ input.getUrl() }}', function(status) {
    var html = page.evaluate(function () {
        return document.documentElement.outerHTML;
    });

-    console.log(html);
+    system.stdout.write(JSON.stringify(response, undefined, 4));

    phantom.exit();
});

3. PHPから使う

get_hello_phantomjs-with-php_phantomjs_partial_script.phpに手を加えたものをget_hello_phantomjs-with-php_phantomjs_custom_template.phpとして作成します。
以下のように先ほど作成したカスタムテンプレート名から拡張子を除いた文字列を記述します。

// get_hello_phantomjs-with-php_phantomjs_custom_template.php

$client = Client::getInstance();
+ $client->setProcedure('get_hello_phantomjs');
$client->getProcedureLoader()->addLoader($procedureLoader);

4. 実行

うまくいきました。

$ php get_hello_phantomjs-with-php_phantomjs_custom_template.php
hello phantomjs

おわりに

PHP PhantomJSおよびその前提となるPhantomJSの使用方法の概要を記しました。

実際の開発では、phantomjsコマンドを駆使して実行可能なJSスクリプトファイルをまず完成させたのち、それをPHP PhantomJSで使用可能なスクリプトファイルに加工するというのが現実的な手法だと思います。
またPHP PhantomJSで作成する埋め込み処理ファイルやスクリプトファイルにおいては//を使用したコメントアウトはsyntaxエラーとなりましたので注意が必要です。
このあたりを気をつけていただければとりあえずは使用に耐えうるものだと思います。参考にしていただければ幸いです。