refactor: 以 bzzoiro 为唯一数据源的全面重构

数据源统一为 bzzoiro,移除 Understat 与 injuries:
- 删除 src/data/understat.py / injuries.py 及相关测试
- 删除 injuries 模型与表;扩展 match_stats(xG 之外增加 big_chances/fouls)
- 新增 standings 表(联赛积分榜:位置/积分/xG/走势/分区)
- matches 表增加 source_event_id 血缘列,支撑统计回填

采集管线(bzzoiro 三条管线):
- events:赛程/比分(/events/),记录 source_event_id
- standings:积分榜快照(/leagues/{id}/standings/)
- stats:已完赛比赛详细统计回填(/events/{id}/stats/)

预测增强:
- standings_slice 替代 injuries_slice;积分榜专家替代阵容完整性专家
- AGENT_META runtime_config 同步更新

管理后台:
- ingest 路由重写为单一 bzzoiro 入口 + task 参数(events/standings/stats/all)
- 新增 /admin/data-completeness 数据完整性分析 API
- 数据源状态页简化为 bzzoiro 单源

前端:
- 采集页重构为任务驱动(比赛/积分榜/统计回填/全量)
- 新增「数据完整性」可视化页(覆盖率矩阵/字段完整率/健康摘要)
- 新增主站积分榜页(/standings)与比赛详情完整统计面板
- agent 名称同步更新(injuries→standings)

迁移 0015_bzzoiro_single_source 已在容器内验证通过,后端测试全部通过。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
This commit is contained in:
shangfangjian
2026-09-20 19:13:07 +08:00
co-authored by new-provider/LongCat-2.0 <
parent ec8f36abb2
commit f05dc1ae15
41 changed files with 1603 additions and 1885 deletions
+326 -64
View File
@@ -1,12 +1,15 @@
"""Bzzoiro 数据源:抓取 + 入库。
"""Bzzoiro 数据源:抓取 + 入库(单一数据源)
迁移自旧项目 app/data/sources/bzzoiro/,改成 async + 简化入库。
使用 Repository 模式进行数据访问,不直接控制事务。
三条管线:
1. events — 比赛日程/比分(/events/),含 source_event_id 血缘
2. standings— 联赛积分榜快照(/leagues/{id}/standings/)
3. stats — 已完赛比赛详细统计回填(/events/{id}/stats/)
使用 Repository 模式进行数据访问,不直接控制事务(由调用方 UnitOfWork 控制)。
"""
from __future__ import annotations
import asyncio
import json as _json
import logging
import random
from collections.abc import Iterable
@@ -22,7 +25,7 @@ from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_COUNTRIES, LEAGUE_NAMES,
from src.data.normalize import normalize_bzzoiro
from src.data.team_names_zh import zh_name
from src.data.sources import register
from src.db.models import League, Match, MatchStats, Team
from src.db.models import League, Match, MatchStats, Standing, Team
logger = logging.getLogger(__name__)
@@ -36,6 +39,16 @@ def _to_date(value):
return value
def _to_int_or_none(value) -> int | None:
"""宽松转 int(用于上游 ID 解析,失败返回 None 不抛错)。"""
if value is None:
return None
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
def _match_key(home_team_id: int, away_team_id: int, match_date) -> tuple[int, int, str]:
"""比赛去重键:(主队, 客队, 天级日期 ISO 字符串)。
@@ -262,38 +275,13 @@ class BzzoiroSource:
home_ht_goals=nm.home_ht_goals,
away_ht_goals=nm.away_ht_goals,
match_stage=nm.match_stage,
source_event_id=_to_int_or_none(raw.get("id")),
)
db.add(m)
await db.flush()
existing_matches[match_key] = m # 防止同批重复
# 存在任一统计字段即可创建 MatchStats(不再强制要求 xG)
if any(getattr(nm, f) is not None for f in ['home_xg', 'away_xg', 'home_shots', 'away_shots', 'home_shots_on_target', 'away_shots_on_target', 'home_corners', 'away_corners', 'home_possession', 'home_yellow_cards', 'away_yellow_cards', 'home_red_cards', 'away_red_cards']):
now = datetime.now(timezone.utc)
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
# 回测 cutoff 若贴着开球,不会把「完赛后才有的统计」误标为赛前可用
available_at = nm.date + timedelta(hours=2) if nm.date else now
stats = MatchStats(
match_id=m.id,
home_xg=nm.home_xg,
away_xg=nm.away_xg,
home_shots=nm.home_shots,
away_shots=nm.away_shots,
home_shots_on_target=nm.home_shots_on_target,
away_shots_on_target=nm.away_shots_on_target,
home_corners=nm.home_corners,
away_corners=nm.away_corners,
home_possession=nm.home_possession,
home_yellow_cards=nm.home_yellow_cards,
away_yellow_cards=nm.away_yellow_cards,
home_red_cards=nm.home_red_cards,
away_red_cards=nm.away_red_cards,
source="bzzoiro",
source_record_id=str(raw.get("id", "")),
retrieved_at=now,
available_at=available_at,
)
db.add(stats)
# 统计字段不在 /events/ 载荷中(单独由 stats 管线回填),
# 此处不再创建 MatchStats。
league_r["inserted"] += 1
else:
# 已有比赛: 直接从内存获取对象更新(无需再查询)
@@ -310,37 +298,11 @@ class BzzoiroSource:
if existing_match.match_stage is None and nm.match_stage:
existing_match.match_stage = nm.match_stage
changed = True
# 存在任一统计字段即可创建 MatchStats(不再强制要求 xG)
if existing_match.stats is None and (
nm.home_xg is not None or nm.away_xg is not None
or nm.home_shots is not None or nm.away_shots is not None
or nm.home_corners is not None or nm.away_corners is not None
or nm.home_possession is not None
):
now = datetime.now(timezone.utc)
# available_at 语义:统计「可被使用」的最早时间,至少不早于比赛结束
# 近似:开球 + 2 小时(实际完赛时间约为 +2 小时,非官方公布时间)
available_at = nm.date + timedelta(hours=2) if nm.date else now
existing_match.stats = MatchStats(
match_id=existing_match.id,
source="bzzoiro",
source_record_id=str(raw.get("id", "")),
retrieved_at=now,
available_at=available_at,
)
db.add(existing_match.stats)
await db.flush()
if existing_match.stats is not None:
for fld in ("home_xg", "away_xg", "home_shots", "away_shots",
"home_shots_on_target", "away_shots_on_target",
"home_corners", "away_corners", "home_possession",
"home_yellow_cards", "away_yellow_cards",
"home_red_cards", "away_red_cards"):
if getattr(existing_match.stats, fld, None) is None:
v = getattr(nm, fld, None)
if v is not None:
setattr(existing_match.stats, fld, v)
changed = True
if existing_match.source_event_id is None:
eid = _to_int_or_none(raw.get("id"))
if eid is not None:
existing_match.source_event_id = eid
changed = True
if changed:
league_r["updated"] += 1
@@ -349,3 +311,303 @@ class BzzoiroSource:
result["total_inserted"] += league_r["inserted"]
result["total_updated"] += league_r["updated"]
return result
# ============================================================
# 积分榜管线:/leagues/{id}/standings/ → standings 表
# ============================================================
async def fetch_bzzoiro_standings(league_code: str, season: str | None = None) -> dict:
"""抓取联赛积分榜(纯抓取,不入库)。season 为 None 时取当前赛季。"""
league_id = BZZOIRO_LEAGUE_IDS.get(league_code)
if league_id is None:
raise ValueError(f"未知联赛代码: {league_code}")
params: dict = {}
if season:
params["season"] = season
return await _fetch_json_async(f"/leagues/{league_id}/standings/", params)
def _season_label_from_dates(start_date, end_date) -> str:
"""从赛季起止日期推导赛季标签(与 derive_season_label 语义一致)。"""
try:
if isinstance(start_date, str):
start = datetime.fromisoformat(start_date[:10])
else:
start = start_date
y = start.year
return f"{y}-{y + 1}" if start.month >= 8 else f"{y - 1}-{y}"
except (TypeError, ValueError):
return "?"
async def ingest_bzzoiro_standings(db, *, leagues: Iterable[str], season: str | None = None) -> dict:
"""采集积分榜 → upsert standings 表。
season 为 None 时采集当前赛季(bzzoiro 默认返回 is_current 赛季)。
球队名与 events 管线使用同一 normalize 规则,保证 Team 匹配。
"""
from src.data.team_names import normalize as normalize_name
result: dict = {"leagues": {}, "total_upserted": 0, "errors": []}
for code in leagues:
league_r: dict = {"upserted": 0, "teams_created": 0, "rows": 0}
try:
payload = await fetch_bzzoiro_standings(code, season=season)
except Exception as e:
logger.exception("bzzoiro standings fetch failed for %s", code)
result["leagues"][code] = {"error": str(e)}
result["errors"].append(f"{code}: {e}")
continue
rows = payload.get("standings") or []
if not rows:
result["leagues"][code] = {"error": "无积分榜数据(赛季未开始或未提供)"}
result["errors"].append(f"{code}: 无积分榜数据")
continue
# 联赛
stmt = select(League).where(League.code == code)
league = (await db.execute(stmt)).scalar_one_or_none()
if league is None:
league = League(code=code, name=LEAGUE_NAMES.get(code, code), country=LEAGUE_COUNTRIES.get(code))
db.add(league)
await db.flush()
# 赛季标签:优先用返回的 season 对象推导
season_obj = payload.get("season") or {}
season_label = _season_label_from_dates(
season_obj.get("start_date"), season_obj.get("end_date")
)
if season_label == "?":
season_label = season or ""
# 批量预载球队
names = {normalize_name(str(r.get("team_name", ""))) for r in rows}
names.discard("")
team_map: dict[str, Team] = {}
if names:
stmt = select(Team).where(Team.name.in_(names))
for t in (await db.execute(stmt)).scalars():
team_map[t.name] = t
now = datetime.now(timezone.utc)
for r in rows:
team_name = normalize_name(str(r.get("team_name", "")))
if not team_name:
continue
team = team_map.get(team_name)
if team is None:
team = Team(name=team_name, name_zh=zh_name(team_name))
db.add(team)
await db.flush()
team_map[team_name] = team
league_r["teams_created"] += 1
zone = r.get("zone") or {}
values = dict(
position=_to_int_or_none(r.get("position")) or 0,
played=_to_int_or_none(r.get("played")) or 0,
won=_to_int_or_none(r.get("won")) or 0,
drawn=_to_int_or_none(r.get("drawn")) or 0,
lost=_to_int_or_none(r.get("lost")) or 0,
goals_for=_to_int_or_none(r.get("gf")) or 0,
goals_against=_to_int_or_none(r.get("ga")) or 0,
goal_diff=_to_int_or_none(r.get("gd")) or 0,
points=_to_int_or_none(r.get("pts")) or 0,
xg_for=_to_float_or_none(r.get("xgf")),
xg_against=_to_float_or_none(r.get("xga")),
form=r.get("form") or None,
zone=zone.get("label") or zone.get("key") or None,
updated_at=now,
retrieved_at=now,
)
stmt = select(Standing).where(
Standing.league_id == league.id,
Standing.season == season_label,
Standing.team_id == team.id,
)
standing = (await db.execute(stmt)).scalar_one_or_none()
if standing is None:
standing = Standing(
league_id=league.id, season=season_label, team_id=team.id, **values
)
db.add(standing)
else:
for k, v in values.items():
setattr(standing, k, v)
league_r["upserted"] += 1
league_r["rows"] = len(rows)
result["leagues"][code] = league_r
result["total_upserted"] += league_r["upserted"]
logger.info(
"bzzoiro standings 采集完成: %s 赛季 %s, upsert %d/%d",
code, season_label, league_r["upserted"], league_r["rows"],
)
return result
# ============================================================
# 统计回填管线:/events/{id}/stats/ → match_stats 表
# ============================================================
# bzzoiro stats 字段 → MatchStats 字段映射(stats.home / stats.away 下)
_STATS_FIELD_MAP = {
"xg": ("home_xg", "away_xg"), # 回退 expected_goals
"ball_possession": ("home_possession", None), # 只取主队值,客队=100-home
"total_shots": ("home_shots", "away_shots"),
"shots_on_target": ("home_shots_on_target", "away_shots_on_target"),
"corner_kicks": ("home_corners", "away_corners"),
"yellow_cards": ("home_yellow_cards", "away_yellow_cards"),
"red_cards": ("home_red_cards", "away_red_cards"),
"big_chances": ("home_big_chances", "away_big_chances"),
"fouls": ("home_fouls", "away_fouls"),
}
def _pick(d: dict, *keys):
"""按优先级取第一个非空字段值。"""
for k in keys:
v = d.get(k)
if v is not None:
return v
return None
def _stats_from_payload(payload: dict) -> dict:
"""把 /events/{id}/stats/ 响应映射成 MatchStats 字段 dict。
响应结构: {"event_id": ..., "stats": {"home": {...}, "away": {...}}}
"""
stats = (payload or {}).get("stats") or {}
home = stats.get("home") or {}
away = stats.get("away") or {}
out: dict = {}
xg_h = _pick(home, "xg", "expected_goals")
xg_a = _pick(away, "xg", "expected_goals")
if xg_h is not None:
out["home_xg"] = _to_float_or_none(xg_h)
if xg_a is not None:
out["away_xg"] = _to_float_or_none(xg_a)
poss = home.get("ball_possession")
if poss is not None:
p = _to_float_or_none(poss)
if p is not None:
out["home_possession"] = p
out["away_possession"] = round(100 - p, 1) if 0 <= p <= 100 else None
for src, (h_fld, a_fld) in _STATS_FIELD_MAP.items():
if src in ("xg", "ball_possession"):
continue # 已处理
hv = home.get(src)
av = away.get(src)
if hv is not None and h_fld:
out[h_fld] = _to_int_or_none(hv)
if av is not None and a_fld:
out[a_fld] = _to_int_or_none(av)
return out
def _to_float_or_none(value) -> float | None:
if value is None:
return None
try:
return float(str(value).strip())
except (TypeError, ValueError):
return None
async def ingest_bzzoiro_event_stats(
db,
*,
leagues: Iterable[str],
limit: int = 100,
only_missing: bool = True,
) -> dict:
"""回填已完赛比赛的详细统计(逐场调 /events/{id}/stats/)。
筛选条件: match_status=finished 且 source_event_id 非空。
only_missing=True 时跳过已有统计的比赛(增量);False 则全量刷新。
limit 控制单次最多处理的比赛数(上游限速 1.2s/请求,大批量需分次触发)。
"""
result: dict = {"fetched": 0, "created": 0, "updated": 0, "skipped": 0, "errors": []}
league_ids = [BZZOIRO_LEAGUE_IDS[c] for c in leagues if c in BZZOIRO_LEAGUE_IDS]
if not league_ids:
result["errors"].append("无有效联赛代码")
return result
stmt = (
select(Match)
.options(select(Match.stats))
.where(Match.match_status == "finished")
.where(Match.source_event_id.is_not(None))
.where(Match.league_id.in_(league_ids))
.order_by(Match.match_date.desc())
.limit(limit * 3 if only_missing else limit)
)
matches = (await db.execute(stmt)).scalars().all()
now = datetime.now(timezone.utc)
processed = 0
for m in matches:
if processed >= limit:
break
if only_missing and m.stats is not None and m.stats.home_shots is not None:
result["skipped"] += 1
continue
processed += 1
try:
payload = await _fetch_json_async(f"/events/{m.source_event_id}/stats/")
except Exception as e:
logger.warning("stats fetch failed match=%s event=%s: %s", m.id, m.source_event_id, e)
result["errors"].append(f"match {m.id}: {e}")
await asyncio.sleep(REQUEST_INTERVAL)
continue
result["fetched"] += 1
fields = _stats_from_payload(payload)
if not fields:
result["skipped"] += 1
await asyncio.sleep(REQUEST_INTERVAL)
continue
if m.stats is None:
# available_at 语义:完赛统计最早在开球+2h 可用(回测防泄漏)
available_at = m.match_date + timedelta(hours=2) if m.match_date else now
m.stats = MatchStats(
match_id=m.id,
source="bzzoiro",
source_record_id=str(m.source_event_id),
retrieved_at=now,
available_at=available_at,
)
db.add(m.stats)
result["created"] += 1
else:
result["updated"] += 1
if m.stats.source is None:
m.stats.source = "bzzoiro"
m.stats.source_record_id = str(m.source_event_id)
if m.stats.retrieved_at is None:
m.stats.retrieved_at = now
if m.stats.available_at is None and m.match_date:
m.stats.available_at = m.match_date + timedelta(hours=2)
for fld, v in fields.items():
# away_possession 为计算字段,模型无此列,跳过
if hasattr(m.stats, fld):
setattr(m.stats, fld, v)
await asyncio.sleep(REQUEST_INTERVAL)
logger.info(
"bzzoiro stats 回填完成: 抓取 %d, 新建 %d, 更新 %d, 跳过 %d, 错误 %d",
result["fetched"], result["created"], result["updated"],
result["skipped"], len(result["errors"]),
)
return result