Webの多くは今もPHPで動いている。ショップ、CMS、社内ツール、顧客ポータルなどだ。こうしたアプリケーションが競合価格、製品詳細、商品一覧といった他サイトのデータを必要とするとき、file_get_contentsでページを取得して正規表現で解析するという方法に惹かれがちだ。これは初日こそ動くが、サイトがサーバーのIPをブロックしたりマークアップを変更したりすると、十日目には壊れてしまう。
web scraping APIを使えば、こうした脆さの大部分を取り除ける。独自のプロキシとブラウザを通じてページを取得し、失敗時にはリトライを行い、必要なフィールドだけをJSONとして返すこともできる。このチュートリアルでは、curl拡張以外に依存関係のない小さなPHPクライアントを構築し、アプリケーションでの使い方を示す。
クライアントに求められること
実用上重要なのは次の4点だ。
- APIキーと対象URLを使ってリクエストを送信する。
- 抽出ルールを使って構造化データを要求し、アプリケーションがHTMLを解析する必要がないようにする。
- 正しいエラーだけをリトライし、決して成功しないエラーでは停止する。
- プランの同時実行数の上限を超えないように複数ページを並行して取得する。
web scraping APIを使ったことがない場合は、初心者向けガイドでリクエスト形式とステータスコードについて詳しく解説している。
クライアント
<?php
final class ShifterScraper
{
private const RETRY = [408, 422, 429, 500];
public function __construct(
private string $apiKey,
private string $endpoint = 'https://scrape.shifter.io/v1',
private int $attempts = 3,
private float $backoffSeconds = 2.0,
) {}
/** Fetch one page; with $extractRules, returns the decoded JSON instead of HTML. */
public function fetch(string $url, array $extractRules = [], bool $renderJs = false): string|array
{
$params = ['api_key' => $this->apiKey, 'url' => $url];
if ($renderJs) {
$params['render_js'] = 1;
}
if ($extractRules) {
$params['extract_rules'] = json_encode($extractRules);
}
for ($attempt = 1; ; $attempt++) {
[$status, $body] = $this->get($this->endpoint . '?' . http_build_query($params));
if ($status === 200) {
return $extractRules ? json_decode($body, true, flags: JSON_THROW_ON_ERROR) : $body;
}
if (!in_array($status, self::RETRY, true) || $attempt >= $this->attempts) {
throw new RuntimeException("HTTP {$status} for {$url}: " . substr($body, 0, 200));
}
usleep((int) ($this->backoffSeconds * $attempt * 1_000_000));
}
}
/** Fetch many pages at once with curl_multi, at most $concurrency in flight. */
public function fetchMany(array $urls, array $extractRules = [], int $concurrency = 10): array
{
$results = [];
foreach (array_chunk($urls, $concurrency) as $batch) {
$multi = curl_multi_init();
$handles = [];
foreach ($batch as $url) {
$params = ['api_key' => $this->apiKey, 'url' => $url];
if ($extractRules) {
$params['extract_rules'] = json_encode($extractRules);
}
$handles[$url] = $this->handle($this->endpoint . '?' . http_build_query($params));
curl_multi_add_handle($multi, $handles[$url]);
}
do {
curl_multi_exec($multi, $running);
curl_multi_select($multi);
} while ($running > 0);
foreach ($handles as $url => $h) {
$status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
$body = curl_multi_getcontent($h);
$results[$url] = $status === 200
? ($extractRules ? json_decode($body, true) : $body)
: $this->fetchOrNull($url, $extractRules); // retry failures one by one
curl_multi_remove_handle($multi, $h);
}
curl_multi_close($multi);
}
return $results;
}
private function fetchOrNull(string $url, array $extractRules): string|array|null
{
try {
return $this->fetch($url, $extractRules);
} catch (RuntimeException) {
return null;
}
}
private function handle(string $requestUrl): CurlHandle
{
$h = curl_init($requestUrl);
curl_setopt_array($h, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120]);
return $h;
}
private function get(string $requestUrl): array
{
$h = $this->handle($requestUrl);
$body = curl_exec($h);
$status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
return [$status, $body === false ? curl_error($h) : $body];
}
}
いくつか触れておくべき設計上の選択がある。
- 効果のある場合にのみリトライする。 タイムアウト(
408)、レンダリング失敗(422)、レート制限(429)、サーバーエラー(500)は、間隔を広げながらリトライされる。それ以外、例えば無効なキーの場合は即座に例外を投げる。 - 抽出ルールは配列になる。 ルールを指定すると、
fetch()はデコード済みのJSONを返すため、呼び出し側のコードはマークアップではなく$product['price']のような形で扱える。 curl_multiによる並行リクエスト。fetchMany()は一度にバッチでリクエストを送信し、すべてが完了するのを待つ。バッチサイズはプランの同時実行数の上限を下回るように調整する。バッチ内で失敗したページは、リトライ機能を持つfetch()によって再度チャンスが与えられ、それでも失敗する場合はnullとして返される。
使い方
<?php
require __DIR__ . '/ShifterScraper.php';
$scraper = new ShifterScraper(getenv('SHIFTER_SCRAPER_KEY'));
// One page, as structured data instead of HTML
$product = $scraper->fetch('https://shop.example.com/p/123', [
'name' => ['selector' => 'h1', 'output' => 'text'],
'price' => ['selector' => '.price', 'output' => 'text'],
]);
// A batch, ten requests in flight at a time; failed pages come back as null
$pages = $scraper->fetchMany($productUrls, ['name' => ['selector' => 'h1', 'output' => 'text']], 10);
APIキーは環境変数やフレームワークのシークレット設定に保存し、コード内に直接書かないこと。
テスト方法
APIの代わりとなるローカルのPHPサーバーに対してクライアントを実行した。このサーバーは通常のページに加え、2回429で失敗してから成功する1ページ、常に401を返す1ページ、常に408でタイムアウトする1ページを配信した。テストでは以下を確認した。
- HTMLと抽出されたJSONが正しく返ってくること
- 不安定なページが3回目の試行で成功すること
401は1回だけ試行され、408は上限までリトライされた後に失敗すること- 13ページのバッチで12件の結果と、失敗したページに対する1件の
nullが返ること
PHP 8.5上で11件のチェックすべてが成功した。
アプリケーションへの組み込み方
ページ表示中にスクレイピングしてはならない。 ユーザーがリモートサイトの読み込みを待たされることは決してあってはならない。収集処理はキューワーカーやスケジュールされたジョブで実行し、保存済みの結果を配信する。
結果はタイムスタンプ付きで保存する。 各レコードを収集時刻とともに保存すれば、アプリケーションはその鮮度を示すことができ、古くなったものだけを更新できる。
積極的にキャッシュする。 ほとんどのデータは分単位で変化するものではない。多くのサイトでは、価格や商品一覧を1時間ごとまたは1日ごとに更新するだけで十分であり、省けたリクエストの一つひとつがコスト削減につながる。
失敗を記録する。 どのURLがnullを返しているか、その理由を記録する。急増している場合は通常、サイトがレイアウトを変更し、抽出ルールの更新が必要であることを意味する。
プランの制限を尊重する。 バッチサイズはプランの同時実行数の上限以下に設定する。Shifterの場合、この上限はプランによって20から500リクエストの間で異なる。
直接プロキシを使うべき場合
web scraping APIはほとんどのデータ収集に適している。レンダリング、リトライ、IP選択を代わりに処理してくれるからだ。ただし、HTTPクライアントを完全に制御する必要がある場合や、1つのIPで長時間のセッションを維持する必要がある場合など、直接プロキシ接続の方が適したタスクも一部存在する。PHPでcURLとGuzzleを使ってレジデンシャルプロキシを利用する方法のガイドでは、その方法を解説している。
FAQ
PHPはweb scrapingに使えますか?
使える。PHPのcurl拡張は、curl_multiによる並行リクエストを含め、HTTPを十分に扱える。これをweb scraping APIと組み合わせれば、アプリケーション自体がプロキシ、ブラウザ、ブロックを管理する必要がなくなる。
PHPからweb scraping APIを呼び出すのにGuzzleは必要ですか?
不要だ。本ガイドのクライアントは組み込みのcurl拡張のみを使用している。すでにアプリケーションでGuzzleを使っている場合は、それでも構わない。
PHPからJavaScriptでレンダリングされたページをスクレイピングするにはどうすればよいですか?
render_jsを渡してAPIにページのレンダリングを依頼する。これはクライアントでパラメータとして公開されている。APIがブラウザを実行し、レンダリング済みのページを返すため、PHP側でヘッドレスブラウザを用意する必要はない。
一度に送信できるリクエスト数はどれくらいですか?
利用しているweb scraping APIのプランが許す範囲まで可能だ。fetchMany()のバッチサイズはその上限以下に設定すること。
まとめ
PHPアプリケーションは、スクレイピング用のインフラを自前で構築しなくてもWebデータを利用できる。curlベースの小さなクライアント、HTML解析の代わりとなる抽出ルール、成功する可能性のあるエラーだけを対象としたリトライ、プランに合わせたサイズの並行バッチがあれば十分だ。これをキューから実行し、結果をタイムスタンプ付きで保存すれば、ユーザーに他者のWebサイトの読み込みを待たせることなく、アプリケーションは新鮮なデータを得られる。