毎晩 LLM にライブ情報を集めさせて学んだ、出力の揺れとの付き合い方
Claude Code でライブ情報を毎晩収集する仕組みと、日によって変わる出力を安定して扱うために試したこと
サンドーム福井 コンサート・ライブ情報というサイトを運営しています。 公演の予定と、チケットの先行・抽選の受付期間を毎晩自動で集めて掲載しています。
情報を集めているのは、スクレイパーではなく Claude Code のヘッドレスモードです。
GitHub Actions の cron から毎日 claude -p を叩き、日本語で書いた指示書を渡して WebSearch と WebFetch で調べさせています。
作ってみて分かったのは、明らかな間違いよりも、日によって出力が少しずつ変わることのほうが扱いにくいということでした。この記事では、実際に起きた問題と、その対処をまとめます。
このサイトを作った経緯と全体像は、LT で話した資料にまとめています。先に目を通すと、以降の話が追いやすいと思います。
仕組み
全体の流れは次のとおりです。
GitHub Actions (毎日 JST 17:00)
└─ claude -p collector/prompt.md ← 調査手順を日本語で書いたファイル
WebSearch / WebFetch で調べて events.json を書く
└─ validate.mjs → POST /api/ingest
└─ Cloudflare Workers + D1 → 一覧・詳細ページ / RSS
収集ロジックはコードではなく Markdown で書いています。「会場の公式カレンダーを検索する」「公演ごとにチケット受付を逆引きする」「特設ページを見つけたら本文を読む」といった手順だけを日本語で指定し、検索する順番は Claude Code に任せています。for 文やリトライ処理はありません。
DB への書き込み経路は POST /api/ingest の 1 本だけにしてあります。これが後で効いてきます。
同じ公演が 2 件に増える
最初に起きたのは、同じ公演が DB に 2 件並ぶ現象でした。
FANTASTICS
FANTASTICS from EXILE TRIBE
どちらも情報源に実在する表記です。LLM は嘘をついていません。 ある晩は公式サイトの短い方を、別の晩はニュース記事の正式名称を拾ってきただけです。
問題は、こちら側がアーティスト名を識別子の一部に使っていたことでした。名前が変われば ID が変わり、別の公演として増えてしまいます。
そこで、識別子に名前を使うのをやめました。
const eventId = `ev-${ev.date}`;
サンドーム福井はホールが 1 つしかなく、同じ日に別の公演が並ぶことはありません。そのため、日付だけで公演を特定できます。
ev-2026-10-11 という ID にしてしまえば、名前がどう揺れても書き込み先は同じ 1 行です。
会場の条件を利用した設計なので、複数のホールを持つ施設では同じ方法を使えません。ただ、LLM に ID を作らせないこと自体は、ほかの用途でも役立つ考え方だと思います。
チケットの受付(FC 先行、一般発売など)は 1 公演に複数ぶら下がるので、日付だけでは足りません。 ここは名前を使わざるを得ないので、正規化してからハッシュを取っています。
function normalizeKey(s: string): string {
return s.normalize("NFKC").toLowerCase().replace(/\s+/g, "");
}
NFKC で全角・半角をそろえ、小文字にして空白を取り除いてからハッシュ化します。
"FC先行(抽選)" → "fc先行(抽選)" c769a638
"FC先行(抽選)" → "fc先行(抽選)" c769a638
"fc 先行(抽選)" → "fc先行(抽選)" c769a638
3 通りの表記が同じ ID に着地します。
さらに、情報源が受付名そのものを書き換えるケースもありました。
NF member 一次抽選 が 全ステイタス対象1次抽選受付 に変わる、というものです。
これは正規化では吸収できないので、受付期間が完全に一致する既存行があれば、同じ申込とみなして ID を引き継ぐようにしています。
名称と期間が同時に変わることは少ないため、期間を照合にも使うようにしました。
昨日あった期間が、今日は消える
次に起きたのは、もっと厄介でした。昨日まで表示されていた受付期間が消えるのです。
1 晩目 LLM「一次抽選 4/24 12:00 〜 5/6 23:59」 → DB に入る
2 晩目 LLM「一次抽選」(期間は null) → 上書きされて消える
3 晩目 LLM「一次抽選 4/24 12:00 〜 5/6 23:59」 → 復活
LLM が嘘をついたわけではなく、その晩はページを読み切れなかっただけです。 でも素直に UPDATE すると、せっかく取れていた情報を自分で消してしまいます。
ここでは、null を「受付期間がなくなった」ではなく「今夜は確認できなかった」と解釈する必要があります。そのため、null では既存の値を上書きしません。
starts_at: l.starts_at ?? old?.starts_at ?? null,
同じ考え方をほかの項目にも適用し、情報が増える方向にだけ状態を更新するルールにしました。
| 対象 | 許す | 禁じる |
|---|---|---|
| 期間・URL | null → 値あり |
値あり → null |
| 情報の確度 | 推定 → 公式 | 公式 → 推定 |
| 売り切れ | 販売中 → 売り切れ | 売り切れ → 販売中 |
| 行の存在 | 出現したら追加 | 1 回消えただけでは削除しない |
爪車のラチェットのように、一方向にだけ進むイメージです。
LLM の出力に合わせて DB も毎日書き換えると、前日に取れた情報が翌日に消え、また復活する動きを繰り返します。更新する方向を限定すれば、情報を多く取得できた日の結果だけを残せます。
この方法にも欠点はあります。売り切れ後の再販には追従できず、情報源の訂正で確度が下がった場合にも対応できません。このサイトでは、古い情報が残ることより、確認済みの情報が消えることを避けたいと考えて採用しました。在庫が頻繁に変わるサービスなら、別の設計が必要です。
1 回の取りこぼしが「新規発見」として通知される
更新は RSS で配信していて、Slack で購読しています。 運用を始めると、毎晩 30〜95 件もの通知が発生しました。
原因のひとつは、収集結果が毎晩少しだけ揺れることでした。 20 件取れる夜と 19 件の夜があり、収集に現れなかった行をその場で削除すると、 翌晩また見つかって「新規発見」として通知が飛びます。同じ情報が何度も新着として扱われていました。
そこで削除にもラチェットを入れました。1 回消えても削除せず、2 回連続で消えたときだけ削除します。
if (row.missed_count >= 1) {
// 2 回連続で現れなかったので削除
} else {
// missed_count を +1 して様子を見る
}
さらに、行を削除しても内容のハッシュだけは残すようにしました。 これがないと、削除された行が後日復活したときに「前回の記録がない」から「新規追加」と判定されてしまいます。
データの更新と通知を分ける
削除の問題を直しても、通知はまだ多いままでした。データを最新に保つことと、その変化を利用者に知らせることは、分けて考える必要がありました。
差分の検知には内容のハッシュを使っています。重要なのはハッシュに何を入れるかでした。
const hash = sha256(JSON.stringify([starts_at, ends_at, confidence, sold_out]));
ハッシュに含めるのは 4 項目だけで、名前と URL は入れていません。
受付名が NF member 一次抽選 から 全ステイタス対象1次抽選受付 に変わっても、
申し込む先も期限も同じなら、購読者には何のニュースでもないからです。
名前や URL は通知せずに、DB とサイト表示だけ最新化します。
加えて、差分があっても通知しない条件を足しました。
- 過去公演の受付(もう申し込めない)
- 締切を過ぎた受付(同上)
- 期間が全く不明な受付(行動できないし、表記が揺れやすくノイズ源になる)
基準は「通知を受け取った人が、今から申し込めるか」です。条件から外れてもデータは更新し、通知だけを止めます。
結果、通知は毎晩 30〜95 件から 2 件程度になりました。
この経験から、取り込みは貪欲に、通知はけちにという方針にしました。収集時は候補を広く拾い、通知時は利用者が行動できる情報だけに絞ります。
購読者全員に誤通知を飛ばした
「売り切れ」フラグを差分検知の材料に後から追加したとき、全行のハッシュが一斉に変わりました。 システムから見ればすべての行が「更新された」ように見えるので、購読者全員に一斉に誤通知が飛びました。
いまはハッシュにバージョンの接頭辞を付けて、バージョンが違う行は移行扱いとして通知しないようにしています。
const HASH_VERSION = "v3";
// → "v3:57f2dd29e10bcf91..."
コードには「ハッシュの材料を変更したら必ずこの値を上げること」という警告コメントを置き、 古いバージョンのハッシュを仕込んで通知が飛ばないことを確認するテストも書きました。
差分検知にハッシュを使う場合は、計算に使う項目の変更もデータ移行として扱う必要があります。
欠けている場所を、アプリ自身に答えさせる
最後に、調査対象の決め方についてです。1 晩ですべての公演を詳しく調べるのは無理があります。 検索の予算には上限があるので、1 公演あたり 2〜3 回の検索に収めると、 チケット特設ページの奥まで読み込む余力が残りません。
そこで収集を 2 段階に分けました。
- 広く浅く。全公演をひととおり収集する
- 狭く深く。受付期間が埋まらなかった公演を、アーティスト単位で集中調査する
2 段階目の対象は、GET /api/missing という API から取得します。DB のどこに情報が足りないかをアプリ側で判断し、その結果を返します。
{
"events": [
{
"artist": "あいみょん",
"date": "2027-02-13",
"missing_lotteries": ["AIM会員優先予約(抽選)", "..."]
}
]
}
これを受け取ったスクリプトが、アーティストごとに専用のプロンプトを組み立てて claude -p を起動します。
次に調べる対象を人がリストで管理するのではなく、DB の状態から毎晩作る仕組みです。
この仕組みは、前述のラチェットと組み合わせて使います。
もし上書き方式だったら、せっかく埋めた期間が翌晩の浅い収集で消えて、また /api/missing に載ります。
同じ公演を何度も調べることになります。情報を増やす方向にだけ更新することで、調査を重ねるたびに未確認の項目が減っていきます。
作ってみて分かったこと
LLM を組み込んだシステムでは、同じ入力でも出力が毎回変わる前提で、受け取り側を作る必要がありました。識別子を LLM に作らせないこと、状態の更新方向を制限すること、データの更新と通知を分けること。今回は、この 3 点が特に有効でした。
一方で、受け取り側で揺れを処理できれば、収集手順そのものは Markdown で柔軟に変更できます。コードを増やさずに調査方法を試せる点は、この構成の使いやすいところです。
ソースコードは GitHub で公開しています。 サイトの全体像は Works のサンドーム福井 ライブ情報 にまとめています。