PHPのWebスクレイピング・ライブラリ「Goutte」と「phpQuery」を比較してみた

私はWordPressの自作プラグインを公式SVNに登録しているのだが、ダウンロード状況等のステータスを毎度WordPress.orgまで見に行くのが億劫になってきたので、自分のブログサイトにその情報だけスクレイピングしてきて楽したいなぁ…と思い立った。

以前、自分でスクレイピング処理を書くのはやったことがあるんだが、その時はソース元のHTMLが汚かったこともあって、非常に面倒だった記憶だけが残っている。私の脳内ではスクレイピング=鬼門なのだ（笑） まぁ、他所のサイトの、しかも一世代前のXHTMLあたりで書かれた（おそらくシステムが自動で吐き出していると思われる）構造だけが複雑なHTMLのパース処理をコーディングをしていると、モチベーションがただひたすらに下がり続けるものなんだ──という苦い経験は今回はしたくない。

そんなわけで、今回はPHPのスクレイピングツールを利用して、サクっと作ってみようと思った次第。検索してみたところ、引っかかった中では「Goutte」と「phpQuery」が一番使い易そうだった。今回の記事では、この二つのライブラリでスクレイピング処理を書いて、実際に動かして、ベンチマーク取ってみての比較をまとめてみた。

## 導入状況を比較

まず、それぞれのライブラリのスペックを比較してみる。

|   ＼   | Goutte | phpQuery |
|:------:|:-------:|:-------:|
| 配布元  | [https://github.com/FriendsOfPHP/Goutte](https://github.com/FriendsOfPHP/Goutte) | [https://code.google.com/p/phpquery/](https://code.google.com/p/phpquery/) |
| インストール方法 | 実行ファイルをインクルード | 実行ファイルをインクルード |
| 実行ファイル名 | **goutte.phar** <br>(Pharアーカイブ形式) | **phpQuery-onefile.php** <br>(PHPファイル) |
| ファイルサイズ | 720KB | 164KB | 
| 最新バージョン | 2.0 | 0.9.5 |
| 推奨PHPバージョン | 5.4 以降 | 5.2 以降 |
| 必要な依存ライブラリ | Guzzle 4.* | 特になし |
| 備考 | PHP 5.3系で利用する場合はGoutte1.0.6+Guzzle3が必要 | CLIもあり、コマンドラインからも利用できる |

_※ 2015年1月30日時点_

導入のし易さとしては、特に依存関係のあるライブラリもなく、対応するPHPバージョンも幅があるので、 `phpQuery` の方にやや軍配が上がるかなぁ。まぁ、`Goutte` にしてもコマンド一発で依存系含めてインストールできるので、特に導入に手間取ることはなかっし、導入時のスペック差はあまりないという印象だ。

## コーディング仕様を比較

ここでは実際にコードを見てもらうのが早いかと思うので、それぞれのライブラリで同じ処理を書いてみた。処理内容としては、WordPress.org の任意のプラグイン（テーマも可）のステータス状況のページから各種利用状況系のデータだけを取得して、再利用し易いPHPの配列に格納するという、まぁWebスクレイピングを使った、いわゆるクローラー的な処理である。

まずは、 **Goutteを利用したスクレイピング処理** の例:

```php:use_goutte.php
<?php
// Plugin Name
$extend_type = 'plugins'; // or themes
$extend_name = 'custom-database-tables';

require_once '/wp-content/themes/my_theme/lib/vendor/goutte.phar';

use Goutte\Client;

// Create Goutte Object
$client = new Client();

// Get Data Source
$crawler = $client->request('GET', "https://wordpress.org/{$extend_type}/{$extend_name}/stats/");

// Get DOM Objects
$plugin_title = $crawler->filter('div#plugin-title h2');
if ($extend_type == 'plugins') {
	$contents_path = 'div#pagebody div.wrapper div.col-10 div div.col-3 p';
} else {
	$contents_path = 'div#fyi ul';
}
$sidebar_info = $crawler->filter($contents_path);
$history = $crawler->filter('div#history');

$stats = array(ucfirst(rtrim($extend_type, 's')) . ' Name' => $plugin_title->text());

$sidebar_info->each(function ($node) use ($extend_type, &$stats) {
	$labels = array();
	
	$text_source = $node->text();
	
	$children = $node->filter('strong');
	$children->each(function ($node) use (&$labels) {
		$labels[] = $node->text();
	});
	
	foreach (array_reverse($labels) as $label) {
		if (preg_match("/{$label}/", $text_source)) {
			list($text_source, $value) = explode($label, $text_source);
			$label = rtrim(trim($label), ':');
			if ($extend_type == 'themes') {
				list($value, ) = preg_split('/[\s]{1,}/', trim($value));
			}
			$stats[$label] = trim($value);
		}
	}
	
});

$history->each(function ($node) use (&$stats) {
	$labels = array();
	
	$rows = $node->filter('tr');
	$rows->each(function ($node) use (&$stats) {
		$key = trim($node->filter('th')->text());
		$value = trim($node->filter('td')->text());
		$stats[$key] = $value;
	});
	
});

var_dump($stats);
```

次に、**phpQueryを利用したスクレイピング処理** の例:

```php:use_phpQuery.php
<?php
// Plugin Name
$extend_type = 'plugins'; // or themes
$extend_name = 'custom-database-tables';

require_once '/wp-content/themes/my_theme/lib/vendor/phpQuery-onefile.php';

// Get Data Source
$html = file_get_contents("https://wordpress.org/{$extend_type}/{$extend_name}/stats/");

// Get DOM Object
$dom = phpQuery::newDocument($html);

// Parse DOM Objects
$plugin_title = $dom['div#plugin-title h2'];
if ($extend_type == 'plugins') {
	$contents_path = 'div#pagebody div.wrapper div.col-10 div div.col-3 p:first';
} else {
	$contents_path = 'div#fyi ul';
}
$sidebar_info = $dom[$contents_path];
$history = $dom['div#history table'];

$stats = array(ucfirst(rtrim($extend_type, 's')) . ' Name' => $plugin_title->text());

$text_source = $sidebar_info->text();
$labels = array();
foreach ($sidebar_info->find('strong') as $label) {
	$labels[] = pq($label)->text();
}

foreach (array_reverse($labels) as $label) {
	if (preg_match("/{$label}/", $text_source)) {
		list($text_source, $value) = explode($label, $text_source);
		$label = rtrim(trim($label), ':');
		if ($extend_type == 'themes') {
			list($value, ) = preg_split('/[\s]{1,}/', trim($value));
		}
		$stats[$label] = trim($value);
	}
}

foreach ($history->find('tr') as $row) {
	$key = trim(pq($row)->find('th')->text());
	$value = trim(pq($row)->find('td')->text());
	$stats[$key] = $value;
}

var_dump($stats);
```

実行すると、どちらも下記のようにプラグインの利用状況をWordPress.orgから取得してきて、再利用し易いPHPの配列にスクレイピングしてくれる。

**スクレイピング元となるWordPress.orgのプラグイン利用状況ページ**
![WordPress.orgのプラグイン利用状況ページ](http://ka2.org/assets/2015/01/web_scraping_base.jpg "WordPress.orgのプラグイン利用状況ページ")

**スクレイピングの結果**
![スクレイピング結果表示](http://ka2.org/assets/2015/01/web_scraping_done.png "スクレイピング結果表示")

コーディングのし易さでも、`phpQuery` の方がコード量が少なくて済むので扱い易い印象があった。 `Goutte` では名前空間を利用しているので、グローバル空間側で定義した変数などを `Client` クラスに受け渡す場合や、 `Client` クラス内のメソッドへ変数を受け渡す時などに、リファレンス扱いで参照渡ししないとそれぞれの名前空間のスコープで変数が共存できなかったりしてちょいと面倒だな…と感じた次第。まぁ、この辺は好き嫌いの範疇だなぁ…。
特筆すべき点としては、スクレイピング元のURLからファイルを取得する処理の差だろうか。というのも、 `Goutte` では専用の `request()` メソッドがあって、そこでは`GET`以外に`POST`メソッドタイプにてファイル取得ができる点が優れている。これによってSubmit後にしか表示できないようなURLにも対応できる可能性があるのだ。一方で、 `phpQuery` ではファイル取得はライブラリに依存しない形で別途コーディングしないといけない。例では `file_get_contents()` 関数で取得しているが、POSTメソッドを指定してアクセスしたい場合などは結構手間が増えるかもしれない。
取得したデータをDOMパースした後のコンテンツ捜査性については、どちらもあまり差はなく、CSSのセレクタ形式（jQueryのセレクタと同じ）のパスで要素を指定できる。そして、セレクタ形式パス指定で取得したDOMオブジェクト（PHPオブジェクト）に対してはメソッドを指定してデータ取得が行えるのも同じだ。まぁ、この部分の差があるとすれば、 `phpQuery` の方がJQueryのメソッド名に近しいので、jQuery知っているならこちらの方が扱い易い程度かなぁ。

## パフォーマンスの比較

さて、ここでは前項で紹介した同じスクレイピング処理をパフォーマンスの点で比較してみた。まずは、処理時間からだ。

### 処理時間の比較

テスト内容としては、30回同じURLに対してスクレイピングリクエストを実行して、まず外部ライブラリを読み込んで初期化完了するまでの時間、次にURLリクエストの応答と取得データのDOMオブジェクトパース処理の時間、最後にコンテンツ捜査処理が完了するまでの時間、そしてトータルの全処理時間の平均値を出してみた。 `Goutte` のみPOSTメソッドでもスクレイピングを実行してみたので、その際の平均値は（）内に記載してある。

|   ＼   | Goutte | phpQuery |
|:----:|------:|------:|
| ライブラリ初期化 | 0.001300057 (0.003100181) | 0.001366655 |
| URLリクエスト応答＋<br>DOMオブジェクト生成 | 0.716607642 (0.760643625) | 0.869116362 |
| コンテンツ捜査| 0.039035694 (0.039702058) | 0.016300988 |
| 全処理合計 | 0.756943393 (0.803445864) | 0.886784005 |

結果としては、 `Goutte` の方がパフォーマンスが良かった。URLリクエストで取得したデータ（html）からDOMオブジェクトを生成する処理で差が大きかった感じだ。なお、 `Goutte` では同一URLへのGETメソッドでのリクエストをキャッシュするようで、初回のリクエストより後は処理時間が短くなっていた（一方、POSTメソッドでは毎回同じような処理時間だった）。面白いのが、生成したDOMオブジェクトに対してのコンテンツ操作性能では `phpQuery` の方が優れていた点だ。捜査時間が `Goutte` の半分以下である。
全体の処理時間性能としては `Goutte` に軍配が上がりそうだが、一度URLリクエスト応答して取得済みのオブジェクトから様々な異なるコンテンツ捜査を複数回行うような処理の場合は、コンテンツ捜査性能が高い `phpQuery` の方が有利になるかもしれない。

### 使用メモリの比較

テスト内容は前述の処理時間と一緒だが、回数重ねてもほぼ近似値しか得られなかったので、それぞれ10回ずつで検証してみた。
なお、メモリの場合、プロセスに割り当てられるメモリの最大値に対して実プロセスでどれだけメモリを使用しているかを測ることで、そのアプリケーション（ライブラリ）がどの程度効率的にメモリを使っているのかとかもわかる。項目が多いのでそれぞれのライブラリでまとめてみる。

#### phpQuery の使用メモリ
|   ＼   | 使用メモリ量(KB) | 割り当てメモリ最大値(KB) | メモリ効率 |
|:----:|------:|------:|------:|
| ライブラリ初期化 | 708.8133 | 717.793 | 98.75% |
| URLリクエスト応答＋<br>DOMオブジェクト生成 | 36.04688 | 41.8125 | 86.21% |
| コンテンツ捜査 | 10.92188 | 159.8391 | 6.83% |
| 全処理合計 | 755.782 | 919.4445 | 82.20% |

コンテンツ捜査にかなり余裕を持ってメモリを割り当てている。だからコンテンツ捜査性能がいいんだねぇ…。まぁ、全体的にメモリ割り当て効率に無駄がなくて、賢いライブラリという印象を受けた。この辺はさすがGoogleと言ったところかな（笑）

#### Goutte の使用メモリ
|   ＼   | 使用メモリ量(KB) | 割り当てメモリ最大値(KB) | メモリ効率 |
|:----:|------:|------:|------:|
| ライブラリ初期化 | 326.4375 | 369.2656 | 88.40% |
| URLリクエスト応答＋<br>DOMオブジェクト生成 | 1217.562 | 1266.816 | 96.11% |
| コンテンツ捜査 | 410.6547 | 375.4781 | 109.37% |
| 全処理合計 | 1954.654 | 2011.56 | 97.17% |

うお！？ ちょっと、コンテンツ捜査処理で使用されるメモリが割り当て値を超えちゃってますよ（笑）これではせっかくその前までのプロセスがパフォーマンス良くても、ここの処理いかんでは詰まってしまう可能性があるかもねぇ。その辺が `phpQuery` よりコンテンツ捜査性能が悪い原因かもしれん。全処理としてのメモリ効率はかなりイイだけに、この内部プロセスのメモリ割り当てを最適化すればかなり良いパフォーマンスになるんではないだろうか？　しかし、まぁ `Goutte` は結構メモリ使うななぁ…。

というわけで、使用メモリを比較した結果は、圧倒的に `phpQuery` の勝ちですな。
`Goutte` でのスクレイピングは2MB程度のメモリを食うが、 `phpQuery` はその半分以下のメモリでサクサク動いているという感じ。

## 総評

なかなか甲乙付けがたい結果ではあるんだが、あえて判定するなら、スクレイピング・ライブラリとしては `phpQuery` が僅差で勝ちって感じかなぁ。あくまで私の主観での総評になるが、比較結果は下記のようになるかと。

- 導入敷居の低さ： **phpQuery** > Goutte （依存系ライブラリがなく、素で対応するPHPバージョンが多い）
- コーディング： **phpQuery** ≒ **Goutte** （コード量では `phpQuery` だが、機能面では `Goutte` の方がやや優秀か）
- パフォーマンス（処理時間）： phpQuery < **Goutte** （今回は総処理時間で `Goutte` に分があったが、コンテンツ捜査処理いかんでは逆転するリスクを含んでいる）
- パフォーマンス（使用メモリ）： **phpQuery** > Goutte （使用メモリ効率として97%の `Goutte` は無駄がなくて良いのだが、総メモリ使用量と各プロセスにおける割り当てメモリの分配の仕方は `phpQuery` の方が優秀だった）

どっちを使うかは好み次第だなぁ…。
ちなみに私は、jQuery好きなこともあるし、使用メモリ量が少なくて済む `phpQuery` を選ぼうかと。