o2o-triple-pick/backend/app/services/baseball_details.py

541 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""야구 부가 데이터 — 프리뷰(선발투수·상대전적)·리그 순위·라이브 필드 뷰.
KBO: 네이버 스포츠 비공식 API (프리뷰·순위·문자중계 relay)
MLB: 공식 Stats API (probablePitcher·standings·feed/live)
수집(refresh_*)은 워커가 매일, 조회(get_extras)는 라우터가 캐시만 읽음.
라이브(fetch_live)는 요청 시 프록시 + 짧은 TTL 메모리 캐시.
"""
from __future__ import annotations
import logging
import time
from datetime import datetime, timedelta, timezone
from ..config import settings
from ..domain import ensure_aware, now_utc
from ..models import DataCache, Match
from ..teams_baseball import KBO_SHORT_TO_CODE, MLB_ID_TO_CODE, MLB_TEAMS
from .baseball_sync import match_seq
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"
)
}
def naver_game_id(m: Match, game_no: str = "0") -> str:
kst = ensure_aware(m.kickoff_at).astimezone(KST)
return f"{kst.strftime('%Y%m%d')}{m.team_a_code}{m.team_b_code}{game_no}{kst.year}"
def naver_game_id_candidates(m: Match) -> list[str]:
"""더블헤더 대응 gameId 후보 — 끝번호 0(단일)/1(DH 1차전)/2(DH 2차전).
seq1 은 단일을 먼저 시도하고 DH 1차전으로 폴백, seq2 는 2차전 고정."""
nos = ["2"] if match_seq(m.match_id) == 2 else ["0", "1"]
return [naver_game_id(m, n) for n in nos]
async def _naver_get_first(client, m: Match, suffix: str) -> dict | None:
"""gameId 후보를 순서대로 시도해 첫 성공 응답을 반환."""
for gid in naver_game_id_candidates(m):
try:
res = await _naver_get(client, f"/schedule/games/{gid}/{suffix}")
except Exception: # noqa: BLE001 — 후보 불일치(404 등)는 다음 후보로
continue
if res:
return res
return None
async def _naver_get(client, path: str) -> dict | None:
r = await client.get(settings.naver_api_base + path, headers=UA)
r.raise_for_status()
data = r.json()
return data.get("result") if data.get("success") else None
async def _upsert(db, key: str, payload: dict) -> None:
row = await db.get(DataCache, key)
if row:
row.payload = payload
row.fetched_at = now_utc()
else:
db.add(DataCache(key=key, payload=payload, fetched_at=now_utc()))
# ── 프리뷰 (선발투수·시즌 상대전적) ────────────────────────────
def _kbo_starter(raw: dict | None) -> dict | None:
if not raw:
return None
info = raw.get("playerInfo") or {}
season = raw.get("currentSeasonStats") or {}
vs = raw.get("currentSeasonStatsOnOpponents") or {}
out = {
"name": info.get("name", ""),
"hitType": info.get("hitType", ""),
"era": season.get("era"),
"w": season.get("w"), "l": season.get("l"),
"vsEra": vs.get("era"),
}
return out if out["name"] else None
async def _refresh_previews_kbo(db, matches: list[Match]) -> int:
import httpx
n = 0
async with httpx.AsyncClient(timeout=15) as client:
for m in matches:
try:
res = await _naver_get_first(client, m, "preview")
p = (res or {}).get("previewData") or {}
except Exception as e: # noqa: BLE001
log.warning("kbo preview 실패 %s: %s", m.match_id, e)
continue
vs = p.get("seasonVsResult") or {}
payload = {
"starterA": _kbo_starter(p.get("awayStarter")),
"starterB": _kbo_starter(p.get("homeStarter")),
"seasonVs": {
"aWin": vs.get("aw"), "draw": vs.get("ad") or 0, "bWin": vs.get("hw"),
} if vs else None,
}
if payload["starterA"] or payload["starterB"] or payload["seasonVs"]:
await _upsert(db, f"preview:{m.match_id}", payload)
n += 1
return n
_HAND_KO = {"L": "", "R": "", "S": ""}
def _mlb_starter(p: dict, stats: dict[int, dict]) -> dict | None:
if not p.get("fullName"):
return None
out: dict = {"name": p["fullName"]}
out.update(stats.get(p.get("id"), {}))
return out
async def _mlb_season_vs(c, m: Match, year: int) -> dict | None:
"""시즌 정규 상대전적 — 두 팀 간 완료 경기 승수 집계 (팀쌍당 1콜)."""
aid = (MLB_TEAMS.get(m.team_a_code) or {}).get("mlb_id")
bid = (MLB_TEAMS.get(m.team_b_code) or {}).get("mlb_id")
if not aid or not bid:
return None
r = await c.get(
f"{settings.mlb_api_base}/v1/schedule?sportId=1&season={year}&gameType=R"
f"&teamId={bid}&opponentId={aid}"
f"&startDate={year}-03-01&endDate={datetime.now(KST).date().isoformat()}"
)
r.raise_for_status()
wins = {aid: 0, bid: 0}
for day in r.json().get("dates") or []:
for g in day.get("games") or []:
if (g.get("status") or {}).get("abstractGameState") != "Final":
continue
for side in ("away", "home"):
t = g["teams"][side]
tid = (t.get("team") or {}).get("id")
if t.get("isWinner") and tid in wins:
wins[tid] += 1
if wins[aid] + wins[bid] == 0:
return None
return {"aWin": wins[aid], "draw": 0, "bWin": wins[bid]}
async def _refresh_previews_mlb(db, matches: list[Match]) -> int:
"""MLB 예고 선발(시즌 ERA·승패·투타 포함)·시즌 상대전적 — 공식 Stats API.
호출량: 일정 1콜 + 선발 스탯 일괄 1콜 + 상대전적 팀쌍당 1콜.
"""
import httpx
if not matches:
return 0
dates = sorted({ensure_aware(m.kickoff_at).astimezone(KST).date() for m in matches})
year = datetime.now(KST).year
# statsapi 의 start/endDate 는 미국 날짜 — KST 새벽~오전 경기는 미국 전날이라
# 시작일을 하루 앞당겨야 누락되지 않는다.
url = (
f"{settings.mlb_api_base}/v1/schedule?sportId=1"
f"&startDate={(dates[0] - timedelta(days=1)).isoformat()}"
f"&endDate={dates[-1].isoformat()}"
"&hydrate=probablePitcher"
)
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(url)
r.raise_for_status()
data = r.json()
# (dateKst, away, home) → (원정 선발 raw, 홈 선발 raw)
starters: dict[tuple, tuple[dict, 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"))
gd = g.get("gameDate")
if not a or not b or not gd:
continue
d = (
datetime.fromisoformat(gd.replace("Z", "+00:00"))
.astimezone(KST).strftime("%Y%m%d")
)
pa = g["teams"]["away"].get("probablePitcher") or {}
pb = g["teams"]["home"].get("probablePitcher") or {}
starters[(d, a, b)] = (pa, pb)
# 선발 시즌 스탯 — people 일괄 조회 1콜 (ERA·승패·투타)
pids = sorted({
p["id"] for pair in starters.values() for p in pair if p.get("id")
})
pstats: dict[int, dict] = {}
if pids:
try:
r2 = await c.get(
f"{settings.mlb_api_base}/v1/people"
f"?personIds={','.join(map(str, pids))}"
f"&hydrate=stats(group=[pitching],type=[season],season={year})"
)
r2.raise_for_status()
for p in r2.json().get("people") or []:
splits = (p.get("stats") or [{}])[0].get("splits") or []
s = splits[0].get("stat", {}) if splits else {}
hand = _HAND_KO.get((p.get("pitchHand") or {}).get("code"))
bat = _HAND_KO.get((p.get("batSide") or {}).get("code"))
pstats[p["id"]] = {
"hitType": f"{hand}{bat}" if hand and bat else None,
"era": s.get("era"),
"w": s.get("wins"), "l": s.get("losses"),
}
except Exception as e: # noqa: BLE001 — 스탯 실패 시 이름만 표시
log.warning("mlb 선발 스탯 실패: %s", e)
n = 0
vs_cache: dict[tuple, dict | None] = {}
for m in matches:
d = ensure_aware(m.kickoff_at).astimezone(KST).strftime("%Y%m%d")
pa, pb = starters.get((d, m.team_a_code, m.team_b_code), ({}, {}))
pair = (m.team_a_code, m.team_b_code)
if pair not in vs_cache:
try:
vs_cache[pair] = await _mlb_season_vs(c, m, year)
except Exception as e: # noqa: BLE001
log.warning("mlb 상대전적 실패 %s: %s", m.match_id, e)
vs_cache[pair] = None
sa = _mlb_starter(pa, pstats)
sb = _mlb_starter(pb, pstats)
if sa or sb or vs_cache[pair]:
await _upsert(db, f"preview:{m.match_id}", {
"starterA": sa,
"starterB": sb,
"seasonVs": vs_cache[pair],
})
n += 1
return n
# ── 리그 순위 ──────────────────────────────────────────────────
async def _refresh_standings_kbo(db) -> bool:
import httpx
year = datetime.now(KST).year
try:
async with httpx.AsyncClient(timeout=15) as client:
res = await _naver_get(client, f"/stats/categories/kbo/seasons/{year}/teams")
except Exception as e: # noqa: BLE001
log.warning("kbo standings 실패: %s", e)
return False
table: dict[str, dict] = {}
for r in (res or {}).get("seasonTeamStats") or []:
code = KBO_SHORT_TO_CODE.get(r.get("teamShortName", ""))
if code:
table[code] = {
"rank": r.get("ranking"),
"w": r.get("winGameCount"), "d": r.get("drawnGameCount"),
"l": r.get("loseGameCount"), "wra": r.get("wra"),
"gb": r.get("gameBehind"), "last5": r.get("lastFiveGames"),
"avg": r.get("offenseHra"), "era": r.get("defenseEra"),
}
if not table:
return False
await _upsert(db, "standings:kbo", table)
return True
# statsapi division.id → 순위표 그룹 키 (AL/NL × 동·중·서)
_MLB_DIV = {201: "ALE", 202: "ALC", 200: "ALW", 204: "NLE", 205: "NLC", 203: "NLW"}
async def _refresh_standings_mlb(db) -> bool:
import httpx
year = datetime.now(KST).year
table: dict[str, dict] = {}
try:
async with httpx.AsyncClient(timeout=15) as c:
for lid in (103, 104): # AL, NL
r = await c.get(
f"{settings.mlb_api_base}/v1/standings?leagueId={lid}&season={year}"
)
r.raise_for_status()
for rec_div in r.json().get("records") or []:
div = _MLB_DIV.get((rec_div.get("division") or {}).get("id"))
for t in rec_div.get("teamRecords") or []:
code = MLB_ID_TO_CODE.get((t.get("team") or {}).get("id"))
if code:
table[code] = {
"div": div,
"rank": int(t.get("divisionRank") or 0) or None,
"w": t.get("wins"), "d": 0, "l": t.get("losses"),
"wra": t.get("winningPercentage"),
"gb": t.get("gamesBack"),
"last5": None, "avg": None, "era": None,
}
except Exception as e: # noqa: BLE001
log.warning("mlb standings 실패: %s", e)
return False
if not table:
return False
await _upsert(db, "standings:mlb", table)
return True
async def refresh_baseball_details(db, league: str, matches: list[Match]) -> None:
"""임박(48h 내) 미종료 경기 프리뷰 + 리그 순위 캐시 갱신."""
horizon = now_utc() + timedelta(hours=48)
targets = [
m for m in matches
if m.result_outcome is None
and m.status != "cancelled"
and ensure_aware(m.kickoff_at) <= horizon
]
if league == "kbo":
n = await _refresh_previews_kbo(db, targets)
await _refresh_standings_kbo(db)
elif league == "mlb":
n = await _refresh_previews_mlb(db, targets)
await _refresh_standings_mlb(db)
else:
return
await db.commit()
log.info("baseball details(%s): 프리뷰 %d경기 캐싱", league, n)
# ── extras 조회 (라우터 — 캐시만) ──────────────────────────────
async def get_extras(db, matches: list[Match]) -> dict[str, dict]:
standings_cache: dict[str, dict] = {}
out: dict[str, dict] = {}
for m in matches:
if m.league not in ("kbo", "mlb"):
continue
if m.league not in standings_cache:
row = await db.get(DataCache, f"standings:{m.league}")
standings_cache[m.league] = row.payload if row else {}
extras: dict = {}
prev = await db.get(DataCache, f"preview:{m.match_id}")
if prev:
extras.update(prev.payload)
st = standings_cache[m.league]
st_a, st_b = st.get(m.team_a_code), st.get(m.team_b_code)
if st_a or st_b:
extras["standings"] = {"a": st_a, "b": st_b}
if extras:
out[m.match_id] = extras
return out
# ── 라이브 필드 뷰 ─────────────────────────────────────────────
_LIVE_TTL_SEC = 15.0
_live_cache: dict[str, tuple[float, dict]] = {}
FIELD_POSITIONS = (
"포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수",
)
# MLB 포지션 약어 → 한글 (필드 좌표 키와 통일)
MLB_POS = {
"C": "포수", "1B": "1루수", "2B": "2루수", "3B": "3루수", "SS": "유격수",
"LF": "좌익수", "CF": "중견수", "RF": "우익수",
}
def _current_slots(batters: list[dict]) -> list[dict]:
by_order: dict[int, dict] = {}
for b in batters or []:
o = b.get("batOrder")
if o is None:
continue
cur = by_order.get(o)
if cur is None or (b.get("seqno") or 0) > (cur.get("seqno") or 0):
by_order[o] = b
return [by_order[o] for o in sorted(by_order)]
def _batter_out(b: dict) -> dict:
return {
"order": b.get("batOrder"),
"name": b.get("name", ""),
"pos": b.get("posName", ""),
"avg": b.get("seasonHra"),
"sub": (b.get("seqno") or 1) > 1,
}
def _transform_naver_relay(t: dict) -> dict:
gs = t.get("currentGameState") or {}
home_batting = str(t.get("homeOrAway")) == "1"
home_lu = t.get("homeLineup") or {}
away_lu = t.get("awayLineup") or {}
offense_lu = home_lu if home_batting else away_lu
defense_lu = away_lu if home_batting else home_lu
offense = _current_slots(offense_lu.get("batter"))
defense = _current_slots(defense_lu.get("batter"))
pitchers = defense_lu.get("pitcher") or []
pitcher = max(pitchers, key=lambda p: p.get("seqno") or 0) if pitchers else {}
batter_code = str(gs.get("batter") or "")
batter = next((b for b in offense if str(b.get("pcode")) == batter_code), None)
return {
"available": True,
"inn": t.get("inn"),
"half": "B" if home_batting else "T",
"score": {"away": gs.get("awayScore"), "home": gs.get("homeScore")},
"bso": {"b": gs.get("ball"), "s": gs.get("strike"), "o": gs.get("out")},
"bases": [
str(gs.get(k) or "0") != "0" for k in ("base1", "base2", "base3")
],
"batter": _batter_out(batter) if batter else None,
"pitcher": {
"name": pitcher.get("name", ""),
"ballCount": pitcher.get("ballCount"),
} if pitcher else None,
"vsRecord": t.get("pitcherVsBatterCareerStats") or "",
"defense": [
{"pos": b.get("posName"), "name": b.get("name", "")}
for b in defense if b.get("posName") in FIELD_POSITIONS
],
"offenseLineup": [_batter_out(b) for b in offense],
}
def _transform_mlb_feed(feed: dict) -> dict:
ld = feed.get("liveData") or {}
ls = ld.get("linescore") or {}
box = (ld.get("boxscore") or {}).get("teams") or {}
half_top = (ls.get("inningHalf") or "").lower() == "top"
offense_side, defense_side = ("away", "home") if half_top else ("home", "away")
off = ls.get("offense") or {}
defn = ls.get("defense") or {}
def _players(side: str) -> dict:
return (box.get(side) or {}).get("players") or {}
# 수비 배치: boxscore players 의 position + 현재 출장(battingOrder 존재)
defense = []
for p in _players(defense_side).values():
pos = MLB_POS.get(((p.get("position") or {}).get("abbreviation") or ""))
name = ((p.get("person") or {}).get("fullName")) or ""
if pos and name and p.get("gameStatus", {}).get("isCurrentBatter") is not None:
defense.append({"pos": pos, "name": name})
# 같은 포지션 중복(교체) — 마지막 것만
dedup: dict[str, dict] = {f["pos"]: f for f in defense}
order_raw = (box.get(offense_side) or {}).get("battingOrder") or []
id_to_player = _players(offense_side)
lineup = []
for i, pid in enumerate(order_raw[:9]):
p = id_to_player.get(f"ID{pid}") or {}
lineup.append({
"order": i + 1,
"name": ((p.get("person") or {}).get("fullName")) or "",
"pos": MLB_POS.get(((p.get("position") or {}).get("abbreviation") or ""), ""),
"avg": None,
"sub": False,
})
batter_name = ((off.get("batter") or {}).get("fullName")) or ""
batter = next((b for b in lineup if b["name"] == batter_name), None)
pitcher = (defn.get("pitcher") or {}).get("fullName") or \
(off.get("pitcher") or {}).get("fullName") or ""
return {
"available": bool(ls.get("currentInning")),
"inn": ls.get("currentInning"),
"half": "T" if half_top else "B",
"score": {
"away": ((ls.get("teams") or {}).get("away") or {}).get("runs"),
"home": ((ls.get("teams") or {}).get("home") or {}).get("runs"),
},
"bso": {"b": ls.get("balls"), "s": ls.get("strikes"), "o": ls.get("outs")},
"bases": [bool(off.get("first")), bool(off.get("second")), bool(off.get("third"))],
"batter": batter or ({"order": None, "name": batter_name} if batter_name else None),
"pitcher": {"name": pitcher, "ballCount": None} if pitcher else None,
"vsRecord": "",
"defense": list(dedup.values()),
"offenseLineup": lineup,
}
async def fetch_live(m: Match) -> dict:
"""라이브 필드 뷰 페이로드 (리그별 소스). 미게시면 available=False."""
import httpx
cached = _live_cache.get(m.match_id)
if cached and time.monotonic() - cached[0] < _LIVE_TTL_SEC:
return cached[1]
payload: dict = {"available": False}
try:
if m.league == "kbo":
async with httpx.AsyncClient(timeout=10) as client:
res = await _naver_get_first(client, m, "relay")
t = (res or {}).get("textRelayData")
if t:
payload = _transform_naver_relay(t)
elif m.league == "mlb":
# gamePk 를 일정에서 재조회 — MLB 일정 API 의 date 는 미국 날짜라
# KST 기준 하루 전~당일 범위로 조회 후 dateKst 로 정확히 매칭.
kst = ensure_aware(m.kickoff_at).astimezone(KST)
date_kst = kst.strftime("%Y%m%d")
start = (kst.date() - timedelta(days=1)).isoformat()
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(
f"{settings.mlb_api_base}/v1/schedule?sportId=1"
f"&startDate={start}&endDate={kst.date().isoformat()}"
)
r.raise_for_status()
# 더블헤더 대응: 같은 날짜·팀쌍 경기를 시작시각순으로 모아
# match_id 의 차수(seq)에 해당하는 경기를 고른다.
cands: list[tuple[str, int]] = []
for day in r.json().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"))
gd = g.get("gameDate")
if not a or not b or not gd:
continue
g_kst = (
datetime.fromisoformat(gd.replace("Z", "+00:00"))
.astimezone(KST).strftime("%Y%m%d")
)
if a == m.team_a_code and b == m.team_b_code and g_kst == date_kst:
cands.append((gd, g.get("gamePk")))
cands.sort()
idx = match_seq(m.match_id) - 1
pk = cands[idx][1] if idx < len(cands) else None
if pk:
r2 = await c.get(f"{settings.mlb_api_base}/v1.1/game/{pk}/feed/live")
r2.raise_for_status()
payload = _transform_mlb_feed(r2.json())
except Exception as e: # noqa: BLE001
log.warning("live 실패 %s: %s", m.match_id, e)
payload = {"available": False}
_live_cache[m.match_id] = (time.monotonic(), payload)
return payload