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:
parent
74de57fc1c
commit
0415ab7636
@ -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 로 정확히 매칭.
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user