Implement KBO game detail retrieval with dynamic caching and API.

- KBO 게임센터의 스코어보드, 박스스코어, 키플레이어 데이터를 통합 조회하는 기능을 구현했습니다.
- 경기 상태(종료, 진행 중, 시작 전)에 따라 TTL을 30초에서 7일까지 유연하게 적용하는 동적 캐싱 시스템을 도입했습니다.
- KBO 공식 데이터에서 이닝별 득점이 누락되는 경우를 대비해 네이버 스포츠 API를 통한 데이터 보완 로직을 추가했습니다.
- Firestore의 중첩 배열 저장 제한을 회피하기 위해 테이블 데이터를 객체 배열 구조로 파싱하도록 설계했습니다.
- CLI 상세 조회 명령어와 REST API 엔드포인트를 추가하고, HTML 엔티티 디코딩 및 관련 단위 테스트를 보완했습니다.
This commit is contained in:
윤정민 2026-04-30 16:34:09 +09:00
parent 360530ad13
commit 5888d446b7
9 changed files with 1030 additions and 0 deletions

View File

@ -3,6 +3,7 @@ import { getRank } from "../services/rankService.js";
import { getSchedule } from "../services/scheduleService.js"; import { getSchedule } from "../services/scheduleService.js";
import { getPlayerStats, getValidPlayerTypes } from "../services/playerService.js"; import { getPlayerStats, getValidPlayerTypes } from "../services/playerService.js";
import { getGameList } from "../services/gameListService.js"; import { getGameList } from "../services/gameListService.js";
import { getGameDetail } from "../services/gameDetailService.js";
import type { PlayerFilters } from "../types/kbo.js"; import type { PlayerFilters } from "../types/kbo.js";
enum KboPath { enum KboPath {
@ -10,6 +11,7 @@ enum KboPath {
Schedule = "schedule", Schedule = "schedule",
Player = "player", Player = "player",
Games = "games", Games = "games",
GameDetail = "gameDetail",
} }
function today(): string { function today(): string {
@ -30,6 +32,7 @@ function parseIntParam(val: unknown, fallback: number): number {
// GET /kbo/schedule?year=2026&month=4&day=14&team=LG&series=정규 // GET /kbo/schedule?year=2026&month=4&day=14&team=LG&series=정규
// GET /kbo/player?type=hitter&year=2025&team=LG&series=7&all=true // GET /kbo/player?type=hitter&year=2025&team=LG&series=7&all=true
// GET /kbo/games?date=20260414&series=regular&league=kbo // GET /kbo/games?date=20260414&series=regular&league=kbo
// GET /kbo/gameDetail?gameId=20250920SSLG0&series=regular&league=kbo&season=2025
export const kbo = onRequest(async (req, res) => { export const kbo = onRequest(async (req, res) => {
const path = req.path.replace(/^\/+|\/+$/g, "").split("/").pop() ?? ""; const path = req.path.replace(/^\/+|\/+$/g, "").split("/").pop() ?? "";
@ -95,6 +98,30 @@ export const kbo = onRequest(async (req, res) => {
return; return;
} }
case KboPath.GameDetail: {
const gameId = req.query.gameId ? String(req.query.gameId) : "";
const series = req.query.series ? String(req.query.series) : undefined;
const league = req.query.league ? String(req.query.league) : undefined;
const seasonStr = req.query.season ? String(req.query.season) : undefined;
if (!/^\d{8}[A-Z]{2}[A-Z]{2}\d$/.test(gameId)) {
res.status(400).json({
error: `Invalid gameId: "${gameId}". Expected like 20250920SSLG0.`,
});
return;
}
const season = seasonStr !== undefined ? parseIntParam(seasonStr, NaN) : undefined;
if (season !== undefined && (isNaN(season) || season < 1982 || season > 2100)) {
res.status(400).json({ error: `Invalid season: ${seasonStr} (1982~2100)` });
return;
}
const result = await getGameDetail({ gameId, series, league, season });
res.status(200).json(result);
return;
}
case KboPath.Player: { case KboPath.Player: {
const type = String(req.query.type ?? ""); const type = String(req.query.type ?? "");
const validTypes = getValidPlayerTypes(); const validTypes = getValidPlayerTypes();

View File

@ -49,6 +49,7 @@ import {
type GameListRecord, type GameListRecord,
type GameListResult, type GameListResult,
} from "./game-list.js"; } from "./game-list.js";
import { fetchGameDetail, type GameDetail } from "./game-detail.js";
const PLAYER_CONFIGS: Record<string, PlayerPageConfig> = { const PLAYER_CONFIGS: Record<string, PlayerPageConfig> = {
hitter: HITTER_CONFIG, hitter: HITTER_CONFIG,
@ -287,6 +288,78 @@ function printGameListTable(result: GameListResult) {
console.log(`\n Total: ${result.games.length} games`); console.log(`\n Total: ${result.games.length} games`);
} }
function printGameDetail(d: GameDetail) {
const { meta, innings, totals, maxInning } = d.scoreBoard;
console.log(`\n${"═".repeat(80)}`);
console.log(
` ${meta.awayTeam.fullName} vs ${meta.homeTeam.fullName} (${meta.gameDate} @ ${meta.stadium})`
);
console.log(
` 관중 ${meta.crowd} ${meta.startTime} ~ ${meta.endTime || "진행중"} 소요 ${meta.useTime || "-"}`
);
console.log(`${"═".repeat(80)}`);
const inningHeader = ["팀", ...Array.from({ length: maxInning }, (_, i) => String(i + 1)), "R", "H", "E", "B"];
console.log(" " + inningHeader.map((h) => padCell(h, 4)).join(""));
const fmt = (s: number | null) => (s == null ? "-" : String(s));
const awayRow = [
meta.awayTeam.name,
...Array.from({ length: maxInning }, (_, i) => {
const inn = innings.find((x) => x.inning === i + 1);
return fmt(inn?.away ?? null);
}),
fmt(totals.away.runs),
fmt(totals.away.hits),
fmt(totals.away.errors),
fmt(totals.away.walks),
];
const homeRow = [
meta.homeTeam.name,
...Array.from({ length: maxInning }, (_, i) => {
const inn = innings.find((x) => x.inning === i + 1);
return fmt(inn?.home ?? null);
}),
fmt(totals.home.runs),
fmt(totals.home.hits),
fmt(totals.home.errors),
fmt(totals.home.walks),
];
console.log(" " + awayRow.map((c) => padCell(c, 4)).join(""));
console.log(" " + homeRow.map((c) => padCell(c, 4)).join(""));
if (d.boxScore.events.length > 0) {
console.log(`\n [기록]`);
for (const e of d.boxScore.events) {
console.log(` ${e.label}: ${e.value}`);
}
}
console.log(`\n [키플레이어 - 타자 (WPA)]`);
for (const p of d.keyPlayer.hitter.items) {
console.log(` ${p.rank}. ${p.playerName}(${p.teamId}) ${p.recordText}`);
}
console.log(`\n [키플레이어 - 투수 (WPA)]`);
for (const p of d.keyPlayer.pitcher.items) {
console.log(` ${p.rank}. ${p.playerName}(${p.teamId}) ${p.recordText}`);
}
}
async function detailCommand(
gameId: string,
jsonMode: boolean,
series?: string,
league?: string,
season?: number
) {
console.log(`Fetching KBO game detail for ${gameId}...\n`);
const result = await fetchGameDetail({ gameId, series, league, season });
if (jsonMode) {
console.log(JSON.stringify(result, null, 2));
} else {
printGameDetail(result);
}
}
async function gamesCommand( async function gamesCommand(
date: string, date: string,
jsonMode: boolean, jsonMode: boolean,
@ -443,6 +516,7 @@ function printHelp() {
console.log(" player <hitter|pitcher|defense|runner> Player stats"); console.log(" player <hitter|pitcher|defense|runner> Player stats");
console.log(" schedule [year] [month] 경기 일정/결과"); console.log(" schedule [year] [month] 경기 일정/결과");
console.log(" games [YYYYMMDD] 게임센터 라이브 조회"); console.log(" games [YYYYMMDD] 게임센터 라이브 조회");
console.log(" detail <gameId> 경기 상세 (스코어보드/박스/키플레이어)");
console.log(""); console.log("");
console.log("Options:"); console.log("Options:");
console.log(" --json JSON output"); console.log(" --json JSON output");
@ -534,6 +608,46 @@ async function main() {
return; return;
} }
if (command === "detail") {
const rest = args.slice(1);
let jsonMode = false;
let gameId: string | undefined;
let dSeries: string | undefined;
let dLeague: string | undefined;
let dSeason: number | undefined;
for (const arg of rest) {
if (arg === "--json") {
jsonMode = true;
} else if (arg.startsWith("--series=")) {
dSeries = arg.slice("--series=".length);
} else if (arg.startsWith("--league=")) {
dLeague = arg.slice("--league=".length);
} else if (arg.startsWith("--season=")) {
const n = parseInt(arg.slice("--season=".length), 10);
if (isNaN(n) || n < 1982 || n > 2100) {
console.error(`Invalid --season: ${arg} (1982~2100)`);
process.exit(1);
}
dSeason = n;
} else if (/^\d{8}[A-Z]{2}[A-Z]{2}\d$/.test(arg)) {
gameId = arg;
} else {
console.error(`Invalid argument: ${arg}`);
process.exit(1);
}
}
if (!gameId) {
console.error("Usage: detail <gameId> [--season=YYYY] [--series=정규] [--league=kbo] [--json]");
console.error(" gameId 형식: YYYYMMDD + 원정 + 홈 + 더블헤더(0/1) (예: 20250920SSLG0)");
process.exit(1);
}
await detailCommand(gameId, jsonMode, dSeries, dLeague, dSeason);
return;
}
if (command === "games") { if (command === "games") {
const rest = args.slice(1); const rest = args.slice(1);
let jsonMode = false; let jsonMode = false;

644
src/kbo/game-detail.ts Normal file
View File

@ -0,0 +1,644 @@
import { stripTags, decodeHtmlEntities } from "./html-utils.js";
import { LeagueCode, LEAGUE_CODES } from "./game-list.js";
import { SERIES_ID } from "./schedule.js";
const BASE_URL = "https://www.koreabaseball.com/ws/Schedule.asmx";
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36";
const COMMON_HEADERS: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
Accept: "application/json, text/javascript, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
"User-Agent": USER_AGENT,
Referer: "https://www.koreabaseball.com/Schedule/GameCenter/Main.aspx",
};
// ── Filters / 공통 옵션 ──
export interface GameDetailFilters {
gameId: string;
league?: LeagueCode | string;
series?: number | string;
season?: number;
}
interface ResolvedFilters {
gameId: string;
leId: number;
srId: number;
seasonId: number;
}
function resolveFilters(f: GameDetailFilters): ResolvedFilters {
const leId =
typeof f.league === "number"
? f.league
: Number(LEAGUE_CODES[f.league ?? ""] ?? f.league ?? LeagueCode.KBO);
const srId =
typeof f.series === "number"
? f.series
: f.series != null && f.series !== ""
? (SERIES_ID[String(f.series)] ?? Number(f.series))
: 0;
const seasonId = f.season ?? Number(f.gameId.slice(0, 4));
if (!Number.isFinite(leId) || !Number.isFinite(srId) || !Number.isFinite(seasonId)) {
throw new Error(
`Invalid filters: league=${f.league} series=${f.series} season=${f.season}`
);
}
return { gameId: f.gameId, leId, srId, seasonId };
}
function bodyOf(p: Record<string, string | number>): string {
const u = new URLSearchParams();
for (const [k, v] of Object.entries(p)) u.set(k, String(v));
return u.toString();
}
async function postJson<T>(path: string, body: string): Promise<T> {
const res = await fetch(`${BASE_URL}/${path}`, {
method: "POST",
headers: COMMON_HEADERS,
body,
});
if (!res.ok) {
throw new Error(`${path} failed: HTTP ${res.status}`);
}
return (await res.json()) as T;
}
// ── ASP.NET MakeTable 파싱 ──
interface RawCell {
Text: string;
Class: string | null;
}
interface RawRow {
row: RawCell[];
}
interface RawTable {
colgroup?: RawRow[];
headers?: RawRow[];
rows?: RawRow[];
tfoot?: RawRow[];
}
// Firestore는 직접 중첩된 배열(`string[][]`)을 거부한다. 모든 행은 객체로 한 번
// 감싸 `{ cells: string[] }[]` 형태로 노출한다.
export interface ParsedTableRow {
cells: string[];
}
export interface ParsedTable {
headers: ParsedTableRow[];
rows: ParsedTableRow[];
tfoot: ParsedTableRow[];
}
function clean(text: string): string {
return stripTags(decodeHtmlEntities(text)).replace(/\s+/g, " ").trim();
}
function parseTableJson(raw: string | RawTable | undefined): ParsedTable {
if (!raw) return { headers: [], rows: [], tfoot: [] };
const t: RawTable = typeof raw === "string" ? JSON.parse(raw) : raw;
const toRows = (rows?: RawRow[]): ParsedTableRow[] =>
(rows ?? []).map((r) => ({
cells: (r.row ?? []).map((c) => clean(c.Text ?? "")),
}));
return {
headers: toRows(t.headers),
rows: toRows(t.rows),
tfoot: toRows(t.tfoot),
};
}
function toInt(s: string | undefined): number | null {
if (s == null) return null;
const trimmed = s.trim();
if (trimmed === "" || trimmed === "-") return null;
const n = parseInt(trimmed.replace(/,/g, ""), 10);
return Number.isFinite(n) ? n : null;
}
// ── 1. ScoreBoardScroll ──
interface RawScoreBoardScroll {
LE_ID: number;
SR_ID: number;
G_ID: string;
G_DT: string;
SEASON_ID: number;
HOME_NM: string;
HOME_ID: string;
AWAY_NM: string;
AWAY_ID: string;
S_NM: string;
CROWD_CN: string;
H_W_CN: number;
H_L_CN: number;
H_D_CN: number;
A_W_CN: number;
A_L_CN: number;
A_D_CN: number;
T_SCORE_CN: number;
B_SCORE_CN: number;
START_TM: string;
END_TM: string;
USE_TM: string;
FULL_HOME_NM: string;
FULL_AWAY_NM: string;
H_INITIAL_LK: string;
A_INITIAL_LK: string;
table1?: string;
table2?: string;
table3?: string;
maxInning: number;
}
export interface ScoreBoardMeta {
gameId: string;
gameDate: string;
season: number;
leagueCode: number;
seriesCode: number;
stadium: string;
crowd: string;
startTime: string;
endTime: string;
useTime: string;
awayTeam: { id: string; name: string; fullName: string; emblem: string };
homeTeam: { id: string; name: string; fullName: string; emblem: string };
awayScore: number;
homeScore: number;
awaySeasonRecord: { wins: number; losses: number; draws: number };
homeSeasonRecord: { wins: number; losses: number; draws: number };
}
export interface InningScore {
inning: number;
away: number | null;
home: number | null;
}
export interface ScoreTotals {
runs: number | null;
hits: number | null;
errors: number | null;
walks: number | null;
}
export interface ScoreBoardScroll {
meta: ScoreBoardMeta;
innings: InningScore[];
totals: { away: ScoreTotals; home: ScoreTotals };
maxInning: number;
}
function parseInnings(t2: ParsedTable): InningScore[] {
const headerRow = t2.headers[0]?.cells ?? [];
const awayRow = t2.rows[0]?.cells ?? [];
const homeRow = t2.rows[1]?.cells ?? [];
const out: InningScore[] = [];
for (let i = 0; i < headerRow.length; i++) {
const inning = parseInt(headerRow[i], 10);
if (!Number.isFinite(inning)) continue;
out.push({
inning,
away: toInt(awayRow[i]),
home: toInt(homeRow[i]),
});
}
return out;
}
function parseTotalsRow(row: string[]): ScoreTotals {
return {
runs: toInt(row[0]),
hits: toInt(row[1]),
errors: toInt(row[2]),
walks: toInt(row[3]),
};
}
export function parseScoreBoardScroll(raw: RawScoreBoardScroll): ScoreBoardScroll {
if (!raw || !raw.G_ID) {
throw new Error(
"Game not found or unavailable for the given series. " +
"정규시즌(series=정규)와 포스트시즌(series=포스트)에 따라 srId가 다르므로, " +
"올바른 series로 다시 요청하세요."
);
}
const t2 = parseTableJson(raw.table2);
const t3 = parseTableJson(raw.table3);
return {
meta: {
gameId: raw.G_ID,
gameDate: raw.G_DT,
season: raw.SEASON_ID,
leagueCode: raw.LE_ID,
seriesCode: raw.SR_ID,
stadium: raw.S_NM,
crowd: raw.CROWD_CN,
startTime: raw.START_TM,
endTime: raw.END_TM,
useTime: raw.USE_TM,
awayTeam: {
id: raw.AWAY_ID,
name: raw.AWAY_NM,
fullName: raw.FULL_AWAY_NM,
emblem: raw.A_INITIAL_LK,
},
homeTeam: {
id: raw.HOME_ID,
name: raw.HOME_NM,
fullName: raw.FULL_HOME_NM,
emblem: raw.H_INITIAL_LK,
},
awayScore: raw.T_SCORE_CN,
homeScore: raw.B_SCORE_CN,
awaySeasonRecord: { wins: raw.A_W_CN, losses: raw.A_L_CN, draws: raw.A_D_CN },
homeSeasonRecord: { wins: raw.H_W_CN, losses: raw.H_L_CN, draws: raw.H_D_CN },
},
innings: parseInnings(t2),
totals: {
away: parseTotalsRow(t3.rows[0]?.cells ?? []),
home: parseTotalsRow(t3.rows[1]?.cells ?? []),
},
maxInning: raw.maxInning,
};
}
export async function fetchScoreBoardScroll(
filters: GameDetailFilters
): Promise<ScoreBoardScroll> {
const { gameId, leId, srId, seasonId } = resolveFilters(filters);
const raw = await postJson<RawScoreBoardScroll>(
"GetScoreBoardScroll",
bodyOf({ leId, srId, seasonId, gameId })
);
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 {
tableEtc?: string;
arrHitter?: { table1?: string; table2?: string; table3?: string }[];
arrPitcher?: { table?: string }[];
maxInning: number;
realMaxInning: number;
}
export interface BoxScoreEtc {
label: string;
value: string;
}
export interface BoxHitter {
/** 타순 — 동일 order에 여러 행이면 교체 출장. */
order: string;
/** 포지션 약어 (유/좌/중/우/1/2/3/포/지/타/대 등). */
position: string;
name: string;
/** 이닝별 결과(좌안, 4구, 삼진 등). 빈 셀은 "". 길이는 maxInning. */
inningResults: string[];
/** 그 경기 타수. */
ab: number | null;
/** 그 경기 안타. */
hits: number | null;
/** 그 경기 타점. */
rbi: number | null;
/** 그 경기 득점. */
runs: number | null;
/** 시즌 누적 타율. */
seasonAvg: number | null;
}
export interface BoxPitcher {
name: string;
/** 등판 유형 (선발/구원). */
appearance: string;
/** 결과 (승/패/세/홀/""). */
decision: string;
seasonWins: number | null;
seasonLosses: number | null;
seasonSaves: number | null;
/** 이닝 (예: "6", "1 2/3"). 분수 표기 유지. */
innings: string;
battersFaced: number | null;
pitches: number | null;
atBats: number | null;
hitsAllowed: number | null;
homeRunsAllowed: number | null;
/** 4사구(볼넷+사구). */
walksAndHbp: number | null;
strikeouts: number | null;
runs: number | null;
earnedRuns: number | null;
/** 시즌 누적 평균자책점. */
seasonEra: number | null;
}
export interface BoxScoreTeam {
hitters: BoxHitter[];
pitchers: BoxPitcher[];
/** 원본 테이블. 컬럼 추가/순서 변경 등 KBO 변경 대응용 escape hatch. */
raw: {
hitterMeta: ParsedTable;
hitterByInning: ParsedTable;
hitterTotals: ParsedTable;
pitchers: ParsedTable;
};
}
export interface BoxScoreScroll {
/** 결승타/홈런/2루타/실책/도루/병살타/심판 등. */
events: BoxScoreEtc[];
away: BoxScoreTeam;
home: BoxScoreTeam;
maxInning: number;
realMaxInning: number;
}
function toFloat(s: string | undefined): number | null {
if (s == null) return null;
const trimmed = s.trim();
if (trimmed === "" || trimmed === "-") return null;
const n = parseFloat(trimmed.replace(/,/g, ""));
return Number.isFinite(n) ? n : null;
}
function parseEvents(raw: string | undefined): BoxScoreEtc[] {
const t = parseTableJson(raw);
return t.rows
.map((r) => ({ label: r.cells[0] ?? "", value: r.cells[1] ?? "" }))
.filter((e) => e.label !== "");
}
function parseHitters(
meta: ParsedTable,
byInning: ParsedTable,
totals: ParsedTable,
maxInning: number
): BoxHitter[] {
const len = Math.min(meta.rows.length, byInning.rows.length, totals.rows.length);
const out: BoxHitter[] = [];
for (let i = 0; i < len; i++) {
const m = meta.rows[i].cells;
const inn = byInning.rows[i].cells;
const tot = totals.rows[i].cells;
// 이닝 결과를 maxInning 길이로 패딩 (부족하면 빈 문자열).
const inningResults: string[] = [];
for (let k = 0; k < maxInning; k++) inningResults.push(inn[k] ?? "");
out.push({
order: m[0] ?? "",
position: m[1] ?? "",
name: m[2] ?? "",
inningResults,
ab: toInt(tot[0]),
hits: toInt(tot[1]),
rbi: toInt(tot[2]),
runs: toInt(tot[3]),
seasonAvg: toFloat(tot[4]),
});
}
return out;
}
function parsePitchers(t: ParsedTable): BoxPitcher[] {
// 헤더 순서: 선수명, 등판, 결과, 승, 패, 세, 이닝, 타자, 투구수, 타수,
// 피안타, 홈런, 4사구, 삼진, 실점, 자책, 평균자책점
return t.rows.map((r) => {
const c = r.cells;
return {
name: c[0] ?? "",
appearance: c[1] ?? "",
decision: c[2] ?? "",
seasonWins: toInt(c[3]),
seasonLosses: toInt(c[4]),
seasonSaves: toInt(c[5]),
innings: c[6] ?? "",
battersFaced: toInt(c[7]),
pitches: toInt(c[8]),
atBats: toInt(c[9]),
hitsAllowed: toInt(c[10]),
homeRunsAllowed: toInt(c[11]),
walksAndHbp: toInt(c[12]),
strikeouts: toInt(c[13]),
runs: toInt(c[14]),
earnedRuns: toInt(c[15]),
seasonEra: toFloat(c[16]),
};
});
}
function buildTeam(
hitterBlob: { table1?: string; table2?: string; table3?: string } | undefined,
pitcherBlob: { table?: string } | undefined,
maxInning: number
): BoxScoreTeam {
const meta = parseTableJson(hitterBlob?.table1);
const byInning = parseTableJson(hitterBlob?.table2);
const totals = parseTableJson(hitterBlob?.table3);
const pitchersTable = parseTableJson(pitcherBlob?.table);
return {
hitters: parseHitters(meta, byInning, totals, maxInning),
pitchers: parsePitchers(pitchersTable),
raw: {
hitterMeta: meta,
hitterByInning: byInning,
hitterTotals: totals,
pitchers: pitchersTable,
},
};
}
export function parseBoxScoreScroll(raw: RawBoxScoreScroll): BoxScoreScroll {
const hitters = raw.arrHitter ?? [];
const pitchers = raw.arrPitcher ?? [];
const maxInning = raw.maxInning ?? 9;
return {
events: parseEvents(raw.tableEtc),
away: buildTeam(hitters[0], pitchers[0], maxInning),
home: buildTeam(hitters[1], pitchers[1], maxInning),
maxInning: raw.maxInning,
realMaxInning: raw.realMaxInning,
};
}
export async function fetchBoxScoreScroll(
filters: GameDetailFilters
): Promise<BoxScoreScroll> {
const { gameId, leId, srId, seasonId } = resolveFilters(filters);
const raw = await postJson<RawBoxScoreScroll>(
"GetBoxScoreScroll",
bodyOf({ leId, srId, seasonId, gameId })
);
return parseBoxScoreScroll(raw);
}
// ── 3. KeyPlayer (Hitter / Pitcher) ──
export type KeyPlayerGroup =
| "GAME_WPA_RT"
| "WPA_RT"
| "HRA_RT"
| "HIT_CN"
| "HR_CN"
| "RBI_CN"
| "OPS_RT";
interface RawKeyPlayer {
record?: {
RANK_NO: number;
P_ID: number;
P_NM: string;
T_ID: string;
RECORD_IF: string;
P_IMG_SEASON_ID: string;
T_PLAYER_LK: string;
}[];
code: string;
msg: string;
}
export interface KeyPlayerItem {
rank: number;
playerId: number;
playerName: string;
teamId: string;
recordText: string;
playerImage: string;
}
export interface KeyPlayerRanking {
groupSc: KeyPlayerGroup;
items: KeyPlayerItem[];
}
interface KeyPlayerOptions extends GameDetailFilters {
groupSc?: KeyPlayerGroup;
sort?: "DESC" | "ASC";
}
function parseKeyPlayer(raw: RawKeyPlayer, group: KeyPlayerGroup): KeyPlayerRanking {
return {
groupSc: group,
items: (raw.record ?? []).map((r) => ({
rank: r.RANK_NO,
playerId: r.P_ID,
playerName: r.P_NM,
teamId: r.T_ID,
recordText: clean(r.RECORD_IF),
playerImage: r.T_PLAYER_LK,
})),
};
}
async function fetchKeyPlayer(
path: "GetKeyPlayerHitter" | "GetKeyPlayerPitcher",
options: KeyPlayerOptions
): Promise<KeyPlayerRanking> {
const { gameId, leId, srId } = resolveFilters(options);
const groupSc = options.groupSc ?? "GAME_WPA_RT";
const sort = options.sort ?? "DESC";
const raw = await postJson<RawKeyPlayer>(
path,
bodyOf({ leId, srId, gameId, groupSc, sort })
);
return parseKeyPlayer(raw, groupSc);
}
export function fetchKeyPlayerHitter(options: KeyPlayerOptions): Promise<KeyPlayerRanking> {
return fetchKeyPlayer("GetKeyPlayerHitter", options);
}
export function fetchKeyPlayerPitcher(options: KeyPlayerOptions): Promise<KeyPlayerRanking> {
return fetchKeyPlayer("GetKeyPlayerPitcher", options);
}
// ── 4. 통합 GameDetail ──
export interface GameDetail {
gameId: string;
scoreBoard: ScoreBoardScroll;
boxScore: BoxScoreScroll;
keyPlayer: {
hitter: KeyPlayerRanking;
pitcher: KeyPlayerRanking;
};
}
export async function fetchGameDetail(
filters: GameDetailFilters
): Promise<GameDetail> {
const [scoreBoard, boxScore, keyHitter, keyPitcher] = await Promise.all([
fetchScoreBoardScroll(filters),
fetchBoxScoreScroll(filters),
fetchKeyPlayerHitter(filters),
fetchKeyPlayerPitcher(filters),
]);
return {
gameId: filters.gameId,
scoreBoard,
boxScore,
keyPlayer: { hitter: keyHitter, pitcher: keyPitcher },
};
}

View File

@ -13,6 +13,7 @@ export function stripTags(html: string): string {
export function decodeHtmlEntities(text: string): string { export function decodeHtmlEntities(text: string): string {
return text return text
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&") .replace(/&amp;/g, "&")
.replace(/&lt;/g, "<") .replace(/&lt;/g, "<")
.replace(/&gt;/g, ">") .replace(/&gt;/g, ">")

View File

@ -57,6 +57,17 @@ export const SERIES_CODES: Record<string, string> = {
postseason: "3,4,5,7", postseason: "3,4,5,7",
}; };
// 일부 게임센터 엔드포인트(GetScoreBoardScroll, GetBoxScoreScroll, GetKeyPlayer*)는
// srId를 단일 정수로만 받는다(콤마 리스트 거부). SERIES_CODES와는 별개의 매핑.
export const SERIES_ID: Record<string, number> = {
정규: 0,
regular: 0,
시범: 1,
exhibition: 1,
포스트: 3,
postseason: 3,
};
// ── API Response Types ── // ── API Response Types ──
interface Cell { interface Cell {

View File

@ -31,6 +31,7 @@ export const kboDailyRefresh = onSchedule(
await invalidateByPrefix("rank__"); await invalidateByPrefix("rank__");
await invalidateByPrefix("schedule_day__"); await invalidateByPrefix("schedule_day__");
await invalidateByPrefix("game_detail__");
try { try {
await fetchRankFromKbo([year]); await fetchRankFromKbo([year]);

View File

@ -0,0 +1,114 @@
import {
fetchGameDetail,
type GameDetail,
type GameDetailFilters,
type ScoreBoardMeta,
} from "../kbo/game-detail.js";
import {
encodeKey,
getCached,
setCached,
acquireLock,
releaseLock,
} from "../repositories/kboCacheRepository.js";
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const ONE_HOUR_MS = 60 * 60 * 1000;
const THIRTY_SEC_MS = 30_000;
/**
* 7d.
* 30s.
* ( 1h).
*
* 판정: scoreBoard.meta.endTime이 .
*/
function gameDetailTtlMs(meta: ScoreBoardMeta): number {
if (meta.endTime && meta.endTime.trim() !== "") {
return SEVEN_DAYS_MS;
}
const startTs = parseGameStartTs(meta.gameDate, meta.startTime);
if (startTs == null) return THIRTY_SEC_MS;
const now = Date.now();
if (now < startTs) {
return Math.min(ONE_HOUR_MS, Math.max(startTs - now, THIRTY_SEC_MS));
}
return THIRTY_SEC_MS;
}
function parseGameStartTs(
gameDate: string | undefined,
startTime: string | undefined
): number | null {
const dm = gameDate?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
const tm = startTime?.match(/^(\d{1,2}):(\d{2})$/);
if (!dm || !tm) return null;
const [, y, mo, d] = dm;
const [, h, mi] = tm;
// KST(UTC+9) 기준 절대 시각
return Date.UTC(
Number(y),
Number(mo) - 1,
Number(d),
Number(h) - 9,
Number(mi)
);
}
export async function getGameDetail(
filters: GameDetailFilters
): Promise<GameDetail> {
if (!/^\d{8}[A-Z]{2}[A-Z]{2}\d$/.test(filters.gameId)) {
throw new Error(`Invalid gameId: "${filters.gameId}"`);
}
const key = encodeKey(["game_detail", filters.gameId]);
return getOrFetchDynamic<GameDetail>(key, async () => {
const detail = await fetchGameDetail(filters);
const ttlMs = gameDetailTtlMs(detail.scoreBoard.meta);
return { value: detail, ttlMs };
});
}
interface DynamicResult<T> {
value: T;
ttlMs: number;
}
/**
* fetcher가 TTL을 .
*
* `kboCacheRepository.getOrFetch` TTL을
* TTL을 . doc의 `ttlMs` (getCached가 )
* read fallback TTL은 , write fetcher가 .
* kboCacheRepository와 .
*/
async function getOrFetchDynamic<T>(
key: string,
fetcher: () => Promise<DynamicResult<T>>
): Promise<T> {
const cached = await getCached<T>(key, Number.MAX_SAFE_INTEGER);
if (cached !== null) return cached;
const locked = await acquireLock(key);
if (!locked) {
for (let i = 0; i < 50; i++) {
await new Promise((r) => setTimeout(r, 500));
const c = await getCached<T>(key, Number.MAX_SAFE_INTEGER);
if (c !== null) return c;
}
const { value } = await fetcher();
return value;
}
try {
const { value, ttlMs } = await fetcher();
await setCached(key, value, ttlMs);
return value;
} finally {
await releaseLock(key);
}
}

View File

@ -32,3 +32,24 @@ export type {
} from "../kbo/game-list.js"; } from "../kbo/game-list.js";
export { LeagueCode, LEAGUE_CODES } from "../kbo/game-list.js"; export { LeagueCode, LEAGUE_CODES } from "../kbo/game-list.js";
export type {
GameDetail,
GameDetailFilters,
ScoreBoardScroll,
ScoreBoardMeta,
InningScore,
ScoreTotals,
BoxScoreScroll,
BoxScoreEtc,
BoxScoreTeam,
BoxHitter,
BoxPitcher,
ParsedTable,
ParsedTableRow,
KeyPlayerRanking,
KeyPlayerItem,
KeyPlayerGroup,
} from "../kbo/game-detail.js";
export { SERIES_ID } from "../kbo/schedule.js";

View File

@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it } from "vitest";
import { firestore } from "../../src/firebase.js";
import { getGameDetail } from "../../src/services/gameDetailService.js";
// 실제 KBO 게임센터 + Firestore 에뮬레이터를 함께 사용한다.
// nested-array Firestore 거부, parseGameStartTs NPE 등 회귀를 잡기 위함.
//
// 기준 경기: 2025-09-20 SS@LG (정규시즌 종료 경기, 12회 연장).
const ENDED_GAME_ID = "20250920SSLG0";
describe("gameDetailService (실제 KBO + Firestore 에뮬레이터)", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("kboCache"));
await firestore.recursiveDelete(firestore.collection("kboLocks"));
});
it("종료 경기를 fetch하고 Firestore 캐시에 정상 저장한다", async () => {
const t0 = Date.now();
const result = await getGameDetail({ gameId: ENDED_GAME_ID });
console.log(`[detail] fetch 완료 (${Date.now() - t0}ms)`);
expect(result.gameId).toBe(ENDED_GAME_ID);
expect(result.scoreBoard.meta.gameId).toBe(ENDED_GAME_ID);
expect(result.scoreBoard.meta.endTime).toBeTruthy();
expect(result.scoreBoard.meta.awayTeam.id).toBe("SS");
expect(result.scoreBoard.meta.homeTeam.id).toBe("LG");
expect(result.scoreBoard.innings.length).toBeGreaterThan(0);
expect(result.scoreBoard.totals.away.runs).toBeTypeOf("number");
expect(result.boxScore.events.length).toBeGreaterThan(0);
expect(result.boxScore.away.hitters.length).toBeGreaterThan(0);
expect(result.boxScore.away.pitchers.length).toBeGreaterThan(0);
expect(result.keyPlayer.hitter.items.length).toBeGreaterThan(0);
expect(result.keyPlayer.pitcher.items.length).toBeGreaterThan(0);
// 도메인 타입 — 첫 타자: 순번/위치/이름 + 이닝 결과 길이가 maxInning과 일치
const firstHitter = result.boxScore.away.hitters[0];
expect(firstHitter.name).toBeTruthy();
expect(firstHitter.order).toBeTruthy();
expect(firstHitter.inningResults.length).toBe(result.scoreBoard.maxInning);
// 빈 이닝(타석에 안 들어선 칸)은 "" — KBO의 &nbsp;가 디코딩되어 사라져야 함
for (const cell of firstHitter.inningResults) {
expect(cell).not.toMatch(/&nbsp;/);
}
expect(firstHitter.ab).toBeTypeOf("number");
expect(firstHitter.seasonAvg).toBeTypeOf("number");
// 첫 투수: 시즌 W/L/S + 이닝/실점/자책 모두 채워짐
const firstPitcher = result.boxScore.away.pitchers[0];
expect(firstPitcher.name).toBeTruthy();
expect(firstPitcher.innings).toBeTruthy();
expect(firstPitcher.earnedRuns).toBeTypeOf("number");
expect(firstPitcher.seasonEra).toBeTypeOf("number");
// ParsedTableRow 형태 확인 (Firestore가 받을 수 있는 모양: 배열 안 객체)
const sampleRawRow = result.boxScore.away.raw.hitterByInning.rows[0];
expect(sampleRawRow).toHaveProperty("cells");
expect(Array.isArray(sampleRawRow.cells)).toBe(true);
// Firestore에 캐시 doc이 실제로 써졌는지 확인 (nested-array 회귀 방지)
const snap = await firestore
.collection("kboCache")
.doc(`game_detail__${ENDED_GAME_ID}`)
.get();
expect(snap.exists).toBe(true);
const doc = snap.data() as { ttlMs?: number };
// 종료 경기는 7일 TTL
expect(doc.ttlMs).toBe(7 * 24 * 60 * 60 * 1000);
}, 30000);
it("두 번째 호출은 캐시에서 온다 (외부 호출 0회)", async () => {
const start1 = Date.now();
await getGameDetail({ gameId: ENDED_GAME_ID });
const firstDuration = Date.now() - start1;
const start2 = Date.now();
const cached = await getGameDetail({ gameId: ENDED_GAME_ID });
const secondDuration = Date.now() - start2;
console.log(`[cache] ${firstDuration}ms → ${secondDuration}ms`);
expect(cached.gameId).toBe(ENDED_GAME_ID);
expect(secondDuration).toBeLessThan(firstDuration);
}, 30000);
it("잘못된 gameId 형식은 거부한다", async () => {
await expect(getGameDetail({ gameId: "bogus" })).rejects.toThrow(
/Invalid gameId/
);
});
it("series가 잘못되어 빈 응답이 와도 NPE 대신 명확한 에러를 던진다", async () => {
// 정규시즌 경기를 포스트시즌 srId(3)으로 조회 → KBO가 빈 응답 반환.
// parseGameStartTs/엔티티 접근에서 죽지 않고 "Game not found"로 던져야 함.
await expect(
getGameDetail({ gameId: ENDED_GAME_ID, series: "포스트" })
).rejects.toThrow(/Game not found|unavailable/i);
}, 15000);
});