0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LINEボットで15サイトのWebスクレイピングを安定運用する設計

0
Posted at

はじめに

中古車の新着情報を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サイトのスクレイピングを安定運用するために入れた設計:

  1. Content Hash — 変更なしならスキップ
  2. ステートマシン — 1ページずつ進め、状態をDB永続化
  3. Safety Valve — 大量検知は異常とみなし通知しない
  4. is_baseline — ベースライン/新着を明確に分離
  5. エンジン切り替え — normal/Playwrightの自動フォールバック
  6. ブロック検知 — Cloudflare等のパターンマッチ
  7. Cross-Site Dedup — サイト間の重複通知を防止
  8. ページネーション抽象化 — サイト差異を吸収
  9. 連続エラーリカバリ — 自動でcursorリセット

個人開発でもここまでやらないと、ユーザーに使い続けてもらえるサービスにはなりません。スクレイピングは「動かす」のは簡単ですが、「壊れないように運用する」のが本当の勝負です。


この仕組みで動いているサービスが中古車ウォッチです。15サイトの新着車両をAI分析付きでLINEに通知します。同じアーキテクチャの不動産版物件ウォッチもあります。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?