feat: 足球 LLM 预测服务初始提交
Profeto — 给 LLM 提供数据,让 LLM 预测足球比分。 核心模块: - FastAPI 后端 + PostgreSQL (SQLAlchemy async) - 多 Agent LLM 预测 (5 专家 + 终裁) - 数据采集 (bzzoiro / understat / injuries) - React 前端 (Vite + Tailwind) 包含: - 数据源抽象 (DataSource 协议 + 注册表) - Alembic 数据库迁移 - Prompt 模板 (单/多 Agent) - 核心路径单元测试
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""Understat xG 数据源。
|
||||
|
||||
迁移自旧项目 app/data/sources/understat.py,改成 async。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from src.core.http_client import get_client
|
||||
from src.data.config import FDCO_TO_UNDERSTAT, LEAGUE_NAMES
|
||||
from src.data.match_lookup import find_existing_match
|
||||
from src.data.normalize import normalize_understat
|
||||
from src.data.sources import register
|
||||
from src.db.models import League, Match, MatchStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNDERSTAT_BASE = "https://understat.com/getLeagueData/{league}/{season}"
|
||||
|
||||
|
||||
async def fetch_understat(league_code: str, season: int) -> list[dict]:
|
||||
"""抓取 understat 单赛季 xG 数据。
|
||||
|
||||
Args:
|
||||
league_code: fdco 风格代码,如 'E0'
|
||||
season: 赛季起始年,如 2025 表示 2025-2026 赛季
|
||||
|
||||
Returns:
|
||||
比赛数组,每项含 datetime/h/a/xG
|
||||
"""
|
||||
understat_league = FDCO_TO_UNDERSTAT.get(league_code)
|
||||
if understat_league is None:
|
||||
raise ValueError(f"未知联赛代码: {league_code}")
|
||||
|
||||
url = UNDERSTAT_BASE.format(league=understat_league, season=season)
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"Referer": f"https://understat.com/league/{understat_league}/{season}",
|
||||
}
|
||||
|
||||
client = get_client()
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
# understat 返回 JS 对象,需要提取 JSON
|
||||
text = resp.text
|
||||
# 匹配 var datesData = JSON.parse('...');
|
||||
match = re.search(r"var\s+datesData\s*=\s*JSON\.parse\('([^']+)'\)", text)
|
||||
if not match:
|
||||
logger.warning("understat 响应格式不符: %s...", text[:200])
|
||||
return []
|
||||
decoded = match.group(1).encode().decode("unicode_escape")
|
||||
data = json.loads(decoded)
|
||||
return data
|
||||
|
||||
|
||||
@register
|
||||
class UnderstatSource:
|
||||
"""understat xG 数据源(实现 DataSource 协议)。"""
|
||||
|
||||
name = "understat"
|
||||
|
||||
async def ingest(self, db, *, league: str, season: int) -> dict:
|
||||
"""采集 understat xG → 回填到现有 Match。只回填 xG 字段,不创建新 Match。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
result = {"updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
|
||||
|
||||
try:
|
||||
raw_matches = await fetch_understat(league, season)
|
||||
except Exception as e:
|
||||
logger.exception("understat fetch failed for %s %s", league, season)
|
||||
result["errors"].append(f"fetch failed: {e}")
|
||||
return result
|
||||
|
||||
# 查联赛
|
||||
stmt = select(League).where(League.code == league)
|
||||
league_obj = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if league_obj is None:
|
||||
result["errors"].append(f"league {league} not found in DB")
|
||||
return result
|
||||
|
||||
for raw in raw_matches:
|
||||
if not raw.get("isResult"):
|
||||
continue
|
||||
try:
|
||||
nm = normalize_understat(raw, league)
|
||||
if nm is None:
|
||||
result["skipped"] += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
result["errors"].append(f"normalize: {e}")
|
||||
continue
|
||||
|
||||
# 匹配已有 Match(天级)
|
||||
existing = await find_existing_match(db, league_obj.id, nm.home_team, nm.away_team, nm.date)
|
||||
if existing is None:
|
||||
result["unmatched"] += 1
|
||||
continue
|
||||
|
||||
# 回填 xG
|
||||
if existing.stats is None and (nm.home_xg is not None or nm.away_xg is not None):
|
||||
existing.stats = MatchStats(match_id=existing.id)
|
||||
db.add(existing.stats)
|
||||
await db.flush()
|
||||
if existing.stats is not None:
|
||||
if existing.stats.home_xg is None and nm.home_xg is not None:
|
||||
existing.stats.home_xg = nm.home_xg
|
||||
result["updated"] += 1
|
||||
if existing.stats.away_xg is None and nm.away_xg is not None:
|
||||
existing.stats.away_xg = nm.away_xg
|
||||
|
||||
await db.commit()
|
||||
return result
|
||||
Reference in New Issue
Block a user