はじめに
中古車の新着情報を15サイトから自動収集し、LINEに通知するサービスを個人開発しています。
対象サイト:
- 大手ポータル: カーセンサー、グーネット、価格.com
- 専門サイト: ネクステージ、カババ、車選びドットコム
- オークション: ヤフオク
- メーカー公式: トヨタ、ホンダ、日産、マツダ、スバル、スズキ、ダイハツ、三菱
15サイトを30分間隔で巡回し続けるのは、思った以上に「壊れやすい」仕組みです。この記事では、安定運用のために入れた設計を紹介します。
アーキテクチャ概要
Cloud Run (常駐) → WebReader API → Supabase → LINE Messaging API
↑ setInterval loop
Cloud Run上でNode.jsプロセスが常駐し、setIntervalで定期的にスキャンを回します。バッチジョブ(Cloud Run Jobs)ではなく常駐にしたのは、スキャン間隔を柔軟に制御するためです。
設計1: Content Hashで無駄なパースを省く
同じページを30分ごとにfetchしても、ほとんどの場合は中身が変わっていません。
const result = await fetchPage(url);
if (result.contentHash === lastHash) {
console.log("No changes (hash match). Skipping.");
return;
}
WebReader APIがレスポンスにcontentHashを含めるので、前回のハッシュと比較して変更がなければパース・DB問い合わせを全てスキップします。これだけでDB負荷が体感で70%以上減りました。
設計2: 巡回スキャンのステートマシン
15サイト × 車種数のURLがあり、各URLは数十ページあります。全ページを一度にfetchするのではなく、1回のループで1ページずつ進めるステートマシン方式を取っています。
monitoring_url:
scan_cursor: 現在のページ番号
scan_cycles_completed: 巡回完了回数
scan_prev_ids_hash: 前ページのリスティングIDハッシュ
- cycle 0 (ベースライン構築): 全ページを1ページずつ巡回して既存物件を登録。通知は飛ばさない
- cycle 1+ (新着検知): 巡回中に未登録の物件を発見したら新着として通知
ステートはDBに永続化されるので、プロセスが再起動しても途中から再開できます。
設計3: Safety Valve(安全弁)
スクレイピングで最も怖いのは「大量通知事故」です。
パーサーのバグ、サイトの仕様変更、HTMLの構造変更などで、既存物件が全て「新着」と誤認されるケースがあります。
const MAX_GENUINE_NEW = 10;
const isSafetyValve = newListings.length > MAX_GENUINE_NEW;
if (isSafetyValve) {
// 大量検知 → ベースライン扱い(通知しない)
await insertProperties(url.id, newListings, true);
} else {
// 正常な新着 → 通知対象
await insertProperties(url.id, newListings, false);
}
1回のスキャンで10件以上の「新着」が検知されたら、異常とみなしてベースライン扱いにします。ユーザーに通知は飛ばず、次のサイクルから正常に検知が再開されます。
設計4: is_baselineフラグによる新着判定
当初はnotified(通知済みフラグ)で新着判定をしていましたが、複数ユーザーが同じURLを共有する設計では意味が曖昧になりました。
そこでis_baselineフラグを導入:
| 挿入タイミング | is_baseline | 意味 |
|---|---|---|
| cycle 0 ベースライン構築 | true | 既存物件。通知対象外 |
| cycle 1+ 新着検知 | false | 本当の新着。通知対象 |
| safety valve発動 | true | 異常検知。通知対象外 |
通知クエリは単純に .eq("is_baseline", false) で取得するだけ。複数ユーザー間での通知タイミングはlast_notified_at(per-user timestamp)で独立に制御します。
設計5: エンジン切り替えとリトライ
サイトによってレンダリング方式が異なります:
- SSR (サーバーサイドレンダリング): 通常のHTTP GETで取得可能
- CSR (クライアントサイドレンダリング): JavaScriptを実行しないとコンテンツが表示されない
// athomeとrakumachiはJS必須
const preferPlaywright = /athome\.co\.jp|rakumachi\.jp/.test(url);
const firstEngine = preferPlaywright ? "playwright" : "normal";
const fallbackEngine = preferPlaywright ? "normal" : "playwright";
try {
const result = await fetchWithRetry(url, firstEngine);
if (isBlockedContent(result.content)) {
// ブロック検知 → エンジン切り替え
return await fetchWithRetry(url, fallbackEngine);
}
return result;
} catch {
return await fetchWithRetry(url, fallbackEngine);
}
最初に推奨エンジンで試行し、ブロックされたりエラーになったら別エンジンにフォールバック。これにより、サイト側の一時的な仕様変更にも自動で対応できます。
設計6: ブロック検知
Cloudflareやアクセス制限ページを検知するパターンマッチ:
const BLOCKED_PATTERNS = [
"アクセスができません",
"アクセスが制限されています",
"Access Denied",
"403 Forbidden",
"Just a moment", // Cloudflare
"Checking your browser", // Cloudflare
];
contentがこれらを含む場合、正常なレスポンスに見えてもブロックされていると判断してフォールバックします。
設計7: Cross-Site Dedup(サイト間重複排除)
同じ車が複数サイトに掲載されることは日常的です。カーセンサーとグーネットの両方に載っている車を2回通知したくない。
// タイトル+価格でdedup key生成
const key = `${title.replace(/\s+/g, "")}|${price.replace(/[,\s]/g, "")}`;
通知時にdedup keyで重複を検出し、1回だけ通知します。
物件ウォッチ(不動産版)ではさらに進んで、所在地+価格でDBレベルのdedup_keyカラムを持ち、insertの時点でサイト間重複を防いでいます。
設計8: ページネーションの抽象化
15サイトそれぞれページネーションの仕組みが違います:
const SITE_CONFIGS = {
carsensor: {
paginateUrl: (url, page) => appendParam(url, "page", String(page)),
},
goonet: {
paginateUrl: (url, page) => appendParam(url, "page", String(page)),
},
home4u: {
// HOME4Uはオフセット方式(パスパラメータ)
paginateUrl: (url, page) => {
if (page <= 1) return url;
return appendPathParam(url, `of=${(page - 1) * 20}`);
},
},
kenbiya: {
// 健美家はパス埋め込み方式
paginateUrl: (url, page) => {
return url.replace(/\/$/, `/n-${page}/`);
},
},
};
SiteConfigインターフェースでページネーション・ソート・チェック間隔を抽象化することで、新サイト追加時のコード変更を最小限にしています。
設計9: 連続エラーのリカバリ
特定のページで5回連続エラーが発生したら、cursorをリセットして最初からやり直します:
const consecutiveErrors = recentLogs?.every(l => l.level === "error")
&& (recentLogs?.length ?? 0) >= 5;
if (consecutiveErrors) {
// cursor を 1 にリセット
await updateScanCursor(url.id, 1);
}
サイト側の一時障害で特定ページだけ壊れるケースがあり、このリカバリがないとそのURLのスキャンが永久に止まります。
運用で学んだこと
サイト側の変更は突然来る
- ある日突然、カーセンサーのページ構造が変わってパーサーが0件を返すようになった
- マツダ公式の詳細URLパラメータの意味が変わっていて、全ページが「取り扱い終了」を表示
対策: Safety Valveと0件検知のアラート。パーサーが0件を返し続けたら自動でcycleリセットし、ログに警告を出す。
ブロックは段階的に来る
最初は正常 → たまに502 → 毎回403 → Cloudflare Challenge、という順番で締め出される。
対策: User-Agent設定、リクエスト間隔の調整、Playwright fallback。それでもダメな場合はCloudflare Workers経由のプロキシ。
データの整合性が一番怖い
コードのバグより、データの不整合のほうが影響が大きい。「全物件を新着と誤認して1000件のLINE通知が飛ぶ」は実際に起こりうるシナリオ。
対策: is_baseline/safety valve/scan cycle state machineの3層防御。
まとめ
15サイトのスクレイピングを安定運用するために入れた設計:
- Content Hash — 変更なしならスキップ
- ステートマシン — 1ページずつ進め、状態をDB永続化
- Safety Valve — 大量検知は異常とみなし通知しない
- is_baseline — ベースライン/新着を明確に分離
- エンジン切り替え — normal/Playwrightの自動フォールバック
- ブロック検知 — Cloudflare等のパターンマッチ
- Cross-Site Dedup — サイト間の重複通知を防止
- ページネーション抽象化 — サイト差異を吸収
- 連続エラーリカバリ — 自動でcursorリセット
個人開発でもここまでやらないと、ユーザーに使い続けてもらえるサービスにはなりません。スクレイピングは「動かす」のは簡単ですが、「壊れないように運用する」のが本当の勝負です。
この仕組みで動いているサービスが中古車ウォッチです。15サイトの新着車両をAI分析付きでLINEに通知します。同じアーキテクチャの不動産版物件ウォッチもあります。