o2o-triple-pick/backend/app/services/baseball_fetch.py
jwkim bc333d64f4 MLB 종료 판정을 detailedState→abstractGameState 기준으로 수정
우천 조기종료(detailedState="Completed Early") 등 "Final" 이외의 종료
변형 문자열은 감지되지 못해 해당 경기가 영원히 정산 안 된 채 live 로
고착되는 버그였다(MLB_PHI_BAL_20260803, 8:0 조기종료 확정임에도 누락).
abstractGameState(Preview/Live/Final 세 값만 존재)로 판정 기준 교체.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:09:05 +09:00

154 lines
6.7 KiB
Python

"""야구 일정·결과 수집 — KBO(네이버 비공식) + MLB(공식 Stats API).
반환 표준 레코드 (두 리그 공통):
{league, teamA(원정), teamB(홈), dateKst 'YYYYMMDD', kickoffKst ISO(+09:00),
venue, cancelled, [scoreA, scoreB]}
야구는 같은 두 팀이 시즌 내 반복 대결 → 경기 식별에 반드시 dateKst 포함.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from ..config import settings
from ..teams_baseball import KBO_TEAMS, MLB_ID_TO_CODE
log = logging.getLogger("triplepick.baseball")
KST = timezone(timedelta(hours=9))
UA = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
)
}
# ── KBO (네이버) ───────────────────────────────────────────────
async def _fetch_kbo(days_back: int, days_ahead: int) -> list[dict]:
import httpx
today = datetime.now(KST).date()
url = (
f"{settings.naver_api_base}/schedule/games"
"?fields=basic,stadium,statusNum"
"&upperCategoryId=kbaseball&categoryId=kbo"
f"&fromDate={(today - timedelta(days=days_back)).isoformat()}"
f"&toDate={(today + timedelta(days=days_ahead)).isoformat()}&size=500"
)
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(url, headers=UA)
r.raise_for_status()
games = (r.json().get("result") or {}).get("games") or []
out: list[dict] = []
for g in games:
a = (g.get("awayTeamCode") or "").upper()
b = (g.get("homeTeamCode") or "").upper()
if a not in KBO_TEAMS or b not in KBO_TEAMS:
continue # 올스타/시범 등 제외
dt = g.get("gameDateTime") # KST, 오프셋 없음
if not dt:
continue
kickoff = datetime.fromisoformat(dt).replace(tzinfo=KST)
rec = {
"league": "kbo",
"teamA": a, "teamB": b,
"dateKst": kickoff.strftime("%Y%m%d"),
"kickoffKst": kickoff.isoformat(),
"venue": g.get("stadium", ""),
"cancelled": bool(g.get("cancel")),
}
if g.get("statusCode") == "RESULT" and not rec["cancelled"]:
sa, sb = g.get("awayTeamScore"), g.get("homeTeamScore")
if sa is not None and sb is not None:
rec["scoreA"], rec["scoreB"] = int(sa), int(sb)
out.append(rec)
return out
# ── MLB (공식 Stats API) ───────────────────────────────────────
async def _fetch_mlb(days_back: int, days_ahead: int) -> list[dict]:
import httpx
today = datetime.now(KST).date()
url = (
f"{settings.mlb_api_base}/v1/schedule?sportId=1"
f"&startDate={(today - timedelta(days=days_back)).isoformat()}"
f"&endDate={(today + timedelta(days=days_ahead)).isoformat()}"
)
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(url)
r.raise_for_status()
data = r.json()
out: list[dict] = []
for day in data.get("dates") or []:
for g in day.get("games") or []:
a = MLB_ID_TO_CODE.get((g["teams"]["away"]["team"] or {}).get("id"))
b = MLB_ID_TO_CODE.get((g["teams"]["home"]["team"] or {}).get("id"))
if not a or not b:
continue # 올스타전 등 제외
gd = g.get("gameDate") # ISO UTC
if not gd:
continue
kickoff = (
datetime.fromisoformat(gd.replace("Z", "+00:00")).astimezone(KST)
)
status_obj = g.get("status") or {}
# detailedState 는 "Final"/"Game Over"/"Completed Early"(우천 조기종료) 등
# 종료를 뜻하는 문자열이 여러 갈래라 그것만으로 판정하면 일부가 누락된다.
# abstractGameState 는 Preview/Live/Final 세 값만 존재하는 신뢰 가능한 판정 기준.
abstract = status_obj.get("abstractGameState", "")
detailed = status_obj.get("detailedState", "")
rec = {
"league": "mlb",
"teamA": a, "teamB": b,
"dateKst": kickoff.strftime("%Y%m%d"),
"kickoffKst": kickoff.isoformat(),
"venue": (g.get("venue") or {}).get("name", ""),
"cancelled": abstract != "Final" and detailed.startswith(
("Postponed", "Cancelled", "Suspended")
),
"gamePk": g.get("gamePk"), # MLB 라이브 피드 키
}
if abstract == "Final" and not rec["cancelled"]:
sa = g["teams"]["away"].get("score")
sb = g["teams"]["home"].get("score")
if sa is not None and sb is not None:
rec["scoreA"], rec["scoreB"] = int(sa), int(sb)
out.append(rec)
return out
# ── 공개 API ────────────────────────────────────────────────────
async def fetch_baseball_schedule(league: str, days_back: int | None = None) -> list[dict]:
"""리그 일정+결과 수집 (취소 포함). 실패 시 빈 리스트 — 기존 일정 유지.
days_back 기본값은 result_recheck_days(짧은 재확인 윈도우). settle_baseball 은
미정산 경기 중 가장 오래된 날짜를 덮도록 더 큰 값을 넘겨, 오래 방치된
미정산 경기(수집 실패 등으로 첫 정산을 놓친 경기)도 결국 잡히게 한다.
"""
days_back = settings.result_recheck_days if days_back is None else days_back
try:
if league == "kbo":
records = await _fetch_kbo(days_back, settings.baseball_days_ahead)
elif league == "mlb":
records = await _fetch_mlb(days_back, settings.baseball_days_ahead)
else:
return []
# 더블헤더 차수 부여 — sync·정산이 같은 seq 로 경기를 식별한다.
from .baseball_sync import assign_seq
assign_seq(records)
log.info("baseball schedule(%s): %d경기 수집", league, len(records))
return records
except Exception as e: # noqa: BLE001
log.error("baseball schedule(%s) 실패: %s — 기존 일정 유지", league, e)
return []
async def fetch_baseball_results(league: str, days_back: int | None = None) -> list[dict]:
"""확정 스코어만 → [{league, teamA, teamB, dateKst, scoreA, scoreB}]."""
return [r for r in await fetch_baseball_schedule(league, days_back) if "scoreA" in r]