【第3回】路線バス乗り換え PWA — Cloud Functions でバス時刻表 URL を取得する

2026年6月17日

はじめに

前回(第2回)は、BusTime を支える Firestore のデータ設計と Firebase Auth の認証フローを解説しました。今回はシリーズ最終回として、Cloud Functions でバス時刻表 URL をスクレイピングする仕組みを詳しく紹介します。

BusTime では市営バスのリアルタイム時刻表・接近情報を開く際、外部サービスのページ URL を動的に生成する必要があります。その URL 取得処理を Cloud Functions に持たせることで、フロントエンドをシンプルに保ちつつサーバーレスでスクレイピングを運用しています。同様の構成を検討している方の参考になれば幸いです。

Cloud Functions をスクレイピング基盤に選んだ理由

スクレイピング処理をクライアントに置かずサーバー側に切り出したのは、CORS の制約を回避するためです。ブラウザから外部サービスに直接リクエストを送ると CORS エラーになるため、サーバー経由でフェッチする必要がありました。

Cloud Functions を選んだ理由は、Firebase プロジェクト内で完結するからです。Firebase Hosting・Firestore と同一プロジェクトで動くため、認証やデプロイの設定を追加せずに済みます。常時起動のサーバーを持つ必要がなく、呼び出された時だけ実行されるのでコスト面でも小規模アプリに向いています。

今回実装した Cloud Functions は2つです。

  • getRouteInfo:発地・着地のバス停 ID を受け取り、対応するバス路線の時刻表 URL・接近情報 URL を返す
  • getBusMapUrl:路線図 PDF のページをスクレイピングし、常に最新の PDF URL を返す

市営バスと民営バスで処理を分けている理由

BusTime では、バス路線の種別によってタップ時の動作が異なります。この切り替えを担うのが、Firestore の bookmarks コレクションにある site フィールドです。

  • 市営バス(site = "市営バス":時刻表ページの URL が「バス停 ID + その時点の日付・時刻」を組み合わせた動的な形式になっています。固定 URL を登録しておくだけでは常に「今この瞬間」のバスを表示できないため、Cloud Functions でタップした瞬間に URL を生成します。
  • 民営バス(それ以外):各社のバス時刻表ページは URL が固定されているため、あらかじめ bookmarks.url フィールドに登録した URL をそのまま開きます。Cloud Functions の呼び出しは不要です。

フロントエンドのコードでは if (bookmark.site === "市営バス") の条件分岐1行でこの切り替えを実現しています。市営バスのみ Cloud Functions が必要で、民営バスには不要——この差がシステム設計のシンプルさにつながっています。

3段キャッシュの全体フロー(おさらい)

前回も触れましたが、Cloud Functions はキャッシュが完全に切れた場合だけ呼び出される設計です。フロントエンドは以下の順にキャッシュを確認します。

  • 1段目:localStorage(TTL 7日)→ ヒットすればそのまま使用
  • 2段目:Firestore busRouteCache/{startId}_{endId}(TTL 7日)→ ヒットすれば localStorage にも保存して返却
  • 3段目:Cloud Functions getRouteInfo→ スクレイピングを実行して Firestore・localStorage に書き込み

Firestore をキャッシュ層にすることで、デバイスをまたいでも同じキャッシュを参照でき、スクレイピングの実行回数を最小限に抑えられます。

getRouteInfo の実装詳細

1. Firestore キャッシュを確認する

課題:Cloud Functions 内でも毎回スクレイピングを実行するとレスポンスが遅い。
解決策:関数の冒頭で Firestore の busRouteCache を確認し、7日以内のキャッシュがあれば即返却する。

const cacheRef = doc(db, "busRouteCache", `${startId}_${endId}`);
const cacheSnap = await getDoc(cacheRef);
if (cacheSnap.exists()) {
  const { courses, cachedAt } = cacheSnap.data();
  const age = Date.now() - cachedAt.toMillis();
  if (age < 7 * 24 * 60 * 60 * 1000) return courses; // 7日以内ならキャッシュを返す
}

forceRefresh=true が渡された場合はこのチェックをスキップし、強制的に再取得します。UI から「更新」ボタンを押したときに使います。

2. HTML スクレイピング(2ページ並列フェッチ)

⚠️ ご注意:スクレイピングは対象サービスの利用規約で禁止されている場合があります。実装前に必ず対象サービスの利用規約を確認してください。本アプリは個人・プライベート利用を前提とした実装であり、商用利用や大量リクエストには適していません。

課題:CORS を回避しつつ、発地・着地の2ページから路線情報を収集する必要がある。
解決策:Node.js の https モジュールでサーバーサイドフェッチし、Promise.all で並列実行する。

// 発地・着地のバス停情報ページを並列フェッチ
const [startHtml, endHtml] = await Promise.all([
  fetchHtml(`https://YOUR_HOST/diagram/BusCourseSearch?busstopId=${startId}`),
  fetchHtml(`https://YOUR_HOST/diagram/BusCourseSearch?busstopId=${endId}`),
]);

外部パーサーライブラリは使わず、正規表現と文字列操作だけで HTML を解析しています。依存パッケージを増やさないことで、Cold Start の時間とバンドルサイズを抑えています。

3. 3ケースで方向を判定する

課題:発地のバス停情報ページには「発地を通るすべての路線」が列挙されるため、着地方向に向かう路線だけを絞り込む必要がある。
解決策:3つのケースに分けて方向を判定する。

  • Case 1 — 着地ページにも登場するコース:発地・着地の両ページに共通する courseId を見つけ、着地側の stopNo(停車順)を参照して発地→着地の向きか確認する
  • Case 2 — 着地が終点のコース:終点のバス停はバス停検索ページに出てこないため、発地ラベルが「{着地名}ゆき」で終わるものを終点方向のコースと判定する
  • Case 3 — 発地が起点・着地が中間駅のコース:停車パターンのテキスト(「A⇒B⇒C⇒…」形式)を取得し、発地名が着地名より前に現れるかで方向を判定する

バス停情報ページの HTML 構造が複雑なため、このような多段判定になっています。3ケースを順に確認することで、ほぼすべての路線パターンに対応できました。

4. 結果を集約してキャッシュに書き込む

方向判定を通過したコースを集約し、同じのりば・同じ系統番号のコースを1件にまとめます。複数の行き先がある場合はラベルを結合して「41 A駅前・B車庫前ゆき」のような形式にしています。

最終的な戻り値の型は以下の通りです。

type RouteInfoCourse = {
  label: string;        // 系統番号 + 行き先
  timetableUrl: string; // 時刻表ページ URL
  approachUrl: string;  // 接近情報ページ URL
};

結果が出たら Firestore の busRouteCache/{startId}_{endId} に書き込みます。cachedAt にはサーバータイムスタンプを使うことで、クライアントの時計ズレに影響されない TTL 管理を実現しています。

await setDoc(cacheRef, {
  courses,
  cachedAt: FieldValue.serverTimestamp(),
});

getBusMapUrl:路線図 PDF の URL を常に最新に保つ

路線図の PDF は定期的に URL が更新されるため、こちらはキャッシュを設けず毎回フェッチしています。実装はシンプルで、公式ページの HTML を取得し正規表現で PDF の相対パスを抽出するだけです。

const html = await fetchHtml("https://YOUR_HOST/bus/norikata/busmap.html");
const match = html.match(/busmap\.files\/[^\s"'<>]+\.pdf/);
return match ? `https://YOUR_HOST/bus/norikata/${match[0]}` : null;

フロントエンドの「便利なリンク集」タブでこの関数を呼び出すことで、アプリ側のコードを変更することなく常に最新の PDF を参照できます。

まとめ:Cloud Functions でスクレイピングを運用するうえでのポイント

BusTime の Cloud Functions 実装を振り返ると、以下のポイントが重要でした。

  • Cloud Functions はサーバーレスのため CORS 回避とインフラ管理ゼロを両立できる
  • 外部パーサーライブラリを使わず Node.js の https モジュール + 正規表現だけで実装すると、依存を最小限に抑えられる
  • Cloud Functions 内でも Firestore キャッシュを確認することで、スクレイピングの実行回数をさらに削減できる
  • HTML 構造が複雑なスクレイピングはケース分けで対応し、各ケースを独立した関数として切り出すとテストしやすい
  • キャッシュの TTL 管理にはサーバータイムスタンプを使うと、クライアントの時計ズレの影響を受けない
  • 定期的に URL が変わるリソースはキャッシュしないシンプルな設計が有効なケースもある

3回にわたって BusTime の設計と実装を紹介しました。React + Firebase の組み合わせは、個人〜家族向けのプライベート PWA を素早く立ち上げるうえで非常に相性が良いと感じています。同じ構成を検討している方の参考になれば幸いです。