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?

不動産月次相場レポートを Claude Opus + web_search で自動生成する設計

0
Posted at

はじめに

物件ウォッチ という、SUUMO・athome・LIFULL HOME'S・楽待・健美家など複数の不動産サイトの新着物件を巡回して LINE 通知するサービスを個人開発しています。

このサービスでは、エリア×カテゴリ別(例: 札幌市中央区 × 土地、渋谷区 × マンション)の月次相場レポートを Claude Opus + web_search で自動生成し、SEO ページとして公開、登録ユーザーに LINE で通知するパイプラインを GitHub Actions + Supabase + Anthropic API で組んでいます。

不動産ドメインで自動生成するときの「単純に LLM 呼んだだけ」では足りないところ:

  • 築年帯の事実拘束: 1981年6月=旧耐震/2000年4月=品確法/2009年=長期優良住宅/2017年=住宅セーフティネット改正、といった法律で固定された境界を LLM に勝手に動かされると記事の信頼が崩れる
  • 公示地価・路線価との整合: 「相場が上がっている」と LLM が書くなら国交省の公示地価ベースで裏取りが要る
  • near-duplicate penalty 回避: 「マンション × 中央区」を毎月同じ構造で書けば同一ページ判定を食らう。前月比による視点シフトが必要

これらを盛り込んだパイプラインの全体像を、コードと数値ベースで解説します。

全体アーキテクチャ

GitHub Actions (cron: JST 04:00 on 1st)
    ↓
run-monthly.ts
    ↓
[per (area_code × category) slug]
    Supabase properties → aggregateArea() → AggregatedStats
        ↓
    data/{slug}-stats-{prev YYYY-MM}.json (前月)
        ↓
    attachMonthlyComparison() → monthly_comparison + notable_changes
        ↓
    Step1: Area Profile (1日1回キャッシュ, Claude Opus + web_search)
    Step2: Article Generation (Claude Opus + web_search, streaming, 6500-8500字)
        ↓
    validate-article.ts (fail-closed gate)
        ↓
    HTML render + JSON-LD (XSS escape)
        ↓
    saveStatsToArchive() → data/{slug}-stats-{YYYY-MM}.json
        ↓
    git commit (per slug)

[全 slug 完了後]
    buildManifest() → docs/monthly/manifest.json
    git commit + git pull --rebase + git push
        ↓
    Railway redeploys
        ↓
    src/jobs/monthly-report-notify.ts (monitor.ts ごと, JST 09-11)
    manifest.json を読み per-slug dedup + batched delivery
        ↓
    LINE push notification

スタック:

  • Node.js 20 / TypeScript
  • Supabase (Postgres)
  • Anthropic API (Claude Opus 4.7) + web_search ツール
  • GitHub Actions (生成・公開)
  • Railway (LINE 通知ジョブの実行環境)
  • LINE Messaging API

ドメインデータ集計

properties テーブル(過去30日の物件掲載データ)から、エリア×カテゴリごとに集計します。LLM には集計済みの数値だけを渡すのがポイント。LLM に統計を作らせると平気で嘘を吐くので、SQL とコードで確定させる。

interface AggregatedStats {
  meta: {
    slug: string;
    area_name: string;
    category: "投資" | "マンション" | "戸建て" | "土地";
    total_records: number;
    active_listings: number;
    generated_at: string;
    window_start: string;
    window_end: string;
  };
  scope: { covered_sites: string[]; time_window: [string, string]; ... };
  by_era: BucketStat[];          // 築年帯別 (旧耐震 / 1981-1999 / 2000-2009 / 2010-2019 / 2020-)
  by_layout: BucketStat[];       // 間取り別 (1K/1LDK/2LDK/3LDK/...)
  by_structure: BucketStat[];    // 構造別 (RC造 / SRC造 / S造 / 木造)
  by_yield_band?: BucketStat[];  // 利回り帯別 (投資カテゴリのみ)
  by_station_walk: BucketStat[]; // 駅徒歩帯別 (徒歩5分以内 / 6-10分 / ...)
  price_distribution: {
    all_records: { p5, q1, median_man, q3, p95, mean };
  };
  outlier_aggregate: {
    bottom_pct: { price_cutoff_man };
    top_pct:    { price_cutoff_man };
  };
  risk_flag_prevalence: {
    旧耐震:   { count, pct };
    再建築不可: { count, pct };
    借地権:   { count, pct };
    告知事項: { count, pct };
  };
  era_boundaries: {
    "1981": "新耐震基準施行";
    "2000": "品確法施行";
    "2009": "長期優良住宅認定制度";
    "2017": "住宅セーフティネット改正";
  };
  monthly_comparison?: MonthlyComparison;  // 前月比
}

risk_flag_prevalenceproperties.content_json.raw_text 内のキーワード正規表現マッチで集計(旧耐震 / 再建築不可 / 借地権 / 告知事項あり 等)。

M-o-M (前月比) 差別化メカニズム

課題

「マンション × 中央区」を毎月 6500字で書かせると、同じ構造(相場概要・築年帯・間取り別・FAQ)が繰り返される。Google の near-duplicate 判定リスクが上がる。

解決: 前月の集計を archive して diff を取る

// scripts/market-report/lib/stats-archive.ts
export function saveStatsToArchive(slug: string, stats: AggregatedStats, date: string): string {
  ensureArchiveDir();
  const cleanStats: AggregatedStats = { ...stats };
  // 自分の monthly_comparison は保存しない (来月から見ると入力データ汚染になる)
  delete cleanStats.monthly_comparison;
  // YYYY-MM-DD ではなく YYYY-MM。同月再実行で上書き → 1ヶ月1スナップショット
  const yearMonth = date.slice(0, 7);
  const filename = `${slug}-stats-${yearMonth}.json`;
  fs.writeFileSync(path.join(ARCHIVE_DIR, filename), JSON.stringify(cleanStats, null, 2));
  return path.join(ARCHIVE_DIR, filename);
}

slug は ${areaCode}-${categorySlug} (例: 13104-mansion, 01101-toshi) なので、parser regex は (.+) greedy:

const m = filename.match(
  /^(.+)-stats-(\d{4}-\d{2}(?:-\d{2})?)\.json$/
);

不動産ドメインでの impact_score 計算

各バケット (築年帯・間取り・構造・利回り帯・駅徒歩帯) を前月→当月で対応付け、new / disappeared / surge / drop / continuing に分類:

const countImpact = Math.abs(n_delta_pct ?? 0) * 50;
const priceImpact = Math.abs(med_delta_pct ?? 0) * 80;
const sizeWeight  = Math.log10(Math.max(now_n, prev_n, 1) + 1) * 5;
const impact_score = Math.round(countImpact + priceImpact + sizeWeight);

risk_flag_prevalence の前月比は pct_delta >= 1.0pt で notable に昇格:

return flags.map((f) => ({
  flag: String(f),
  prev_pct: prev[f]?.pct ?? 0,
  now_pct: current[f]?.pct ?? 0,
  pct_delta: Math.round((current[f].pct - prev[f].pct) * 10) / 10,
})).filter((r) => Math.abs(r.pct_delta) >= 1.0);

LLM プロンプトへの注入

## 今月の最重要トピック (前月比)
1. ㎡単価中央値が前月比 -3.2% (84万円/㎡ → 81万円/㎡)
2. 築年帯「2010-2019」の件数が 156→89件に減少 (-43%)
3. 旧耐震物件比率が 14.5% → 11.2% へ -3.3pt 縮小

これらの変化を H1 タイトルと TL;DR で foreground せよ。
本文4章「今月のトップ3変化(前月比)」セクションを必ず設けよ。

結果、月ごとに H1 が変化:

  • 2026年5月: "中央区マンション、㎡単価3.2%下落 — 築15年帯の供給絞り込みが始まる"
  • 2026年6月: "中央区マンション、旧耐震物件が過去最低比率に — 建替え需要が顕在化"

Claude Opus による記事生成

2ステップに分離している。両ステップとも web_search を使う点が中古車パイプラインとの違い。

Step 1: Area Profile (1日1回キャッシュ, web_search 5回)

エリア固有の事実情報(人口動態・交通・再開発・地価動向・競合エリア比較等)を Claude Opus + web_search で構造化 JSON として取得:

import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();

const response = await client.messages.create({
  model: "claude-opus-4-7",
  max_tokens: 8000,
  tools: [{
    type: "web_search_20250305",
    name: "web_search",
    max_uses: 5,
  }],
  messages: [{ role: "user", content: profilePrompt(areaName, category) }],
});

profile はエリア×カテゴリ別 JSON にキャッシュし、月次バッチでは再取得しない。

Step 2: Article Generation (毎月1回, streaming + web_search)

stats + profile + monthly_comparison を入力に、Markdown 6500-8500字を生成。article 段階でも web_search を有効にすることで、相場の解説に対して国交省の公示地価・路線価ベースで裏取りができる:

const stream = client.messages.stream({
  model: "claude-opus-4-7",
  max_tokens: 16000,
  tools: [{
    type: "web_search_20250305",
    name: "web_search",
    max_uses: 10,
  }],
  messages: [{ role: "user", content: articlePrompt({ areaName, category, stats, profile }) }],
});
const response = await stream.finalMessage();

非ストリーミングだと長文生成が10分超で timeout するので、stream API を使う。

プロンプトに含める項目:

  • 集計済み数値(LLM に作らせない、与える)
  • era_boundaries (1981/2000/2009/2017) — 法律で固定された境界、捏造禁止
  • 前月比 notable_changes(差別化軸)
  • 必須H2セクション一覧(TL;DR / 価格分布 / 築年帯 / 間取り / リスク要素 / FAQ等)
  • 推奨検索クエリ("渋谷区 マンション 2026 相場"、"渋谷区 公示地価 推移")
  • 禁止フレーズリスト("圧倒的人気" "今すぐ買うべき"等の煽り文)

不動産ドメイン固有の事実拘束

era_boundaries:
  1981年6月: 新耐震基準施行 (旧耐震との境界)
  2000年4月: 品確法 (10年瑕疵保証義務化)
  2009年6月: 長期優良住宅認定制度
  2017年: 住宅セーフティネット改正

これらの年は法律で固定。LLM が「築40年=旧耐震」と書くのは誤り
(1985年築なら新耐震)。建築年で判定すること。
era_boundaries の事実のみを引用し、年代を盛らない。

ここを明示しないと LLM は平気で「旧耐震基準は1980年に変わった」「品確法は1998年から」のような捏造をする。

Fail-closed Validation Gate

LLM 出力は時々:

  • 必須セクションが欠落
  • 禁止フレーズ混入
  • ${variable} がそのまま漏出
  • 「集計対象 12サイト」と書くべきところ「9サイト」と捏造
  • 年代の境界 (1981/2000/2009) を書き間違える
const validation = validateArticle(articleResult.markdown, stats);
if (validation.errors.length > 0) {
  throw new Error(
    `validation failed (${validation.errors.length}): ${validation.errors.slice(0, 3).join("; ")}`
  );
}

tryPipeline は2回まで retry し、両方失敗ならその slug の公開を skip。Claude を2回叩くので $3-4 のロスにはなるが、年代境界を間違えた記事を本番に出すよりは明確に良い。

HTML レンダリング + JSON-LD

return `<!DOCTYPE html>
<html lang="ja">
<head>
  <title>${escapeHtml(input.title)}</title>
  <meta name="description" content="${escapeHtml(input.description)}">
  ...
</head>
<body>
  ${input.bodyHtml}

  <script type="application/ld+json">
${JSON.stringify(input.articleSchema, null, 2).replace(/</g, "\\u003c")}
  </script>
</body>
</html>`;

.replace(/</g, "\\u003c") がポイント。LLM 生成の見出しに </script> という文字列が混入すると script ブロックを早期終了して任意JS実行を許す(XSS)。

GitHub Actions オーケストレーション

on:
  schedule:
    - cron: "0 19 * * *"   # UTC 19:00 = JST 04:00 (翌日)
  workflow_dispatch:
    inputs:
      only:    { description: "Comma-separated slugs", required: false }
      dry_run: { type: boolean, default: false }

jobs:
  gate:
    outputs: { proceed: ${{ steps.check.outputs.proceed }} }
    steps:
      - id: check
        run: |
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            echo "proceed=true" >> "$GITHUB_OUTPUT"
          else
            JST_DAY=$(TZ=Asia/Tokyo date +%d)
            if [ "$JST_DAY" = "01" ]; then
              echo "proceed=true" >> "$GITHUB_OUTPUT"
            fi
          fi

  generate:
    needs: gate
    if: needs.gate.outputs.proceed == 'true'
    timeout-minutes: 120
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: npm ci
      - run: npx ts-node scripts/market-report/run-monthly.ts

並行 push の race-mitigation

別の cron (hot-deals 6時間ごとの再生成) が同じ window で push する可能性があるため、最後の push 前に rebase:

try {
  git("pull", "--rebase", "origin", currentBranch);
} catch (err) {
  try { git("rebase", "--abort"); } catch {}
  throw new Error(`pull --rebase failed: ${err.message}`);
}
git("push", "origin", currentBranch);

LINE 通知: manifest.json + per-slug dedup + batched delivery

公開後、登録ユーザーへ「あなたが監視中エリアの月次相場レポートが公開されました」と LINE 通知。マニフェスト方式で、Railway 側で DB を再クエリせず、リポジトリにコミットされた docs/monthly/manifest.json を読みます:

// scripts/market-report/run-monthly.ts (生成側)
const manifest = {
  generated_at: new Date().toISOString(),
  date: opts.today,
  reports: results.filter(r => r.success).map(r => ({
    slug: r.slug,
    area_code: r.area_code,
    area_name: r.area_name,
    category: r.category,
    url: r.url,
    headline: r.headline,
    summary: { total_records, median_man, median_change_pct, key_change_topic },
  })),
};
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
// src/jobs/monthly-report-notify.ts
// monitor.ts ごと15分間隔で起動、JST 09-11 ゲート
export async function notifyMonthlyReports(): Promise<void> {
  if (!isInNotifyWindow()) return;
  if (!fs.existsSync(MANIFEST_PATH)) return;
  const manifest: Manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8"));

  // 同月チェック (workflow が 1日に走った後 4日に notify が遅延でも届くように緩和)
  const today = todayJst();
  if (manifest.date.slice(0, 7) !== today.slice(0, 7)) return;

  const yearMonth = manifest.date.slice(0, 7);
  const userPending = new Map<string, PendingReport[]>();

  for (const report of manifest.reports) {
    const slugLabel = `monthly-${report.slug}-${yearMonth}`;
    const watchers = await findActiveWatchers(report.area_code, report.category);
    for (const w of watchers) {
      if (await alreadySent(w.line_user_id, slugLabel)) continue;
      const arr = userPending.get(w.line_user_id) || [];
      arr.push({ ...report, _label: slugLabel });
      userPending.set(w.line_user_id, arr);
    }
  }

  for (const [lineUserId, reports] of userPending) {
    // 同一ユーザーに対して N=1 なら個別、N≥2 なら全部入りバッチ
    const msg = reports.length === 1
      ? buildMonthlyMessage(reports[0])
      : buildBatchMessage(reports);
    const ok = await pushMessage(lineUserId, msg);
    if (ok) {
      // 送信成功時のみ per-slug 単位で notification_logs に記録
      for (const r of reports) {
        await recordSent(lineUserId, r._label, msg.slice(0, 500));
      }
    }
    await sleep(250);
  }
}

バッチ配信の意味

複数エリアを監視しているユーザー(投資家など)は1日に5-10通の月次通知を受け取りうる。バッチにまとめて1通の長文にすることで:

  • ユーザーの LINE が連続着信音で煩わしくならない
  • LINE Free プランの月間配信通数を節約

48h cleanup と monthly ラベル保持

notification_logs の48時間 TTL クリーンアップは monthly ラベルを除外する:

await getClient()
  .from("notification_logs")
  .delete()
  .lt("sent_at", new Date(Date.now() - 48 * 3600 * 1000).toISOString())
  .not("label", "like", "monthly-%");

これがないと月初に送信したラベルが2日後に消えて、3日後の遅延配信で全件再送してしまう。

コスト & 実数値

項目 コスト
Profile 生成 (1 slug, Opus + web_search 5回) $0.30〜0.50
Article 生成 (1 slug, Opus + web_search 10回, 30k in / 8k out) $2.00〜3.00
1 slug 1回あたり合計 $2.50〜3.50
30 slug × 月1回 $75〜105 / 月
GitHub Actions 月120分以内、無料枠

実測 dry-run: sapporo-chuo-toshi で $2.68 (web_search 8回, in 28k / out 6.4k)。

ハマりどころ

1. monthly_comparison の archive 汚染

当月 stats に monthly_comparison(前月比結果)を含めたまま archive すると、来月の「前月読み込み」でその diff も含めて読み込み、「前月比の前月比」を取って意味不明な結果になる。saveStatsToArchive で必ず delete cleanStats.monthly_comparison

2. era_boundaries の事実拘束を必ずプロンプトに入れる

LLM は「旧耐震は 1980年に〜」「品確法は 1998年に〜」のような近年の数字捏造をする。era_boundaries: { "1981": "新耐震基準施行" } を JSON で投入し、「これ以外の年代を書くな」と明示する。

3. JSON-LD XSS escape

LLM 生成 title/description に </script> 文字列が混入する可能性。.replace(/</g, "\\u003c") を必ず通す。

4. notification_logs cleanup vs monthly dedup

48h で全 logs を消すと月次 dedup label が消滅して翌月再送信。label LIKE 'monthly-%' を保護 where句 として追加。

5. manifest.date の月一致チェック緩和

最初は manifest.date === today.slice(0,10) の strict 一致で書いていたが、Cloud Run 遅延・Railway redeploy ラグで通知 window (JST 09-11) を逸して「永遠に通知されない」という事故が出た。slice(0, 7) === today.slice(0, 7) の同月一致に緩和。per-slug dedup が再送を防ぐので安全。

6. fail-closed validation の retry コスト

validation エラーで throw すると tryPipeline が retry、Claude Opus を2回叩いて $3-5 増。systematic な validation バグがあると毎月数十 slug で大損害。warning と error を明確に分け、error は欠陥のあるケースに絞る。

7. cleanup の safety floor

古い slug を消す --cleanup フラグは active set が空に陥った瞬間に LP 全消しの危険。wouldDelete > existing × 0.5 で refuse、admin 投入で復旧する設計に。

まとめ

不動産ドメインで LLM 月次記事自動生成パイプラインを組むときの非自明な点:

  • 法律で固定された境界era_boundaries として LLM に拘束情報で渡す
  • 公示地価/路線価/取引価格情報 を web_search で記事生成中に裏取り
  • 前月比の notable_changes で月ごとに視点をシフトさせる (near-duplicate 回避)
  • manifest.json ベースの notify で生成側と通知側を疎結合に
  • batched delivery で複数エリア watcher の LINE スパムを防ぐ
  • per-slug dedup で部分失敗時の重複送信を遮断

物件ウォッチでは月 30 slug 規模を $75-105 / 月 で回す設計。

実物のアウトプット:

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?