diff --git a/backend/app/services/baseball_details.py b/backend/app/services/baseball_details.py index 2c38e4d..bc96156 100644 --- a/backend/app/services/baseball_details.py +++ b/backend/app/services/baseball_details.py @@ -8,6 +8,7 @@ MLB: 공식 Stats API (probablePitcher·standings·feed/live) """ from __future__ import annotations +import asyncio import logging import time from datetime import datetime, timedelta, timezone @@ -41,18 +42,29 @@ def naver_game_id_candidates(m: Match) -> list[str]: return [naver_game_id(m, n) for n in nos] -async def _naver_get_first(client, m: Match, suffix: str) -> dict | None: - """gameId 후보를 순서대로 시도해 첫 성공 응답을 반환.""" +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 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() @@ -426,6 +438,109 @@ def _transform_naver_relay(t: dict) -> dict: } +# ── 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]] + + def _transform_mlb_feed(feed: dict) -> dict: ld = feed.get("liveData") or {} ls = ld.get("linescore") or {} @@ -495,10 +610,17 @@ async def fetch_live(m: Match) -> dict: 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) + 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 로 정확히 매칭. diff --git a/frontend/src/components/LiveField.tsx b/frontend/src/components/LiveField.tsx index ff26786..4fdba19 100644 --- a/frontend/src/components/LiveField.tsx +++ b/frontend/src/components/LiveField.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import type { Lang } from "@/lib/i18n"; import { timeOnly } from "@/lib/format"; -import { getLive, type LiveData } from "@/lib/api"; +import { getLive, type LiveData, type RelayEvent, type RelayGroup } from "@/lib/api"; import type { Match } from "@/lib/types"; // 라이브 필드 뷰 — 수비 배치 다이아몬드 + 현재 타자/투수 + B/S/O + 주자 + 대기타석. @@ -111,6 +111,91 @@ function BallparkSvg({ bases }: { bases?: boolean[] }) { ); } +// 이벤트 종류별 텍스트 스타일 — 투구는 흐리게, 타석 결과는 강조. +const RELAY_STYLE: Record = { + pitch: "text-white/35", + result: "font-extrabold text-white/90", + run: "text-[#8FD3FF]", + sub: "text-white/45", + note: "text-white/40", + inning: "text-white/60", + batter: "text-white/60", + end: "font-bold text-white/70", +}; + +// 문자중계 — 타석 단위 그룹(최신순). events 가 비면 이닝 구분 헤더. +// 원문이 한국어(네이버)라 영문 모드에서도 그대로 노출한다. +function RelayFeed({ groups, lang }: { groups: RelayGroup[]; lang: Lang }) { + const [showPitches, setShowPitches] = useState(true); + + return ( +
+
+ + {lang === "en" ? "PLAY-BY-PLAY" : "문자중계"} + +
+ + NAVER +
+
+ + +
+ ); +} + export default function LiveField({ match, lang = "ko" }: { match: Match; lang?: Lang }) { const isLive = match.phase === "live"; // 경기 중엔 20초 폴링, 그 외(경기 전·종료)는 1회 로드. @@ -289,6 +374,9 @@ export default function LiveField({ match, lang = "ko" }: { match: Match; lang?: ))} )} + + {/* 문자중계 (KBO 만 — MLB 는 소스 미연동) */} + {(data.relay?.length ?? 0) > 0 && } ); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cc5d68f..1156f2f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -88,6 +88,7 @@ export interface LiveData { vsRecord?: string; defense?: { pos?: string; name: string }[]; offenseLineup?: LiveBatter[]; + relay?: RelayGroup[]; // 야구 문자중계 (KBO=네이버, 최신 타석순) // MLS(축구) 라이브 — soccer=true 일 때만 soccer?: boolean; clock?: string; // "45'" @@ -103,6 +104,22 @@ export interface LiveData { commentary?: LiveCommentary[]; // 문자중계 (ESPN 영문, 최신순) } +// 야구 문자중계 — 타석 단위 그룹. events 가 비면 이닝 구분 헤더("7회초 한화 공격"). +export interface RelayGroup { + inn?: number; + half?: "T" | "B"; + no?: number; + title: string; // "5번타자 노시환" + events: RelayEvent[]; +} + +export interface RelayEvent { + // pitch=투구 · result=타석결과 · run=주루 · sub=교체 · note=기타 · inning/end=구분 + kind: "pitch" | "result" | "run" | "sub" | "note" | "inning" | "batter" | "end"; + text: string; + score?: boolean; // 이 이벤트로 점수가 났음 +} + export interface LiveCommentary { clock: string; text: string;