o2o-triple-pick/backend/app/services/baseball_details.py
jwkim 00494e9e93 feat(mlb): 문자중계 추가 — feed/live allPlays 규칙 기반 한글 변환
- 투구 호칭·eventType 한글 매핑, 타구 방향은 desc 의 야수 표현에서 추출
- 주자 이동(홈인·진루·주루사)은 정규식 파싱 — 선수명은 이니셜("J.P.")만
  마침표 허용해 문장 경계를 넘지 않게 함
- 교체는 "A replaces B" 구문 분해 (투수 교체·대타·대주자·수비 교체)
- 득점 강조는 KBO 와 동일하게 플레이 종료 점수 합 변화로 판정 + 홈런 결과 라인
- 미지원 문장·이벤트는 영문 원문 폴백. 노이즈(타자 타임·수비 위치이동 등) 제외
- KBO relay 와 동일 스키마라 프론트(LiveField RelayFeed) 변경 없음

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:38:48 +09:00

862 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""야구 부가 데이터 — 프리뷰(선발투수·상대전적)·리그 순위·라이브 필드 뷰.
KBO: 네이버 스포츠 비공식 API (프리뷰·순위·문자중계 relay)
MLB: 공식 Stats API (probablePitcher·standings·feed/live)
수집(refresh_*)은 워커가 매일, 조회(get_extras)는 라우터가 캐시만 읽음.
라이브(fetch_live)는 요청 시 프록시 + 짧은 TTL 메모리 캐시.
"""
from __future__ import annotations
import asyncio
import logging
import re
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_with_id(
client, m: Match, suffix: str
) -> tuple[str, dict] | None:
"""gameId 후보를 순서대로 시도해 (성공한 gameId, 응답) 을 반환.
같은 경기를 추가 조회할 때(이닝별 문자중계) 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 gid, res
return None
async def _naver_get_first(client, m: Match, suffix: str) -> dict | None:
"""gameId 후보를 순서대로 시도해 첫 성공 응답을 반환."""
found = await _naver_get_first_with_id(client, m, suffix)
return found[1] if found else 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 조회 (라우터 — 캐시만) ──────────────────────────────
# MLS 도 동일 캐시 키(preview:{id}, standings:mls)를 쓰므로 여기서 함께 서빙.
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", "mls"):
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],
}
# ── KBO 문자중계 (네이버 relay textRelays) ─────────────────────
# 네이버는 이미 한국어 중계문을 타석 단위로 묶어 준다 — 번역 없이 그대로 서빙.
# relay 기본 응답은 "현재 이닝"만 담고, 지난 이닝은 ?inning=N 으로 따로 받아야 한다.
# textOptions.type → 프론트 표시용 의미 (네이버 숫자 코드를 UI 에 노출하지 않기 위함)
_RELAY_KIND = {
0: "inning", # 이닝 시작 ("1회초 한화 공격")
1: "pitch", # 투구 ("3구 헛스윙")
2: "sub", # 선수 교체
7: "note", # 투수판 이탈·비디오 판독·마운드 방문
8: "batter", # 타석 시작 (그룹 제목과 동일)
13: "result", # 타석 결과
23: "result", # 타석 결과 (주자 있을 때 표기)
14: "run", # 주루
24: "run", # 주루·홈인
99: "end", # 경기 종료·승리투수
}
# 라이브 페이로드에 싣는 최근 타석 수 — 전 이닝(약 80타석)을 매 폴링마다 보내지 않기 위한 상한
_RELAY_MAX_GROUPS = 40
# 지난 이닝 중계 캐시 (경기 → {이닝: 그룹[]}). 종료된 이닝은 불변이라 재조회하지 않는다.
_relay_innings: dict[str, dict[int, list[dict]]] = {}
def _score_total(gs: dict) -> int | None:
try:
return int(gs.get("homeScore") or 0) + int(gs.get("awayScore") or 0)
except (TypeError, ValueError):
return None
def _relay_commentary(groups: list[dict]) -> list[dict]:
"""relay 그룹 → 문자중계 [{inn, half, no, title, events[]}] (최신 타석 순).
득점 표시는 텍스트 패턴 대신 옵션마다 실린 currentGameState 의 점수 합 변화로
판정한다 — 홈런·적시타뿐 아니라 홈인·실책 득점·밀어내기까지 정확히 잡힌다.
"""
out: list[dict] = []
prev_total: int | None = None
for r in sorted(groups, key=lambda g: g.get("no") or 0):
title = (r.get("title") or "").strip()
if not title.strip("="): # 경기 종료 구분선 그룹 — 제목 없이 내용만
title = ""
events: list[dict] = []
for o in sorted(r.get("textOptions") or [], key=lambda o: o.get("seqno") or 0):
total = _score_total(o.get("currentGameState") or {})
scored = prev_total is not None and total is not None and total > prev_total
if total is not None:
prev_total = total
text = (o.get("text") or "").strip()
# 구분선("=====")·제목 중복 라인은 버린다
if not text or text == title or not text.strip("="):
continue
ev = {"kind": _RELAY_KIND.get(o.get("type"), "note"), "text": text}
if scored:
ev["score"] = True
events.append(ev)
# 이닝 헤더 그룹은 events 가 비지만 구분선으로 쓰이므로 남긴다
if not title and not events:
continue
out.append({
"inn": r.get("inn"),
"half": "B" if str(r.get("homeOrAway")) == "1" else "T",
"no": r.get("no"),
"title": title,
"events": events,
})
out.reverse()
# 종료된 경기는 폴링이 멈추므로 전 경기 중계를 그대로 준다(다시보기).
# 진행 중엔 20초마다 다시 실리므로 최근 타석만 — 스크롤 분량으로도 충분하다.
ended = any(e["kind"] == "end" for g in out for e in g["events"])
return out if ended else out[:_RELAY_MAX_GROUPS]
async def _kbo_relay_groups(client, m: Match, gid: str, t: dict) -> list[dict]:
"""전 이닝 문자중계 그룹. 이닝별 조회를 캐시와 병렬 조회로 채운다.
현재·직전 이닝은 매번 다시 받는다 — 기본 응답은 진행 중인 half 만 담을 때가 있고,
이닝이 넘어간 직후엔 직전 이닝 마지막 타석이 캐시에 안 들어와 있을 수 있다.
그보다 앞선 이닝은 더 변하지 않으므로 한 번만 받는다.
"""
cur_groups = t.get("textRelays") or []
cur = int(t.get("inn") or max((g.get("inn") or 0) for g in cur_groups) or 1)
if len(_relay_innings) > 40: # 하루치 경기 이상 쌓이면 통째로 비움
_relay_innings.clear()
cache = _relay_innings.setdefault(m.match_id, {})
volatile = {cur - 1, cur}
need = [i for i in range(1, cur + 1) if i in volatile or i not in cache]
if need:
res = await asyncio.gather(
*(
_naver_get(client, f"/schedule/games/{gid}/relay?inning={i}")
for i in need
),
return_exceptions=True,
)
for i, r in zip(need, res):
if isinstance(r, BaseException) or not r:
continue # 실패한 이닝은 기존 캐시 유지 → 다음 폴링에서 재시도
cache[i] = ((r.get("textRelayData") or {}).get("textRelays")) or []
cache.setdefault(cur, cur_groups) # 현재 이닝 조회 실패 시 기본 응답으로 대체
return [g for i in sorted(cache) for g in cache[i]]
# ── MLB 문자중계 (feed/live allPlays 규칙 기반 한글 변환) ──────
# MLB 중계문은 정형 영문 템플릿 — eventType·투구 호칭을 한글로 매핑하고
# 타구 방향·주자 진루는 정규식으로 뽑는다. 미지원 문장은 영문 원문 폴백.
_MLB_PITCH_KO = {
"Ball": "볼", "Ball In Dirt": "볼 (원바운드)", "Intent Ball": "고의사구 볼",
"Called Strike": "스트라이크", "Swinging Strike": "헛스윙",
"Swinging Strike (Blocked)": "헛스윙", "Foul": "파울", "Foul Tip": "파울팁",
"Foul Bunt": "번트 파울", "Missed Bunt": "번트 헛스윙",
"Hit By Pitch": "몸에 맞는 볼", "Pitchout": "피치아웃",
"Automatic Ball": "자동 볼 (피치클록)", "Automatic Strike": "자동 스트라이크 (피치클록)",
"In play, out(s)": "타격", "In play, no out": "타격", "In play, run(s)": "타격",
}
_MLB_EVENT_KO = {
"Single": "1루타", "Double": "2루타", "Triple": "3루타", "Home Run": "홈런",
"Walk": "볼넷", "Intent Walk": "고의4구", "Strikeout": "삼진 아웃",
"Strikeout Double Play": "삼진 (병살)", "Groundout": "땅볼 아웃",
"Flyout": "플라이 아웃", "Lineout": "직선타 아웃", "Pop Out": "팝플라이 아웃",
"Bunt Groundout": "번트 아웃", "Bunt Pop Out": "번트 팝플라이 아웃",
"Forceout": "포스 아웃", "Grounded Into DP": "병살타", "Double Play": "병살",
"Triple Play": "삼중살", "Sac Fly": "희생플라이", "Sac Bunt": "희생번트",
"Field Error": "실책 출루", "Fielders Choice": "야수선택",
"Fielders Choice Out": "야수선택 아웃", "Hit By Pitch": "몸에 맞는 볼",
"Catcher Interference": "포수 타격방해", "Runner Out": "주자 아웃",
"Caught Stealing 2B": "도루자 (2루)", "Caught Stealing 3B": "도루자 (3루)",
"Caught Stealing Home": "도루자 (홈)", "Pickoff 1B": "견제사 (1루)",
"Pickoff 2B": "견제사 (2루)", "Pickoff 3B": "견제사 (3루)",
}
# 타구 방향/처리 야수 — desc 에서 가장 먼저 나오는 표현 (긴 표현 우선 매칭)
_MLB_DIR_KO = [
("left fielder", "좌익수"), ("left field", "좌익수"),
("center fielder", "중견수"), ("center field", "중견수"),
("right fielder", "우익수"), ("right field", "우익수"),
("shortstop", "유격수"), ("first baseman", "1루수"),
("second baseman", "2루수"), ("third baseman", "3루수"),
("first base", "1루수"), ("second base", "2루수"), ("third base", "3루수"),
("pitcher", "투수"), ("catcher", "포수"),
]
# 대문자 시작 단어 연속 = 선수명 ("J.P. Crawford", "Wenceel Pérez").
# 마침표는 이니셜("J.P.")에만 허용 — 일반 단어 끝 마침표를 막아 문장 경계를 넘지 않게 한다.
_MLB_NAME_W = r"(?:(?:[A-ZÀ-Ý]\.)+|[A-ZÀ-Ý][\w'\-]*)"
_MLB_NAME = rf"{_MLB_NAME_W}(?: {_MLB_NAME_W})*"
_BASE_KO = {"2nd": "2루", "3rd": "3루", "home": "홈"}
def _mlb_direction(desc: str) -> str:
hits = [(desc.find(en), ko) for en, ko in _MLB_DIR_KO if en in desc]
return min(hits)[1] if hits else ""
def _mlb_runs(desc: str) -> list[dict]:
"""desc 의 주자 이동 문장 → run 이벤트 (홈인·진루·주루사)."""
out: list[dict] = []
for nm in re.findall(rf"({_MLB_NAME}) scores\b", desc):
out.append({"kind": "run", "text": f"{nm} : 홈인"})
for nm, base in re.findall(rf"({_MLB_NAME})(?: advances)? to (2nd|3rd)\b", desc):
out.append({"kind": "run", "text": f"{nm} : {_BASE_KO[base]}까지 진루"})
for nm, base in re.findall(rf"({_MLB_NAME}) out at (2nd|3rd|home)\b", desc):
out.append({"kind": "run", "text": f"{nm} : {_BASE_KO[base]}에서 아웃"})
seen: set[str] = set() # "to 3rd"+"advances to 3rd" 같은 중복 문장 제거
return [e for e in out if not (e["text"] in seen or seen.add(e["text"]))]
def _tr_mlb_result(res: dict, batter: str) -> str:
event, desc = res.get("event") or "", res.get("description") or ""
ko = _MLB_EVENT_KO.get(event)
if not ko: # 미지원 이벤트 — 영문 원문 폴백
return f"{batter} : {desc}" if desc else f"{batter} : {event}"
if event == "Strikeout":
if "called out on strikes" in desc:
ko += " (루킹)"
elif "strikes out swinging" in desc:
ko += " (헛스윙)"
direction = _mlb_direction(desc)
return f"{batter} : {direction} {ko}".replace(" ", " ").replace(" : ", " : ")
def _tr_mlb_action(det: dict) -> tuple[str, str] | None:
"""playEvents 액션 → (kind, text). None 이면 표시하지 않음 (노이즈)."""
event, desc = det.get("event") or "", det.get("description") or ""
if event in ("Game Advisory", "Batter Timeout") or not (event or desc):
return None
if event.startswith("Stolen Base"):
base = "2루" if event.endswith("2B") else "3루" if event.endswith("3B") else "홈"
nm = re.match(rf"({_MLB_NAME}) steals", desc)
who = nm.group(1) if nm else ""
return "run", f"{who} : 도루로 {base}까지 진루".replace(" ", " ")
if event.startswith("Caught Stealing"):
return "run", _MLB_EVENT_KO.get(event, "도루자")
if event == "Wild Pitch":
return "note", "폭투"
if event == "Passed Ball":
return "note", "포일"
if event == "Mound Visit":
return "note", "마운드 방문"
if event in ("Pitching Substitution", "Offensive Substitution",
"Defensive Sub", "Defensive Substitution"):
label = {
"Pitching Substitution": "투수 교체",
"Offensive Substitution": "대타" if "hitter" in desc else "대주자",
}.get(event, "수비 교체")
# "Pitching Change: A replaces B." / "...: Pinch-hitter A replaces
# right fielder B, batting 2nd..." → "{label} — B → A"
body = desc.split(": ", 1)[-1].rstrip(".")
if " replaces " in body:
new, old = body.split(" replaces ", 1)
new = re.sub(r"^Pinch-(?:hitter|runner) ", "", new)
old = re.sub(r"^[a-z][a-z ]* ", "", old) # 포지션 수식어 제거
old = old.split(", batting")[0].split(" because")[0]
return "sub", f"{label} — {old} → {new}"
return "sub", desc
if event == "Defensive Switch":
return None # 포지션 이동 — 중계 피드에선 노이즈
if desc.startswith("Pickoff Attempt"):
return "note", "견제 시도"
if desc.startswith("Pitcher Step Off"):
return None
return "note", desc or event # 미지원 액션 — 영문 폴백
def _transform_mlb_relay(feed: dict, m: Match) -> list[dict]:
"""allPlays → 문자중계 그룹 (KBO relay 와 동일 스키마, 최신 타석순)."""
plays = ((feed.get("liveData") or {}).get("plays") or {}).get("allPlays") or []
away_s = m.team_a_short or m.team_a_code
home_s = m.team_b_short or m.team_b_code
groups: list[dict] = []
prev_half: tuple | None = None
prev_total = 0
no = 0
for p in plays:
about = p.get("about") or {}
inn = about.get("inning")
top = about.get("halfInning") == "top"
if (inn, top) != prev_half: # 이닝 구분 헤더 (KBO 와 동일한 빈 그룹)
prev_half = (inn, top)
no += 1
groups.append({
"inn": inn, "half": "T" if top else "B", "no": no,
"title": f"{inn}회{'초' if top else '말'} {away_s if top else home_s} 공격",
"events": [],
})
events: list[dict] = []
for ev in p.get("playEvents") or []:
det = ev.get("details") or {}
if ev.get("isPitch"):
call = det.get("description") or ""
n = ev.get("pitchNumber")
ko = _MLB_PITCH_KO.get(call, call)
events.append({"kind": "pitch", "text": f"{n}구 {ko}" if n else ko})
else:
ke = _tr_mlb_action(det)
if ke:
events.append({"kind": ke[0], "text": ke[1]})
events += _mlb_runs(det.get("description") or "") # 폭투 득점 등
res = p.get("result") or {}
batter = ((p.get("matchup") or {}).get("batter") or {}).get("fullName") or ""
if res.get("event"):
events.append({"kind": "result", "text": _tr_mlb_result(res, batter)})
events += _mlb_runs(res.get("description") or "")
# 득점 판정 — 플레이 종료 시점 점수 합 변화 (KBO 와 동일 원리)
if about.get("isComplete"):
total = int(res.get("awayScore") or 0) + int(res.get("homeScore") or 0)
if total > prev_total:
marked = False
for e in events:
if e["text"].endswith("홈인"):
e["score"] = True
marked = True
if not marked and events: # 홈인 문장 미파싱 (솔로 홈런 등) — 결과 라인 강조
events[-1]["score"] = True
# 홈런은 타자 본인 득점이 홈인 문장에 없음 — 결과 라인도 강조
if res.get("event") == "Home Run":
for e in events:
if e["kind"] == "result":
e["score"] = True
prev_total = total
if not events:
continue
no += 1
groups.append({
"inn": inn, "half": "T" if top else "B", "no": no,
"title": batter, "events": events,
})
groups.reverse()
ended = ((feed.get("gameData") or {}).get("status") or {}).get(
"abstractGameState") == "Final"
return groups if ended else groups[:_RELAY_MAX_GROUPS]
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:
found = await _naver_get_first_with_id(client, m, "relay")
gid, res = found if found else ("", None)
t = (res or {}).get("textRelayData")
if t:
payload = _transform_naver_relay(t)
try: # 문자중계는 부가 정보 — 실패해도 필드 뷰는 그대로 서빙
payload["relay"] = _relay_commentary(
await _kbo_relay_groups(client, m, gid, t)
)
except Exception as e: # noqa: BLE001
log.warning("kbo 문자중계 실패 %s: %s", m.match_id, e)
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()
feed = r2.json()
payload = _transform_mlb_feed(feed)
try: # 문자중계는 부가 정보 — 실패해도 필드 뷰는 그대로 서빙
relay = _transform_mlb_relay(feed, m)
if relay:
payload["relay"] = relay
payload["available"] = True # 종료 경기 다시보기 지원
except Exception as e: # noqa: BLE001
log.warning("mlb 문자중계 실패 %s: %s", m.match_id, e)
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