PHPでもスクレイピングがしたい

「スクレイピング 入門」でググるとやたら Python が勧められますが、それは情報と優秀なライブラリが充実しているためであって Python じゃないとスクレイピングできないわけではありません。昨今の Web 事情から、スクレイピングはヘッドレスブラウザを併用するのが当たり前になっているので、ブラウザを操作するスクリプトが作れれば言語は何だって構いません。

PHP でのスクレイピングに関してはあまりやる人がいないのか、調べても情報が古くて (あるいは古臭くて) あまり参考にならなかったので片手間に実装した例を書いておきます。

## 目標

PHP でスクレイピングによって Web サイトから情報を取得するクローラーを開発する。

## 使用するライブラリ

- [Guzzle](https://github.com/guzzle/guzzle) : HTTP クライアント
- [PHP DOM Wrapper](https://github.com/scotteh/php-dom-wrapper) : DOM 操作
- [Chrome PHP](https://github.com/chrome-php/headless-chromium-php) : ヘッドレス Chrome 操作
- [WorkerPool](https://github.com/qxsch/WorkerPool) : 並列処理

```
composer require guzzlehttp/guzzle scotteh/php-dom-wrapper chrome-php/chrome qxsch/worker-pool
```

ライブラリの製作者に感謝です😘

## 注意事項

- スクレイピングは対象のサイトに過剰な負荷をかけないように節度を守って行いましょう。
- スクレイピングが禁止されているサイトでは行わないようにしましょう。
- プログラムのバグで対象のサイトに過剰な負荷をかけないように、ローカル環境でのテストやデバッグを徹底しましょう。

これから紹介するコードは実際には存在しない URL を対象とするので、自分で試すときは適宜置き換えてください。

## 実例

### URL から HTML をダウンロードして必要な情報を抜き出したい

スクレイピングは、欲しい情報が載ったドキュメントをパースして情報の場所を特定し、それを抜き出します。ドキュメントは URL からダウンロードします (今回は HTML ドキュメントを対象とします) 。

ダウンロードには Guzzle を、 HTML パーサーは PHP DOM Wrapper を使います。

```php
<?php

require_once __DIR__ . '/vendor/autoload.php';

use DOMWrap\Document;
use GuzzleHttp\Client;

// ここに書かれている URL はダミーです
// クロールリストは実際にはデータベースから取ってくるとか
$sites = [
    [
        'url' => 'https://dev.wazly.net/',
        'selector' => '#news > div > dl:nth-child(1) > dd > p > a',
    ],
    [
        'url' => 'https://info.wazly.net/news',
        'selector' => '#root > div.contents > div.main > ul > li:nth-child(1) > a > h4'
    ]
];

$client = new Client;

foreach ($sites as $site) {
    $response = $client->get($site['url']);
    $html = (string) $response->getBody();
    $doc = new Document;
    $node = $doc->html($html);

    // 取得した文字列
    $text = $node->find($site['selector'])->text();

    // 通知したりデータベースに追加したり
    echo $text, PHP_EOL;
}
```

状況に応じて HTTP ヘッダに適当な値をセットしてもよいでしょう (モバイル端末用サイトを参照するときに UA を変更するとか)。

https://github.com/guzzle/guzzle

PHP DOM Wrapper は CSS セレクターで要素を指定できるだけでなく、 jQuery のように DOM を扱うこともできます。

https://github.com/scotteh/php-dom-wrapper

### 動的サイトに対応したい

スクレイピング対象のページの HTML に最初から欲しい情報があるとは限りません。ブラウザでページをロードした後、 JavaScript によって後から情報がダウンロードされる仕組みをもつサイトも数多く存在します。また、ボタンクリックなどのアクションを起こさないと情報が取り出せないこともあるでしょう。

そのような場合は、ブラウザを立ち上げてサイトにアクセスし、アクションを実行させる必要があります。もちろん、ブラウザの起動・終了と操作は PHP スクリプトで制御し自動で行うようにします。

下記コードは Chrome PHP と macOS 上の Google Chrome のヘッドレスモードを使ってクロール&スクレイピングする例です:

```php
<?php

require_once __DIR__ . '/vendor/autoload.php';

use DOMWrap\Document;
use HeadlessChromium\BrowserFactory;

// ここに書かれている URL はダミーです
// クロールリストは実際にはデータベースから取ってくるとか
$sites = [
    [
        'url' => 'https://dev.wazly.net/',
        'selector' => '#news > div > dl:nth-child(1) > dd > p > a',
    ],
    [
        'url' => 'https://info.wazly.net/news',
        'selector' => '#root > div.contents > div.main > ul > li:nth-child(1) > a > h4'
    ]
];

foreach ($sites as $site) {
    $browserFactory = new BrowserFactory(
        // 実行するブラウザのパスに応じて変更
        // https://github.com/chrome-php/headless-chromium-php/issues/75
        '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome'
    );
    $browser = $browserFactory->createBrowser();
    $page = $browser->createPage();
    $page->navigate($site['url'])->waitForNavigation();
    $evaluation = $page->evaluate('document.documentElement.innerHTML');
    $value = $evaluation->getReturnValue();
    $browser->close();

    $doc = new Document;
    $node = $doc->html($value);

    // 取得した文字列
    $text = $node->find($site['selector'])->text();

    // 通知したりデータベースに追加したり
    echo $text, PHP_EOL;
}
```

画像をダウンロードしないでリソースを節約したり、スクリーンショットを撮ったりすることもできます。

https://github.com/chrome-php/headless-chromium-php

### 並列実行して時短したい

ページロードの待ち時間などの理由で、動的サイトのスクレイピングは非常に時間がかかるものです。現在開いているブラウザの終了を待って次のブラウザを開く、では効率が悪すぎます。なので複数のブラウザを開いてそれぞれを並列に処理するようにします (ひとつのサイトに対する複数同時アクセスは控えましょう) 。

下記は WorkerPool を使って並列スクレイピングを実行するコードの例です:

```php
<?php

require_once __DIR__ . '/vendor/autoload.php';

use DOMWrap\Document;
use QXS\WorkerPool\WorkerPool;
use QXS\WorkerPool\ClosureWorker;
use HeadlessChromium\BrowserFactory;

// ここに書かれている URL はダミーです
// クロールリストは実際にはデータベースから取ってくるとか
$sites = [
    [
        'url' => 'https://dev.wazly.net/',
        'selector' => '#news > div > dl:nth-child(1) > dd > p > a',
    ],
    [
        'url' => 'https://info.wazly.net/news',
        'selector' => '#root > div.contents > div.main > ul > li:nth-child(1) > a > h4'
    ]
];

$wp = new WorkerPool;

$wp->setWorkerPoolSize(2) // 同時実行する数
    ->create(new ClosureWorker(
        function ($input, $semaphore, $storage) {
            $browserFactory = new BrowserFactory('/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome');
            $browser = $browserFactory->createBrowser();
            $page = $browser->createPage();
            echo 'Browsing ' . $input['url'], PHP_EOL;
            $page->navigate($input['url'])->waitForNavigation();
            $evaluation = $page->evaluate('document.documentElement.innerHTML');
            $value = $evaluation->getReturnValue();
            echo 'Leaving ' . $input['url'], PHP_EOL;
            $browser->close();

            $doc = new Document;
            $node = $doc->html($value);

            // 取得した文字列
            return $node->find($input['selector'])->text();
        }
    ));

foreach ($sites as $site) {
    $wp->run($site);
}

$wp->waitForAllWorkers();

foreach ($wp as $val) {
    // 通知したりデータベースに追加したり
    echo $val['data'], PHP_EOL;
}
```

これを実行すると

```
Browsing https://info.wazly.net/news
Browsing https://dev.wazly.net/
```

の2行が同時に出力され、その後時間差で

```
Leaving https://dev.wazly.net/
Leaving https://info.wazly.net/news
```

という具合にそれぞれの行が出力されます。そのとき早く処理が終わったものから出力されるので順不同です。最後に取得結果が出力されます。

並列処理した方が圧倒的に早く終わるので、実行環境に応じて並列数を調整してリソースを最大限に活用するとよいでしょう。

https://github.com/qxsch/WorkerPool

## まとめと感想

PHP でももちろんスクレイピングはできます。しかもやってみたら意外と簡単で実用的でした。これもライブラリのおかげですね。 Guzzle 以外は GitHub のスター数が2、3桁でそこまで有名ではないのですが、こうやって組み合わせてコンボを発揮させると楽しいです。PHP を普段よく書いていてスクレイピングをやってみたいという方はぜひお試しあれ😋