// 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); }