From 5149ecf82a48bb21f3506cc9af7d25754886e41e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 4 May 2026 08:30:35 +0900 Subject: [PATCH] Integrate scoring play analysis into game detail retrieval. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Naver Sports의 highLight 엔드포인트를 통해 득점 발생 타석의 상세 데이터를 추출하는 기능을 구현했습니다. - 단순 이닝별 점수 보강을 넘어 타석 결과, 주자 이동, 아웃 카운트 및 베이스 상태 변화를 포함한 정밀한 정보를 제공합니다. - KBO와 네이버 간의 경기 ID 체계 차이를 자동으로 처리하며, 병렬 요청 구조로 전체 조회 성능을 최적화했습니다. - 득점 주자의 출루 위치와 타점 발생 상황을 구조화된 데이터로 파싱하여 상세 조회 기능을 고도화했습니다. --- src/kbo/game-detail.ts | 70 +----- src/kbo/play-by-play.ts | 497 ++++++++++++++++++++++++++++++++++++++++ src/types/kbo.ts | 11 + 3 files changed, 520 insertions(+), 58 deletions(-) create mode 100644 src/kbo/play-by-play.ts diff --git a/src/kbo/game-detail.ts b/src/kbo/game-detail.ts index 4648634..e1463fe 100644 --- a/src/kbo/game-detail.ts +++ b/src/kbo/game-detail.ts @@ -1,6 +1,7 @@ import { stripTags, decodeHtmlEntities } from "./html-utils.js"; import { LeagueCode, LEAGUE_CODES } from "./game-list.js"; import { SERIES_ID } from "./schedule.js"; +import { fetchScoringPlays, type GameScoringPlays } from "./play-by-play.js"; const BASE_URL = "https://www.koreabaseball.com/ws/Schedule.asmx"; @@ -284,58 +285,6 @@ export async function fetchScoreBoardScroll( return parseScoreBoardScroll(raw); } -// ── 1b. Naver 이닝별 득점 보강 ── -// KBO ScoreBoardScroll은 이닝별 득점(table2)을 비워서 내려주는 경우가 있어, -// Naver Sports relay 엔드포인트의 inningScore만 가져와 보강한다. - -interface NaverRelayResponse { - code: number; - success: boolean; - result?: { - textRelayData?: { - inningScore?: { - home?: Record; - away?: Record; - }; - }; - }; -} - -export async function fetchNaverInningScores( - gameId: string -): Promise { - const url = `https://api-gw.sports.naver.com/schedule/games/${encodeURIComponent(gameId)}/relay`; - const res = await fetch(url, { - headers: { "User-Agent": USER_AGENT, Accept: "application/json" }, - }); - if (!res.ok) return null; - const json = (await res.json()) as NaverRelayResponse; - const score = json.result?.textRelayData?.inningScore; - if (!score) return null; - - const inningKeys = new Set(); - for (const k of Object.keys(score.home ?? {})) { - const n = parseInt(k, 10); - if (Number.isFinite(n)) inningKeys.add(n); - } - for (const k of Object.keys(score.away ?? {})) { - const n = parseInt(k, 10); - if (Number.isFinite(n)) inningKeys.add(n); - } - if (inningKeys.size === 0) return null; - - const sorted = [...inningKeys].sort((a, b) => a - b); - return sorted.map((inning) => ({ - inning, - away: toInt(score.away?.[String(inning)]), - home: toInt(score.home?.[String(inning)]), - })); -} - -function hasInningScores(innings: InningScore[]): boolean { - return innings.some((s) => s.away != null || s.home != null); -} - // ── 2. BoxScoreScroll ── interface RawBoxScoreScroll { @@ -624,21 +573,26 @@ export interface GameDetail { hitter: KeyPlayerRanking; pitcher: KeyPlayerRanking; }; + /** Naver highLight에서 추출한 득점 타석 목록. 호출 실패 시 null. */ + scoringPlays: GameScoringPlays | null; } export async function fetchGameDetail( filters: GameDetailFilters ): Promise { - const [scoreBoard, boxScore, keyHitter, keyPitcher] = await Promise.all([ - fetchScoreBoardScroll(filters), - fetchBoxScoreScroll(filters), - fetchKeyPlayerHitter(filters), - fetchKeyPlayerPitcher(filters), - ]); + const [scoreBoard, boxScore, keyHitter, keyPitcher, scoringPlays] = + await Promise.all([ + fetchScoreBoardScroll(filters), + fetchBoxScoreScroll(filters), + fetchKeyPlayerHitter(filters), + fetchKeyPlayerPitcher(filters), + fetchScoringPlays(filters.gameId).catch(() => null), + ]); return { gameId: filters.gameId, scoreBoard, boxScore, keyPlayer: { hitter: keyHitter, pitcher: keyPitcher }, + scoringPlays, }; } diff --git a/src/kbo/play-by-play.ts b/src/kbo/play-by-play.ts new file mode 100644 index 0000000..0d176f4 --- /dev/null +++ b/src/kbo/play-by-play.ts @@ -0,0 +1,497 @@ +// Naver Sports의 highLight 엔드포인트에서 득점이 발생한 타석만 추출한다. +// +// GET https://api-gw.sports.naver.com/schedule/games/{gameId}/relay/highLight +// +// 한 번의 호출로 전체 경기의 득점 타석이 시간 역순으로 모두 들어온다. KBO +// 공식 응답에 없는 다음 정보를 보강한다: +// +// - 어느 타석에서 점수가 났는지 (타자·결과 텍스트) +// - 누가 어느 베이스에서 홈인했는지 +// - 그 시점의 아웃 카운트·스코어 추이 +// +// 인증 불필요. 종료된 경기는 데이터 불변, 라이브 중에는 그 시점까지 누적된 +// 득점만 들어온다. + +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36"; + +const HIGHLIGHT_BASE = "https://api-gw.sports.naver.com/schedule/games"; + +// ── Public types ── + +export type ScoringOutcome = + | "homerun" + | "single" + | "double" + | "triple" + | "walk" + | "hit_by_pitch" + | "sac_fly" + | "sac_bunt" + | "fielders_choice" + | "error" + | "wild_pitch" + | "passed_ball" + | "balk" + | "other"; + +export interface BaseRunner { + pcode: string; + name: string; +} + +export interface BaseState { + first: BaseRunner | null; + second: BaseRunner | null; + third: BaseRunner | null; +} + +export interface ScoringRunner { + /** 1·2·3루 주자였는지, "batter"이면 타자가 직접 홈인(홈런 등). */ + fromBase: 1 | 2 | 3 | "batter"; + name: string; + /** "실책으로 홈인" 같은 부가 설명. */ + note?: string; +} + +export interface ScoringBatter { + pcode: string; + name: string; + batOrder: number; + position: string; + seasonAvg: number; + todayAvg?: number; +} + +export interface ScoringPitcher { + pcode: string; + name: string; +} + +export interface ScoringPlay { + inning: number; + half: "top" | "bottom"; + /** 공격팀 코드 (gameId에서 파싱). 표시용 이름은 KBO scoreBoard에서 매핑. */ + battingTeamCode: string; + + /** 타석 진입 직후~결과 직전 상태. */ + outsBefore: 0 | 1 | 2; + basesBefore: BaseState; + scoreBefore: { home: number; away: number }; + + /** 타석 종료 시점. */ + outsAfter: 0 | 1 | 2 | 3; + basesAfter: BaseState; + scoreAfter: { home: number; away: number }; + + /** 이 타석에서 들어온 점수 수. */ + runsScored: number; + + batter: ScoringBatter; + pitcher: ScoringPitcher; + + outcome: ScoringOutcome; + /** 타석 결과 원문. 예: "박건우 : 우익수 뒤 홈런 (홈런거리:110M)" */ + resultText: string; + /** 결과 부가 정보. 예: "홈런거리:110M" */ + resultDetail?: string; + + /** 이 타석으로 홈인한 주자들. 시간순. */ + scoringRunners: ScoringRunner[]; +} + +export interface GameScoringPlays { + gameId: string; + plays: ScoringPlay[]; +} + +// ── Naver raw shapes ── + +interface NaverLineupBatter { + pcode?: string; + name?: string; + batOrder?: number; + posName?: string; + seasonHra?: number; + todayHra?: number; +} + +interface NaverLineupPitcher { + pcode?: string; + name?: string; +} + +interface NaverEntryPlayer { + pcode?: string; + name?: string; +} + +interface NaverGameState { + homeScore?: string; + awayScore?: string; + pitcher?: string; + batter?: string; + out?: string; + base1?: string; + base2?: string; + base3?: string; +} + +interface NaverBatterRecord { + pcode?: string; + name?: string; + batOrder?: number; + posName?: string; + seasonHra?: number; + todayHra?: number; +} + +interface NaverTextOption { + text?: string; + type?: number; + seqno?: number; + currentGameState?: NaverGameState; + batterRecord?: NaverBatterRecord; +} + +interface NaverTextRelay { + title?: string; + no?: number; + inn?: number; + homeOrAway?: string; + textOptions?: NaverTextOption[]; +} + +interface NaverHighLightData { + gameId?: string; + textRelays?: NaverTextRelay[]; + homeEntry?: { batter?: NaverEntryPlayer[]; pitcher?: NaverEntryPlayer[] }; + awayEntry?: { batter?: NaverEntryPlayer[]; pitcher?: NaverEntryPlayer[] }; + homeLineup?: { batter?: NaverLineupBatter[]; pitcher?: NaverLineupPitcher[] }; + awayLineup?: { batter?: NaverLineupBatter[]; pitcher?: NaverLineupPitcher[] }; +} + +interface NaverHighLightResponse { + result?: { textRelayData?: NaverHighLightData }; +} + +// ── helpers ── + +interface PlayerInfo { + pcode: string; + name: string; + batOrder?: number; + position?: string; + seasonAvg?: number; + todayAvg?: number; +} + +type PcodeMap = Map; +type BatOrderMap = Map; + +function buildPcodeMap(data: NaverHighLightData): PcodeMap { + const map: PcodeMap = new Map(); + const upsert = (info: PlayerInfo) => { + if (!info.pcode) return; + const prev = map.get(info.pcode); + map.set(info.pcode, { + pcode: info.pcode, + name: info.name || prev?.name || "", + batOrder: info.batOrder ?? prev?.batOrder, + position: info.position ?? prev?.position, + seasonAvg: info.seasonAvg ?? prev?.seasonAvg, + todayAvg: info.todayAvg ?? prev?.todayAvg, + }); + }; + + for (const src of [data.homeLineup, data.awayLineup]) { + for (const b of src?.batter ?? []) { + if (!b.pcode) continue; + upsert({ + pcode: b.pcode, + name: b.name ?? "", + batOrder: b.batOrder, + position: b.posName, + seasonAvg: b.seasonHra, + todayAvg: b.todayHra, + }); + } + for (const p of src?.pitcher ?? []) { + if (!p.pcode) continue; + upsert({ pcode: p.pcode, name: p.name ?? "" }); + } + } + for (const src of [data.homeEntry, data.awayEntry]) { + for (const b of src?.batter ?? []) { + if (!b.pcode) continue; + upsert({ pcode: b.pcode, name: b.name ?? "" }); + } + for (const p of src?.pitcher ?? []) { + if (!p.pcode) continue; + upsert({ pcode: p.pcode, name: p.name ?? "" }); + } + } + return map; +} + +// 베이스 필드(`base1/2/3`)에는 pcode가 아니라 그 슬롯에 있는 주자의 batOrder +// 코드(1~9 string, "0"=빈루)가 들어온다. batOrder→{pcode,name} 매핑을 양팀 +// 라인업에서 빌드해 두면 베이스 점유 주자 식별이 가능하다. +function buildBatOrderMap( + lineup: { batter?: NaverLineupBatter[] } | undefined +): BatOrderMap { + const map: BatOrderMap = new Map(); + for (const b of lineup?.batter ?? []) { + if (!b.batOrder || !b.pcode) continue; + if (!map.has(b.batOrder)) { + map.set(b.batOrder, { pcode: b.pcode, name: b.name ?? "" }); + } + } + return map; +} + +function toIntStrict(s: string | undefined, fallback: number): number { + if (s == null) return fallback; + const n = parseInt(s, 10); + return Number.isFinite(n) ? n : fallback; +} + +function clampOuts0to2(n: number): 0 | 1 | 2 { + if (n <= 0) return 0; + if (n >= 2) return 2; + return 1; +} + +function clampOuts0to3(n: number): 0 | 1 | 2 | 3 { + if (n <= 0) return 0; + if (n >= 3) return 3; + return n as 1 | 2; +} + +function parseBaseState( + state: NaverGameState | undefined, + battingTeamMap: BatOrderMap +): BaseState { + const slot = (raw: string | undefined): BaseRunner | null => { + if (!raw || raw === "0") return null; + const order = parseInt(raw, 10); + if (!Number.isFinite(order)) return null; + const looked = battingTeamMap.get(order); + if (looked) return { pcode: looked.pcode, name: looked.name }; + // 매칭 실패 시 raw 코드 보존 + return { pcode: raw, name: "" }; + }; + return { + first: slot(state?.base1), + second: slot(state?.base2), + third: slot(state?.base3), + }; +} + +// gameId 형식: `YYYYMMDD{AWAY}{HOME}{N}{YYYY}` (예: 20260429HTNC02026 → HT/NC). +function parseGameIdTeams(gameId: string): { away: string; home: string } { + const m = gameId.match(/^\d{8}([A-Z]{2})([A-Z]{2})/); + if (!m) return { away: "", home: "" }; + return { away: m[1], home: m[2] }; +} + +// KBO 게임센터는 13자리(`YYYYMMDDXXYY0`)를, Naver는 17자리(`...YYYY` 시즌 suffix) +// 를 쓴다. 13자리가 들어오면 앞 4자리(연도)를 suffix로 붙여 17자리로 변환한다. +function toNaverGameId(gameId: string): string { + if (/^\d{8}[A-Z]{2}[A-Z]{2}\d{5}$/.test(gameId)) return gameId; + if (/^\d{8}[A-Z]{2}[A-Z]{2}\d$/.test(gameId)) { + return gameId + gameId.slice(0, 4); + } + return gameId; +} + +// ── outcome 분류 ── + +function classifyOutcome(text: string): ScoringOutcome { + if (/홈런/.test(text)) return "homerun"; + if (/3루타/.test(text)) return "triple"; + if (/2루타/.test(text)) return "double"; + if (/희생플라이|희비/.test(text)) return "sac_fly"; + if (/희생번트/.test(text)) return "sac_bunt"; + if (/야수선택/.test(text)) return "fielders_choice"; + if (/실책/.test(text)) return "error"; + if (/4구|볼넷|고의4구/.test(text)) return "walk"; + if (/사구|몸에\s*맞는/.test(text)) return "hit_by_pitch"; + if (/안타|1루타/.test(text)) return "single"; + if (/폭투/.test(text)) return "wild_pitch"; + if (/포일|패스볼/.test(text)) return "passed_ball"; + if (/보크/.test(text)) return "balk"; + return "other"; +} + +// "X루주자 이름 : 홈인" / "X루주자 이름 : 실책으로 홈인" 같은 텍스트에서 +// 주자 홈인 정보를 뽑는다. 진루·아웃·태그아웃 등 비득점 이벤트는 null. +function parseRunnerScoring(text: string): ScoringRunner | null { + const m = text.match(/([1-3])루주자\s+([^\s:]+)\s*:\s*(.+)/); + if (!m) return null; + const note = m[3].trim(); + if (!/홈인/.test(note)) return null; + const fromBase = parseInt(m[1], 10) as 1 | 2 | 3; + const runner: ScoringRunner = { fromBase, name: m[2] }; + if (note !== "홈인") runner.note = note; + return runner; +} + +// ── per-entry parser ── + +function parseScoringEntry( + entry: NaverTextRelay, + pcodeMap: PcodeMap, + homeBatOrder: BatOrderMap, + awayBatOrder: BatOrderMap, + teams: { away: string; home: string } +): ScoringPlay | null { + const opts = entry.textOptions ?? []; + if (opts.length === 0) return null; + + const firstState = opts[0]?.currentGameState ?? {}; + const lastState = opts[opts.length - 1]?.currentGameState ?? firstState; + + const homeBefore = toIntStrict(firstState.homeScore, 0); + const awayBefore = toIntStrict(firstState.awayScore, 0); + const homeAfter = toIntStrict(lastState.homeScore, homeBefore); + const awayAfter = toIntStrict(lastState.awayScore, awayBefore); + const runsScored = Math.max( + 0, + homeAfter - homeBefore + (awayAfter - awayBefore) + ); + if (runsScored === 0) return null; + + const half: "top" | "bottom" = entry.homeOrAway === "1" ? "bottom" : "top"; + const battingTeamMap = half === "top" ? awayBatOrder : homeBatOrder; + const battingTeamCode = half === "top" ? teams.away : teams.home; + + const outsBefore = clampOuts0to2(toIntStrict(firstState.out, 0)); + const outsAfter = clampOuts0to3( + Math.max(toIntStrict(lastState.out, outsBefore), outsBefore) + ); + + const basesBefore = parseBaseState(firstState, battingTeamMap); + const basesAfter = parseBaseState(lastState, battingTeamMap); + + // 결과 텍스트: type 23(타자 결과)을 우선, 없으면 마지막 textOption.text. + let resultOption: NaverTextOption | undefined; + for (const o of opts) { + if (o.type === 23) resultOption = o; + } + const resultText = resultOption?.text ?? opts[opts.length - 1].text ?? ""; + const detailMatch = resultText.match(/\(([^)]+)\)/); + const outcome = classifyOutcome(resultText); + + // 타자 / 투수 + const br = opts.find((o) => o.batterRecord)?.batterRecord; + const batterPcode = br?.pcode ?? lastState.batter ?? firstState.batter ?? ""; + const looked = pcodeMap.get(batterPcode); + const batter: ScoringBatter = { + pcode: batterPcode, + name: br?.name ?? looked?.name ?? "", + batOrder: br?.batOrder ?? looked?.batOrder ?? 0, + position: br?.posName ?? looked?.position ?? "", + seasonAvg: br?.seasonHra ?? looked?.seasonAvg ?? 0, + }; + const todayAvg = br?.todayHra ?? looked?.todayAvg; + if (todayAvg != null) batter.todayAvg = todayAvg; + const pitcherPcode = lastState.pitcher ?? firstState.pitcher ?? ""; + const pitcher: ScoringPitcher = { + pcode: pitcherPcode, + name: pcodeMap.get(pitcherPcode)?.name ?? "", + }; + + // 주자 이동: type=24 이벤트와 type=23(홈런 시) 본인 홈인을 모은다. + const scoringRunners: ScoringRunner[] = []; + for (const o of opts) { + if (o.type === 24 && o.text) { + const r = parseRunnerScoring(o.text); + if (r) scoringRunners.push(r); + } + } + // 타자 본인 홈인 (홈런이거나, 결과 텍스트에 본인 이름과 "홈인"이 함께) + const batterScored = + outcome === "homerun" || + (batter.name && new RegExp(`${batter.name}\\s*:.*홈인`).test(resultText)); + if (batterScored) { + scoringRunners.unshift({ fromBase: "batter", name: batter.name }); + } + + const play: ScoringPlay = { + inning: entry.inn ?? 0, + half, + battingTeamCode, + outsBefore, + basesBefore, + scoreBefore: { home: homeBefore, away: awayBefore }, + outsAfter, + basesAfter, + scoreAfter: { home: homeAfter, away: awayAfter }, + runsScored, + batter, + pitcher, + outcome, + resultText, + scoringRunners, + }; + if (detailMatch) play.resultDetail = detailMatch[1]; + return play; +} + +// ── public parse ── + +export function parseScoringPlays( + raw: NaverHighLightResponse, + gameId: string +): GameScoringPlays { + const data = raw.result?.textRelayData; + if (!data || !Array.isArray(data.textRelays)) { + return { gameId, plays: [] }; + } + const pcodeMap = buildPcodeMap(data); + const homeBatOrder = buildBatOrderMap(data.homeLineup); + const awayBatOrder = buildBatOrderMap(data.awayLineup); + const teams = parseGameIdTeams(gameId); + // textRelays는 보통 no DESC. 시간순(ASC) 정렬. + const sorted = [...data.textRelays].sort((a, b) => (a.no ?? 0) - (b.no ?? 0)); + const plays: ScoringPlay[] = []; + for (const entry of sorted) { + const play = parseScoringEntry( + entry, + pcodeMap, + homeBatOrder, + awayBatOrder, + teams + ); + if (play) plays.push(play); + } + return { gameId, plays }; +} + +// ── public fetch ── + +export async function fetchScoringPlays( + gameId: string +): Promise { + const naverGameId = toNaverGameId(gameId); + const url = `${HIGHLIGHT_BASE}/${encodeURIComponent(naverGameId)}/relay/highLight`; + let res: Response; + try { + res = await fetch(url, { + headers: { "User-Agent": USER_AGENT, Accept: "application/json" }, + }); + } catch { + return null; + } + if (!res.ok) return null; + let json: NaverHighLightResponse; + try { + json = (await res.json()) as NaverHighLightResponse; + } catch { + return null; + } + if (!json.result?.textRelayData) return null; + // 호출자가 KBO 13자리를 넘겨도 결과의 gameId는 호출 시 입력값 그대로 보존. + return parseScoringPlays(json, gameId); +} diff --git a/src/types/kbo.ts b/src/types/kbo.ts index fd32252..45474ea 100644 --- a/src/types/kbo.ts +++ b/src/types/kbo.ts @@ -52,4 +52,15 @@ export type { KeyPlayerGroup, } from "../kbo/game-detail.js"; +export type { + GameScoringPlays, + ScoringPlay, + ScoringBatter, + ScoringPitcher, + ScoringRunner, + ScoringOutcome, + BaseState, + BaseRunner, +} from "../kbo/play-by-play.js"; + export { SERIES_ID } from "../kbo/schedule.js";