はじめに
物件ウォッチ という、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_prevalence は properties.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 / 月 で回す設計。
実物のアウトプット:
- LP: https://bukken-watch.jp/
- 月次レポート一覧: https://bukken-watch.jp/monthly/
- LINE 友達追加: https://line.me/R/ti/p/@306pnyfc