Integrate scoring play analysis into game detail retrieval.

- Naver Sports의 highLight 엔드포인트를 통해 득점 발생 타석의 상세 데이터를 추출하는 기능을 구현했습니다.
- 단순 이닝별 점수 보강을 넘어 타석 결과, 주자 이동, 아웃 카운트 및 베이스 상태 변화를 포함한 정밀한 정보를 제공합니다.
- KBO와 네이버 간의 경기 ID 체계 차이를 자동으로 처리하며, 병렬 요청 구조로 전체 조회 성능을 최적화했습니다.
- 득점 주자의 출루 위치와 타점 발생 상황을 구조화된 데이터로 파싱하여 상세 조회 기능을 고도화했습니다.
This commit is contained in:
윤정민 2026-05-04 08:30:35 +09:00
parent 5888d446b7
commit 5149ecf82a
3 changed files with 520 additions and 58 deletions

View File

@ -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<string, string>;
away?: Record<string, string>;
};
};
};
}
export async function fetchNaverInningScores(
gameId: string
): Promise<InningScore[] | null> {
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<number>();
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<GameDetail> {
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,
};
}

497
src/kbo/play-by-play.ts Normal file
View File

@ -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<string, PlayerInfo>;
type BatOrderMap = Map<number, BaseRunner>;
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<GameScoringPlays | null> {
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);
}

View File

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