時代遅れひとりFizzBuzz祭り*1、全3回*2のJavaScript de 処理系ネタもこれで最後。今回はヘッドレスブラウザのPhantomJSだ。こいつはクライアントサイドJavaScriptなのかそうでないのか微妙だ……どちらかというと「JavaScriptのコードでブラウザを操作する」なので、一応クライアントサイドJavaScriptとは別物。でも意外と関係が深い。
PhantomJSは前回のNode.jsとは立ち位置が異なるツールであるものの、その背後を憶測してみると共通項がある。Node.jsの背後には「サーバサイドの処理をJavaScriptで記述したい」という動機があるだろう。PhantomJSの目的は「ブラウザの操作をスクリプトで記述して自動化する」という所だと思うが、その記述言語としてJavaScriptを採用した。双方とも元々の役割は異なるものの、何故かJavaScriptを採用した。JavaScriptはそこそこ強力で面白い反面、色々と欠点も多い言語なのだけど、クライアントサイド以外の分野で使おうと考える人が結構出てきている(ように思う)。興味深い。
現時点でのPhantomJSの最新版は1.5.0だが、今回はWindows版の1.4.1を使う。大した理由は無くて、単にバージョンアップを怠っているだけだ。
普通のFizzBuzz
今回もまずは普通にFizzBuzz。実は前回のNode.js、前々回のRhino共に、最初のFizzBuzzのコードの中身はほぼ同じだったりする。
/*jslint devel: true, plusplus: true, maxerr: 50, indent: 2 */ /*global phantom: false */ (function () { 'use strict'; var iota = function (count, start, step) { var retval = [], i; if (start === undefined) { start = 0; } if (step === undefined) { step = 1; } for (i = 0; i < count; ++i) { retval[i] = start + step * i; } return retval; }, fizzbuzz = (function () { var ref = [null, 'Fizz', 'Buzz', 'FizzBuzz']; return function (n) { var fizz = n % 3 === 0 ? 1 : 0, buzz = n % 5 === 0 ? 2 : 0; ref[0] = n; return ref[fizz + buzz]; }; }()); console.log(iota(100, 1).map(fizzbuzz).join('\n')); phantom.exit(); }());
最後にphantom.exit()を読んでいる点を除いて、Node.jsでのFizzBuzzのコードと全く同じだ。phantom.exit()を呼ばないとプログラムは終了しない。
モジュール機能
PhantomJSにはCommonJSをモデルとしたモジュール機能が組み込まれているものの、現時点では2〜3の組み込みモジュールにしか適用できない。なのでNode.jsのようなスタイルでの独自モジュールの管理は不可能だ。
その代わりinjectJs()という組み込みメソッドを使用して外部のスクリプトを読み込むことができる。例えば以下のような内容のファイルfizzbuzz.jsがあったとする。
/*jslint plusplus: true, maxerr: 50, indent: 2 */ var fizzbuzzAnswers = (function () { 'use strict'; var iota = function (count, start, step) { var retval = [], i; if (start === undefined) { start = 0; } if (step === undefined) { step = 1; } for (i = 0; i < count; ++i) { retval[i] = start + step * i; } return retval; }, fizzbuzz = (function () { var ref = [null, 'Fizz', 'Buzz', 'FizzBuzz']; return function (n) { var fizz = n % 3 === 0 ? 1 : 0, buzz = n % 5 === 0 ? 2 : 0; ref[0] = n; return ref[fizz + buzz]; }; }()); return function (count) { if (typeof count !== 'number') { throw new TypeError('answers(): count must be number'); } if (count <= 0) { throw new RangeError('answers(): count must be > 0'); } return iota(count, 1).map(fizzbuzz); }; }());
別のファイルにてこんな風に使うことができる。
/*jslint devel: true, maxerr: 50, indent: 2 */ /*global phantom: false, fizzbuzzAnswers: false */ (function () { 'use strict'; phantom.injectJs('./fizzbuzz.js'); fizzbuzzAnswers(100).forEach(function (v) { console.log(v); }); phantom.exit(); }());
詳細は調べていないので分からないのだが、Rhinoのload()に近い印象を受ける。
injectJs()はphantomオブジェクトだけでなくWebPageオブジェクトにも存在する。両者の違いについては後述する。
fs (Filesystem Module)
PhantomJSのAPIリストを見ているとファイル/ディレクトリ操作用の機能が結構充実していることに気が付く。Filesystemモジュールなのだが、CommonJSのそれと何やら関係があるようだ(しかし英語が分からなくて詳細は不明)。
試しにseqが吐き出すような改行区切りの数列が記述されているテキストファイルfizzbuzz.txtを読み込んでFizzBuzzするコードを書くとこんな感じ。
/*jslint devel: true, plusplus: true, maxerr: 50, indent: 2 */ /*global phantom: false, require: false */ (function () { 'use strict'; var iota = function (count, start, step) { var retval = [], i; if (start === undefined) { start = 0; } if (step === undefined) { step = 1; } for (i = 0; i < count; ++i) { retval[i] = start + step * i; } return retval; }, fizzbuzz = (function () { var ref = [null, 'Fizz', 'Buzz', 'FizzBuzz']; return function (n) { var fizz = n % 3 === 0 ? 1 : 0, buzz = n % 5 === 0 ? 2 : 0; ref[0] = n; return ref[fizz + buzz]; }; }()), input, line, count; try { input = require('fs').open('./fizzbuzz.txt', 'r'); } catch (e) { console.error(e); phantom.exit(1); } while (!input.atEnd()) { line = input.readLine(); count = line.trim(); if (count === '') { // EMPTY } else if (/^[1-9][0-9]{0,8}$/.test(count)) { console.log(fizzbuzz(count)); } else { console.error("Invalid input: '%s'", line); } } input.close(); phantom.exit(); }());
fsにはファイルの存在を確認するexists()やファイルか否かチェックするisFile()があるものの、チェックしてから実際にopen()するまでの微妙なタイミングの隙間を突かれる可能性があるので、潔くいきなりopen()している。失敗時の例外ではエラーメッセージの文字列が上がってくるようだ。
ファイルの中身はreadLine()を使って行単位で取得している。公式のAPIリストにはストリームの終了を判定できそうなメソッドやプロパティが無くて悩んだが、atEnd()というメソッドで判定できるようだ。
ちなみにLinuxあたりならファイルとして/dev/stdinや/dev/stdoutを選択することで標準入出力を使用できるが、Windowsでは未対応なようだ。未対応というか、単にWindowsには無いOSの機能を使っているだけだよなあ。
Webページの取得とHTTPサーバ
PhantomJSはヘッドレスのブラウザなので、その本領はやはりWebページを取得したりページ内で色々と操作したりする所にある。
前回、Node.jsでFizzBuzz CGIもどき(GETメソッド用)を書いた。このプログラムに対応するクライアント側の処理をPhantomJSで書くとこんな感じ。
/*jslint devel: true, regexp: true, maxerr: 50, indent: 2 */ /*global phantom: false, require: false */ (function () { 'use strict'; var page = require('webpage').create(); page.open('http://localhost:5432/fizzbuzz?count=100', function (status) { if (status === 'success') { console.log(page.content.replace(/<[^>]+>/g, '')); } else { console.log('failed.'); } phantom.exit(); }); }());
WebPageオブジェクトで既存のページを開いている。Node.jsでのHTTPクライアントの実装とは異なり、随分とWebブラウザ的な記述だ。「file://」で始まるURLを使用することでローカル環境のファイルを開くことも可能なようだ。
page.contentに対してやっつけでタグを取り除いている。これはサーバ側がtext/plainなレスポンスを返しているにも関わらずHTMLタグが前後に付加された値が返される為だ。ドキュメントを見る限りpage.contentはそういう仕様のようだ。
PhantomJSの使用例でよく挙げられるのは、page.render()でページの描画結果をファイル出力するものだ。
/*jslint devel: true, maxerr: 50, indent: 2 */ /*global phantom: false, require: false */ (function () { 'use strict'; var page = require('webpage').create(); page.open('http://localhost:5432/fizzbuzz?count=100', function (status) { if (status === 'success') { page.render('./fizzbuzz.pdf'); } else { console.log('failed.'); } phantom.exit(); }); }());
ここではPDFとして出力している。手元の環境ではPNGでもOKだったが、JPEGでは真っ黒な画像になってしまった。
PhantomJS 1.4以降には実験的ながらもWebServerモジュールが組み込まれているので、サーバ側もPhantomJSで書くことができる。
/*jslint devel: true, plusplus: true, maxerr: 50, indent: 2 */ /*global require: false */ (function () { 'use strict'; var server = require('webserver').create(), iota = function (count, start, step) { var retval = [], i; if (start === undefined) { start = 0; } if (step === undefined) { step = 1; } for (i = 0; i < count; ++i) { retval[i] = start + step * i; } return retval; }, fizzbuzz = (function () { var ref = [null, 'Fizz', 'Buzz', 'FizzBuzz']; return function (n) { var fizz = n % 3 === 0 ? 1 : 0, buzz = n % 5 === 0 ? 2 : 0; ref[0] = n; return ref[fizz + buzz]; }; }()); server.listen(5432, function (request, response) { var query, count; if (request.method !== 'GET') { response.statusCode = 405; response.write(''); return; } query = /^\/fizzbuzz\?count=([1-9][0-9]{0,8})$/.exec(request.url); if (query === null) { response.statusCode = 400; response.write(''); return; } count = Number(query[1]); console.assert(isFinite(count)); response.statusCode = 200; response.headers['Content-Type'] = 'text/plain'; response.write(iota(count, 1).map(fizzbuzz).join('\n')); }); }());
ちなみにWebServerモジュールは「PhantomJSのスクリプトと外部とのやり取り」の為に用意されているもので、Node.jsなどのように本格的にWebサーバとして使うような用途向けではないらしい。
Webページの動的生成
WebPageオブジェクトにはevaluate()というメソッドがあり、個々のWebページのコンテクストにて指定した関数を実行することができる。関数はサンドボックスの中で実行され、実行中にWebページの外のオブジェクトにアクセスすることはできない。その代わりWebページ内のコンテクスト――DOMにアクセスすることが可能だ。
既存のWebページにてpage.evaluate()を使ってDOM経由でページを操作することで、PDFやPNGを出力する前にページの配色を変えたり、フォーム要素に自動的に値を設定してsubmitする、といった使い方ができる。*3
それだけでなく、PhantomJSのスクリプト内でページを生成してpage.render()で結果を描画することもできる。
/*jslint browser: true, plusplus: true, maxerr: 50, indent: 2 */ /*global phantom: false, require: false */ (function () { 'use strict'; var page = require('webpage').create(); page.content = '<html><body><p id="result"></p></body></html>'; page.evaluate(function () { var iota = function (count, start, step) { var retval = [], i; if (start === undefined) { start = 0; } if (step === undefined) { step = 1; } for (i = 0; i < count; ++i) { retval[i] = start + step * i; } return retval; }, fizzbuzz = (function () { var ref = [null, 'Fizz', 'Buzz', 'FizzBuzz']; return function (n) { var fizz = n % 3 === 0 ? 1 : 0, buzz = n % 5 === 0 ? 2 : 0; ref[0] = n; return ref[fizz + buzz]; }; }()); document.getElementById('result').innerHTML = iota(100, 1).map(fizzbuzz).join('<br>'); }); page.viewportSize = { width: 100, height: 100 }; page.render('./fizzbuzz.png'); phantom.exit(); }());
新しいWebページを生成し、雛形となるHTMLテキストを設定した上で、page.evaluate()を使用してDOM経由で動的にHTMLを操作し、結果をPNGとして出力している。
page.evaluate()の引数に指定した関数からは外部の関数や変数を参照することができない。試しにクロージャを引数に指定してみたところ、関数自体の内部変数しか参照できなかった。
page.evaluate()のコードに対して外部からパラメータ等を渡す方法は無いか? 「モジュール機能」の項の最後に書いたが、WebPageオブジェクトにもメソッドinjectJs()がある。実はこのメソッドで読み込んだコードはpage.evaluate()から参照することが可能なようだ。
例えば「モジュール機能」の項にでてきたfizzbuzz.jsを使うことで、前のコードをこんな風に書き換えられる。
/*jslint browser: true, maxerr: 50, indent: 2 */ /*global phantom: false, require: false, fizzbuzzAnswers: false */ (function () { 'use strict'; var page = require('webpage').create(); page.content = '<html><body><p id="result"></p></body></html>'; page.injectJs('./fizzbuzz.js'); page.evaluate(function () { document.getElementById('result').innerHTML = fizzbuzzAnswers(100).join('<br>'); }); page.viewportSize = { width: 100, height: 100 }; page.render('./fizzbuzz.png'); phantom.exit(); }());
page.injectJs()と似たメソッドにpage.includeJs()がある。こちらは引数としてURLとコールバック関数を指定する。指定したURLのスクリプト読み込み、完了したらコールバック関数を実行するのだ。振る舞いとしてはscriptタグを動的に生成にて任意のスクリプトファイルを読み込むのと同じ感じらしい。
まとめ
PhantomJSでWebページを操作する(特にフォーム要素)ネタってどこかで見たことがあると思っていたのだが、思い出した。WSHを使ったInternet Explorerのオートパイロットだ。私は挫折したので実際に書いたことは無いけど、VBScripじゃなくてJScriptで書いたら「似て非なる世界」になるかもしれない。実際のコードは随分と異なるはずだが、WSH + JScript + IEの組み合わせはコマンドプロンプトで実行できて、モジュール化に対応していて*4、ファイルやフォルダを操作可能で、COM経由でIEを操作することでスクレイピング的なことができる。
もっとも私がIEのオートパイロットネタに遭遇した頃にはまだスクレイピングなんて単語は一般的ではなかったように記憶している(もしかして、まだ無かったのかも……)。
PhantomJSは「ヘッドレスWebブラウザ」という性質上、既存のWebページを取得して加工することに長けていることは間違いない。しかし他にも色々な使い方があるように感じる点が色々あるように思う。一般的なブラウザと同様にローカルのHTMLファイル等を開くこともできるし、スクリプト内で新たなページを生成することもできる。決して汎用的なJavaScriptの処理系ではないものの、GUIでは無いという部分で一般的なブラウザの枠を超えた使い道を模索できる余地がある。
ただマニュアルの整備が追いついてなくて、サンプルが無いので使い所が分からないAPIもあれば、そもそもマニュアルに書かれていないAPIもある。この辺りが課題だろうと思うけど、この手の作業は手間が掛かるし(私のように)好きじゃない人もいるので大変だ。
まあ、そんな難しいことを考えなくともクライアントサイドJavaScript絡みの単体テストをコンソールで実行できるだけで十分に価値があるツールだ。QUnit + QUnit-TAP + PhantomJSの組み合わせは素晴らしい。
*1:世間じゃ逆FizzBuzzなるものが流行ってるらしいけど、わたしゃ普通のFizzBuzzだけで手一杯なのよ。
*2:今、決めた。
*3:付け加えると、<input type="file"> な要素にファイルを指定するにはpage.uploadFile()を使えばよいらしい。あとpage.sendEvent()というメソッドもあるが、サンプルが見つからなかったこともあり、よく分からない。
*4:但しWSF(Windows Script File)を使った場合のみ。WSFの中身はXMLで、HTMLにてscriptタグでJavaScriptのソースファイルを読み込むのと似たような感じにモジュール化することになる。