feat(kbo): 라이브 필드에 문자중계 추가 — 네이버 relay 전 이닝

- relay 응답에서 버리던 textRelays 를 타석 단위 그룹으로 서빙 (한국어 원문 그대로)
- 기본 응답은 현재 이닝만 담아, 지난 이닝은 ?inning=N 병렬 조회 후 캐시
  (현재·직전 이닝은 매번 재조회 — 이닝 전환 직후 마지막 타석 누락 방지)
- 득점 하이라이트는 텍스트 패턴이 아니라 currentGameState 점수 합 변화로 판정
  (홈런·적시타뿐 아니라 홈인·폭투/실책 득점·밀어내기까지 정확)
- 네이버 type 코드는 kind(pitch/result/run/sub/note)로 변환해 UI 에 노출하지 않음
- 진행 중엔 최근 40타석, 종료 후엔 전 경기 중계(다시보기)
- 프론트: 투구 접기/펼치기 토글 · 이닝 구분선 · 득점 강조

MLB 는 소스(feed/live allPlays)가 영문이라 별도 작업 — 이번 커밋은 KBO 만.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jwkim 2026-08-14 13:57:02 +09:00
parent 74de57fc1c
commit 0415ab7636
3 changed files with 235 additions and 8 deletions

View File

@ -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 로 정확히 매칭.

View File

@ -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<RelayEvent["kind"], string> = {
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 (
<div className="mt-3 border-t border-white/10 pt-2.5">
<div className="mb-1.5 flex items-baseline justify-between">
<span className="text-[10px] font-bold text-white/35">
{lang === "en" ? "PLAY-BY-PLAY" : "문자중계"}
</span>
<div className="flex items-baseline gap-2">
<button
onClick={() => setShowPitches((v) => !v)}
className="text-[10px] font-bold text-white/45 transition active:scale-95"
>
{showPitches
? lang === "en" ? "Hide pitches" : "투구 접기"
: lang === "en" ? "Show pitches" : "투구 펼치기"}
</button>
<span className="text-[9px] text-white/25">NAVER</span>
</div>
</div>
<ul className="max-h-[260px] space-y-2 overflow-y-auto pr-1 [scrollbar-width:thin] [scrollbar-color:rgba(255,255,255,.22)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-white/20">
{groups.map((g) => {
const events = showPitches ? g.events : g.events.filter((e) => e.kind !== "pitch");
// 이닝 구분 헤더 (내용 없는 그룹)
if (g.events.length === 0) {
return (
<li key={g.no} className="flex items-center gap-2 pt-1">
<span className="h-px flex-1 bg-white/10" />
<span className="text-[10px] font-extrabold text-white/40">{g.title}</span>
<span className="h-px flex-1 bg-white/10" />
</li>
);
}
if (events.length === 0) return null; // 투구만 있던 타석 — 접기 모드에선 숨김
return (
<li key={g.no}>
{g.title && (
<div className="flex items-baseline gap-1.5">
{g.inn != null && (
<span className="shrink-0 font-mono text-[10px] font-bold text-white/30">
{g.inn}
{g.half === "B" ? "말" : "초"}
</span>
)}
<span className="text-[11px] font-extrabold text-white/75">{g.title}</span>
</div>
)}
<ul className="mt-0.5 space-y-px pl-1.5 text-[11px] leading-snug">
{events.map((e, i) => (
<li
key={i}
className={
e.score
? "rounded-lg border border-[#94FBE0]/30 bg-[#94FBE0]/10 px-1.5 py-1 font-extrabold text-[#94FBE0]"
: RELAY_STYLE[e.kind]
}
>
{e.text}
</li>
))}
</ul>
</li>
);
})}
</ul>
</div>
);
}
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?:
))}
</div>
)}
{/* 문자중계 (KBO 만 — MLB 는 소스 미연동) */}
{(data.relay?.length ?? 0) > 0 && <RelayFeed groups={data.relay!} lang={lang} />}
</div>
</section>
);

View File

@ -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;