はじめに
趣味の競馬を自動化したくて、JRAの重賞・メインレースをAIが全頭分析して予想印を出すWebサービスを作りました。
生涯収支がマイナスなので、AIに助けてもらおうという動機です。
技術スタック
| 用途 | 技術 |
|---|---|
| フレームワーク | Next.js 15 App Router (TypeScript) |
| ホスティング | Vercel(maxDuration=300s) |
| DB | Supabase(PostgreSQL + RLS) |
| AI分析 | OpenAI gpt-5-nano(reasoning_effort: low) |
| Web検索 | gpt-4o-search-preview(3並列) |
システム構成
出走表・オッズ・馬歴・調教・騎手成績を取得
↓
Web検索(展開予想・調教評判・コース傾向)× 3並列
↓
OpenAI に全データを渡して全頭分析
↓
Supabase に保存
↓
Next.js で表示
AI分析の実装
取得したデータを全部まとめてOpenAIに渡し、JSON形式で予想を返させます。
const userPrompt = `
【レース情報】
レース名: ${race.name}
開催場: ${race.venue}(${turnDirection})
距離: ${race.surface}${race.distance}m
【出走馬一覧】
${entriesText}
【最新ウェブ情報】
${webInfo}
以下のJSON形式で回答してください:
{
"race_comment": "レース全体の分析(400文字程度)",
"horse_scores": [
{
"horse_name": "馬名",
"horse_number": 馬番,
"ai_score": 0〜100,
"mark": "◎か○か▲か△か☆か×か-",
"comment": "評価コメント(100〜160文字)",
"is_ana_pick": true/false
}
]
}
`
予想印のルール
| 印 | 頭数 | 条件 |
|---|---|---|
| ◎ | 必ず1頭 | 本命 |
| ○ | 必ず1頭 | 対抗 |
| ▲ | 必ず1頭 | 単穴 |
| △ | 1〜2頭 | 連下 |
| ☆ | 0〜1頭 | 穴馬(4番人気以内は不可) |
| × | 0〜2頭 | 抑え |
☆は4番人気以内に絶対つけないというルールをプロンプトで強制し、AIが出力したあとにもコードで検証しています。
parsed.horse_scores.forEach((h) => {
if (h.mark === '☆') {
const pop = entryMap.get(h.horse_number)?.popularity
if (hasPopularityData && pop !== null && pop <= 4) {
h.mark = h.ai_score >= 60 ? '△' : '×'
h.is_ana_pick = false
}
}
})
gpt-5-nanoあるある対策
gpt-5-nanoはコストが安い反面、JSON出力が不安定なことがあります。実際に遭遇したケースと対策です。
英単語で数値を出力する
"ai_score": sixty // ← これが来る
const WORD_TO_NUM: Record<string, number> = {
zero:0, one:1, ..., sixty:60, seventy:70, ...
}
jsonText = jsonText.replace(
/("(?:ai_score|ana_score|horse_number)"\s*:\s*)([a-zA-Z]+)/g,
(_, prefix, word) => {
const val = word.toLowerCase().split('-')
.reduce((sum, p) => sum + (WORD_TO_NUM[p] ?? 0), 0)
return `${prefix}${val || 0}`
}
)
キー名にスペースが入る
" horse_number" // ← 先頭にスペース
for (const key of Object.keys(raw)) {
const trimmed = key.trim()
if (trimmed !== key) {
raw[trimmed] = raw[key]
delete raw[key]
}
}
hourse_number とタイポする
if (raw.hourse_number !== undefined && raw.horse_number === undefined) {
raw.horse_number = raw.hourse_number
delete raw.hourse_number
}
Web検索を3並列で実行
gpt-4o-search-previewで以下の3クエリを並列実行し、最新情報を補完しています。
const queries = [
`${raceName} ${year}年 展開予想 ペース予想 馬場傾向 競馬`,
`${raceName} ${year}年 ${topHorses} 調教 状態 仕上がり 競馬`,
`${venue} ${course} コース傾向 有利 脚質 データ 競馬`,
]
const results = await Promise.allSettled(queries.map((q) => webSearch(q)))
まとめ
競馬 × AIは思ったより相性がよくて、データドリブンな分析を自動化できました。
3/14(土)の中山・中京・阪神、3競馬場の10R〜12R・合計9レースの予想をすでに出しているので、結果が楽しみです。
コードはまだ整理中ですが、興味があれば見てみてください。
AIは競馬で人間に勝てるのか。この実験はまだ始まったばかりです。