BVP Scraper は、ボートレースの公式サイトから出走表、直前情報、オッズ、結果をスクレイピングするための PHP ライブラリです。
v10 以降は、後継ライブラリである turnmark/scraper とは異なる方向性を持つ派生として、以下の 2 点に力を入れています。
- 鮮度に応じたキャッシュ: 確定済みの過去日のレースは不変とみなし、キャッシュに永続化。バックフィル用途で同じ日付を何度も取り直す必要がなくなります。
- インスタンス単位の並行実行: レート制御・キャッシュ参照をインスタンススコープに保持するため、プロキシやワーカーごとに複数の
Scraperインスタンスを同一プロセス内で干渉なく並行運用できます。
- php: ^8.3
- nesbot/carbon: ^2.63 || ^3.0
- psr/simple-cache: ^3.0
- symfony/browser-kit: ^7.0 || ^8.0
- symfony/cache: ^7.0 || ^8.0
- symfony/css-selector: ^7.0 || ^8.0
- symfony/http-client: ^7.0 || ^8.0
composer require bvp/scraperScraper はインスタンスベースの API です。静的なシングルトンファサードは提供していません(後述)。
| メソッド | 説明 | 引数 |
|---|---|---|
scrapeProgram($date, $stadiumNumber, $raceNumber) |
出走表を取得 | $date : Carbon対応日付文字列またはCarbonインスタンス$stadiumNumber : 1〜24$raceNumber : 1〜12 |
scrapePreview($date, $stadiumNumber, $raceNumber) |
直前情報を取得 | 同上 |
scrapeOdds($date, $stadiumNumber, $raceNumber) |
全オッズ(7種)を取得 | 同上 |
scrapeWin / scrapePlace / scrapeExacta / scrapeQuinella / scrapeQuinellaPlace / scrapeTrifecta / scrapeTrio |
単勝・複勝・2連単・2連複・拡連複・3連単・3連複のオッズを個別に取得 | 同上 |
scrapeSingle / scrapePair / scrapeTriple |
単勝・複勝 / 2連単・2連複・拡連複 / 3連単・3連複をまとめて取得 | 同上 |
scrapeResult($date, $stadiumNumber, $raceNumber) |
結果を取得 | 同上 |
scrapeStadium($date) |
開催中の場を取得 | $date のみ |
一括取得は BatchScraper が提供します。メソッド名は Scraper と同名で、引数だけが一括用($stadiumNumber/$raceNumber → $stadiumNumbers/$raceNumbers)になります。
| メソッド | 説明 | 引数 |
|---|---|---|
scrapeProgram($date, $stadiumNumbers = [], $raceNumbers = []) |
出走表を一括取得 | $stadiumNumbers/$raceNumbers 省略時はその日開催している全場・全レース |
scrapePreview / scrapeOdds / scrapeResult |
同上の一括取得版 | 同上 |
scrapeWin / scrapePlace / scrapeExacta / scrapeQuinella / scrapeQuinellaPlace / scrapeTrifecta / scrapeTrio |
同上の一括取得版 | 同上 |
scrapeSingle / scrapePair / scrapeTriple |
同上の一括取得版 | 同上 |
$date の例
'2025-01-01''2025/01/01''yesterday'Carbon::now()->subDay()
<?php
require __DIR__ . '/vendor/autoload.php';
use BVP\Scraper\Scraper;
$scraper = new Scraper();
// 出走表を取得
$program = $scraper->scrapeProgram('2025-01-01', 24, 1);
// 直前情報を取得
$preview = $scraper->scrapePreview('2025-01-01', 24, 1);
// オッズを取得
$odds = $scraper->scrapeOdds('2025-01-01', 24, 1);
// 結果を取得
$result = $scraper->scrapeResult('2025-01-01', 24, 1);
print_r($program);
print_r($preview);
print_r($odds);
print_r($result);use BVP\Scraper\BatchScraper;
// レート制御・キャッシュを共有したい場合は既存の Scraper を渡す(省略時は内部で生成)
$batchScraper = new BatchScraper($scraper);
// その日開催している全場・全レースの結果を取得
$results = $batchScraper->scrapeResult('2025-01-01');
// 開催場・レースを絞り込む
$results = $batchScraper->scrapeResult('2025-01-01', [24], [1, 2, 3]);既定では、リトライを使い切ったレースが1つでもあると例外がそのまま伝播し、そのレース以降の全場・全レースが取得されません。メンテナンス中のように全体が落ちる場合は問題になりませんが、特定のレースだけが恒久的に壊れている場合、失うのは壊れたレースではなくその後ろに並んでいた全場です。
onError を渡すと、1レースの失敗はコールバックに渡されて結果から除外され、走査は続行します。ログに出すか、件数を数えて閾値で判断するかは呼び出し側で決められます。
$failures = [];
$results = $batchScraper->scrapeResult('2025-01-01', onError: function (
Throwable $throwable,
int $stadiumNumber,
int $raceNumber,
) use (&$failures): void {
$failures[] = compact('stadiumNumber', 'raceNumber');
});開催場一覧の解決(scrapeStadium())だけは onError の対象外で、従来どおり例外になります。走査すべきグリッドが決まらないため、隔離のしようがないためです。
各フィールドは、公式サイトから取得した生の文字列({field}_source)と、型変換・Enum変換済みの値({field})のペアで返されます。生データが常に残るため、変換ロジックの検証やデバッグがしやすくなっています。
$result = $scraper->scrapeResult('2017-03-31', 24, 1);
// $result の抜粋
[
'weather_number_source' => '雨',
'weather_number' => 3,
'wind_direction_number_source' => '南西',
'wind_direction_number' => 11,
'racers' => [
1 => [
'name' => '中辻 博訓',
'number_source' => '3833',
'number' => 3833,
// ...
],
// ...
],
];racers は必ず 1〜6 号艇すべてを含みます。ページに載っていない艇も、キーは揃えたうえで値が null になります。
決着しなかった舟券(特払・不成立)は、組番の代わりに公式サイトの表記が label に入ります。
// $result['payouts']['win'] の抜粋
[
['combination' => null, 'amount' => 70, 'label' => '特払'],
];金額が読めなかった行は、行ごと捨てずに amount を null にして残します。組番や文言は取れているのに金額だけ取れないのは、表の状態ではなくページ構成の変化が疑われるため、痕跡を残す方針です。組番も文言も無い空欄行は従来どおり返しません。
payouts は 7 賭式すべてのキーを必ず含みます。払戻表が無いレースでもキーが欠けることはなく、空配列になります。
返還艇があったレースでは remarks に備考、refunds に返還された艇番が入ります(無い場合は remarks が null、refunds が [])。
直前情報の各艇には、プロペラ交換の表記 propeller と、部品交換の一覧 parts が入ります。parts は交換が無ければ空配列、直前情報自体が未掲載なら null です。数量が印字されない部品は quantity が null になります。
// $preview['racers'][2]['parts'] の抜粋
[
['part_number_source' => 'ピストン', 'part_number' => 1, 'quantity' => 2],
['part_number_source' => 'シリンダ', 'part_number' => 5, 'quantity' => null],
];過去日(実行日より前の日付)のスクレイピング結果は、既定でファイルシステムベースの PSR-16 キャッシュに無期限保存されます。当日・未来日はキャッシュされません(レース情報自体が変動しうるため)。
use BVP\Scraper\Caching\CacheFactory;
// キャッシュディレクトリを指定
$scraper = new Scraper(cache: CacheFactory::createDefault('/path/to/cache'));
// 1回目: ネットワークにアクセス
$scraper->scrapeResult('2017-03-31', 24, 1);
// 2回目: キャッシュから即座に返る
$scraper->scrapeResult('2017-03-31', 24, 1);Psr\SimpleCache\CacheInterface を実装した任意のバックエンド(Redis や APCu など)や、キャッシュ対象の判定ロジック(BVP\Scraper\Caching\CachePolicyInterface)を差し替えることもできます。
ごく稀に、公式サイト側で確定済みの過去レースのデータに修正が入ることがあります。そのような場合は forceRefresh: true を指定すると、キャッシュを無視してネットワークから再取得し、その結果でキャッシュを上書きします。以降の通常呼び出しは上書き後の値を返します。
// キャッシュを無視して再取得し、キャッシュも上書きする
$scraper->scrapeResult('2017-03-31', 24, 1, forceRefresh: true);forceRefresh は Scraper/BatchScraper の全ての scrape*() メソッドに指定できます(BatchScraper に指定した場合、開催場一覧の解決も含めて一括分すべてが再取得されます)。
キャッシュの名前空間にはレスポンス形状のバージョンが含まれています(現在 bvp-scraper.v3)。レスポンスのキーや値の意味が変わるリリースでは、このバージョンが上がるため、旧形状のエントリは参照されなくなります。過去日のキャッシュは無期限に保存されるため、これが無いとバックフィルが初回実行時の形状を返し続けることになります。forceRefresh を使う必要はありません。
レート制御はインスタンスごとに保持されるため、プロキシやアカウントが異なる複数の Scraper インスタンスを同一プロセス内で並行運用しても、互いのペース配分を食い合いません。
use BVP\Scraper\RateLimiting\ThrottleRateLimiter;
use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;
// ワーカー1: プロキシA経由で今日分を取得(3秒間隔)
$scraperA = new Scraper(
httpBrowser: new HttpBrowser(HttpClient::create(['proxy' => 'http://proxy-a:8080'])),
rateLimiter: new ThrottleRateLimiter(3.0),
);
// ワーカー2: プロキシB経由で過去分をバックフィル(1秒間隔)
$scraperB = new Scraper(
httpBrowser: new HttpBrowser(HttpClient::create(['proxy' => 'http://proxy-b:8080'])),
rateLimiter: new ThrottleRateLimiter(1.0),
);
// 両者は独立したレート状態を持つため、同一プロセス内で並行運用しても
// 互いのペース配分を食い合わない既定の HTTP クライアントにはタイムアウトの指定がありません(Symfony の既定値=default_socket_timeout、多くの環境で 60 秒。総時間の上限 max_duration は無制限)。応答が返らない場合、1回の試行あたり最大でこの時間ブロックし、さらにリトライ回数だけ繰り返します。一括取得で1日分を走査する用途では、明示的に指定することを推奨します。
HttpBrowserFactory::create() に自前のクライアントを渡せば、このライブラリの UA 偽装ヘッダを維持したままトランスポート設定だけを差し替えられます。
use BVP\Scraper\Factories\HttpBrowserFactory;
use Symfony\Component\HttpClient\HttpClient;
$scraper = new Scraper(
httpBrowser: HttpBrowserFactory::create(httpClient: HttpClient::create([
'timeout' => 15.0,
'max_duration' => 30.0,
])),
);HttpBrowserFactory が UA と Sec-* ヘッダを詰めているのは体裁のためではありません。boatrace.jp は Akamai の背後にあり、ブラウザらしくないリクエストを拒否ではなく「8〜10 秒待たせて 200 で返す」形で扱います(server-timing: edge; dur=8000 として応答自身が申告します。オリジンの処理時間は常に数十ミリ秒です)。エラーにならないため、遅いだけの正常動作に見えてしまいます。
判定軸は 2 つで、どちらを外しても同じ遅延に落ちます。
Sec-CH-UA群とSec-Fetch群のいずれか(両方欠けると遅延)とAccept-Language- UA が名乗る Chrome のメジャーバージョン。これには下限があり、しかも Chrome のリリース暦に追従して上がっていきます(2026-08-07 の実測では 145 が遅延・146 以降が正常)
そのため、このライブラリはバージョンを固定せず日付から外挿します。4 週ごとに 1 つ繰り上げ、さらに少し先行した値を名乗ります。1 つ足りないだけで全リクエストが 9 秒になる一方、多く名乗るぶんには不利益が無い(実測では存在しないバージョンでも正常に応答する)ため、正確さよりも「下回らないこと」に寄せた設計です。
BVP\Scraper\Factories\HttpBrowserFactory::chromeMajorVersion(); // 例: 151固定したい場合や、別の値を名乗らせたい場合は $extraParameters で上書きできます。
HttpBrowserFactory::create([
'HTTP_USER_AGENT' => 'Mozilla/5.0 ... Chrome/149.0.0.0 Safari/537.36',
]);なお、この外挿が守るのは上記のルールだけです。別の理由(未知のヘッダ要求など)で遅延が発生した場合は追従できないため、server-timing: edge; dur を監視することを推奨します。
- v12 は破壊的変更を含みます。v11 から上げる場合は以下を確認してください。
- 金額が読めなかった払戻行を、行ごと捨てずに
amount => nullで返すようになりました。payoutsの行数が従来より増えることがあり、amountをintとして型宣言している箇所は?intに変更が必要です。 - 非推奨だった
Scraper側のscrape*Bulk()(scrapeProgramBulkなど 14 メソッド)を削除しました。BatchScraperの同名メソッドへ移行してください。動作は同一です。 - キャッシュの名前空間が変わるため、蓄積済みの過去日キャッシュは参照されなくなり、再取得が発生します。バックフィル用途で大量にためている場合は、レート制限のもとで再取得にかかる時間を見込んでください。
- 金額が読めなかった払戻行を、行ごと捨てずに
- v11 はレスポンススキーマの破壊的変更を含みます。v10 から上げる場合は以下を確認してください。
payoutsの各行にlabelが加わりました。特払・不成立の行ではcombinationがnullになるため、stringとして型宣言している箇所は?stringに変更が必要です。- 数値でないオッズ(出走取消の文言など)と、欠損した払戻金額は
0/0.0ではなくnullを返すようになりました。float/intとして型宣言している箇所は同様に変更が必要です。 racersは常に 1〜6 号艇すべてを含みます。従来はページに載っていない艇のキー自体が欠落していました。- 結果に
remarks/refunds、直前情報にpropeller/partsが加わりました。 - キャッシュの名前空間が変わるため、蓄積済みの過去日キャッシュは参照されなくなり、再取得が発生します。バックフィル用途で大量にためている場合は、レート制限のもとで再取得にかかる時間を見込んでください。
- v10 は v6 との後方互換性を意図的に持たない大きな設計変更(インスタンスベース API・レスポンススキーマの変更)を含みます。v6 のまま利用する場合は
bvp/scraper: ^6.0に固定してください。 - スクレイピング対象の公式サイトの構造が変更された場合、正しくデータを取得できなくなる可能性があります。
- 利用時は対象サイトの利用規約を遵守してください。
Scraper は MIT license の元で公開されています。