- 투구 호칭·eventType 한글 매핑, 타구 방향은 desc 의 야수 표현에서 추출
- 주자 이동(홈인·진루·주루사)은 정규식 파싱 — 선수명은 이니셜("J.P.")만
마침표 허용해 문장 경계를 넘지 않게 함
- 교체는 "A replaces B" 구문 분해 (투수 교체·대타·대주자·수비 교체)
- 득점 강조는 KBO 와 동일하게 플레이 종료 점수 합 변화로 판정 + 홈런 결과 라인
- 미지원 문장·이벤트는 영문 원문 폴백. 노이즈(타자 타임·수비 위치이동 등) 제외
- KBO relay 와 동일 스키마라 프론트(LiveField RelayFeed) 변경 없음
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
862 lines
38 KiB
Python
862 lines
38 KiB
Python
"""야구 부가 데이터 — 프리뷰(선발투수·상대전적)·리그 순위·라이브 필드 뷰.
|
||
|
||
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
|