From 5888d446b73abd8dc8f28d62c86006c42add8e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Thu, 30 Apr 2026 16:34:09 +0900 Subject: [PATCH] Implement KBO game detail retrieval with dynamic caching and API. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KBO 게임센터의 스코어보드, 박스스코어, 키플레이어 데이터를 통합 조회하는 기능을 구현했습니다. - 경기 상태(종료, 진행 중, 시작 전)에 따라 TTL을 30초에서 7일까지 유연하게 적용하는 동적 캐싱 시스템을 도입했습니다. - KBO 공식 데이터에서 이닝별 득점이 누락되는 경우를 대비해 네이버 스포츠 API를 통한 데이터 보완 로직을 추가했습니다. - Firestore의 중첩 배열 저장 제한을 회피하기 위해 테이블 데이터를 객체 배열 구조로 파싱하도록 설계했습니다. - CLI 상세 조회 명령어와 REST API 엔드포인트를 추가하고, HTML 엔티티 디코딩 및 관련 단위 테스트를 보완했습니다. --- src/handlers/kboHandlers.ts | 27 + src/kbo/cli.ts | 114 ++++ src/kbo/game-detail.ts | 644 +++++++++++++++++++++++ src/kbo/html-utils.ts | 1 + src/kbo/schedule.ts | 11 + src/scheduled/kboRefresh.ts | 1 + src/services/gameDetailService.ts | 114 ++++ src/types/kbo.ts | 21 + tests/services/gameDetailService.test.ts | 97 ++++ 9 files changed, 1030 insertions(+) create mode 100644 src/kbo/game-detail.ts create mode 100644 src/services/gameDetailService.ts create mode 100644 tests/services/gameDetailService.test.ts diff --git a/src/handlers/kboHandlers.ts b/src/handlers/kboHandlers.ts index 390a139..68a06c8 100644 --- a/src/handlers/kboHandlers.ts +++ b/src/handlers/kboHandlers.ts @@ -3,6 +3,7 @@ import { getRank } from "../services/rankService.js"; import { getSchedule } from "../services/scheduleService.js"; import { getPlayerStats, getValidPlayerTypes } from "../services/playerService.js"; import { getGameList } from "../services/gameListService.js"; +import { getGameDetail } from "../services/gameDetailService.js"; import type { PlayerFilters } from "../types/kbo.js"; enum KboPath { @@ -10,6 +11,7 @@ enum KboPath { Schedule = "schedule", Player = "player", Games = "games", + GameDetail = "gameDetail", } 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/player?type=hitter&year=2025&team=LG&series=7&all=true // 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) => { const path = req.path.replace(/^\/+|\/+$/g, "").split("/").pop() ?? ""; @@ -95,6 +98,30 @@ export const kbo = onRequest(async (req, res) => { 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: { const type = String(req.query.type ?? ""); const validTypes = getValidPlayerTypes(); diff --git a/src/kbo/cli.ts b/src/kbo/cli.ts index 87dbeff..742db81 100644 --- a/src/kbo/cli.ts +++ b/src/kbo/cli.ts @@ -49,6 +49,7 @@ import { type GameListRecord, type GameListResult, } from "./game-list.js"; +import { fetchGameDetail, type GameDetail } from "./game-detail.js"; const PLAYER_CONFIGS: Record = { hitter: HITTER_CONFIG, @@ -287,6 +288,78 @@ function printGameListTable(result: GameListResult) { 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( date: string, jsonMode: boolean, @@ -443,6 +516,7 @@ function printHelp() { console.log(" player Player stats"); console.log(" schedule [year] [month] 경기 일정/결과"); console.log(" games [YYYYMMDD] 게임센터 라이브 조회"); + console.log(" detail 경기 상세 (스코어보드/박스/키플레이어)"); console.log(""); console.log("Options:"); console.log(" --json JSON output"); @@ -534,6 +608,46 @@ async function main() { 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 [--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") { const rest = args.slice(1); let jsonMode = false; diff --git a/src/kbo/game-detail.ts b/src/kbo/game-detail.ts new file mode 100644 index 0000000..4648634 --- /dev/null +++ b/src/kbo/game-detail.ts @@ -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 = { + "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 { + const u = new URLSearchParams(); + for (const [k, v] of Object.entries(p)) u.set(k, String(v)); + return u.toString(); +} + +async function postJson(path: string, body: string): Promise { + 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 { + const { gameId, leId, srId, seasonId } = resolveFilters(filters); + const raw = await postJson( + "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; + 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 { + 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 { + const { gameId, leId, srId, seasonId } = resolveFilters(filters); + const raw = await postJson( + "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 { + const { gameId, leId, srId } = resolveFilters(options); + const groupSc = options.groupSc ?? "GAME_WPA_RT"; + const sort = options.sort ?? "DESC"; + const raw = await postJson( + path, + bodyOf({ leId, srId, gameId, groupSc, sort }) + ); + return parseKeyPlayer(raw, groupSc); +} + +export function fetchKeyPlayerHitter(options: KeyPlayerOptions): Promise { + return fetchKeyPlayer("GetKeyPlayerHitter", options); +} + +export function fetchKeyPlayerPitcher(options: KeyPlayerOptions): Promise { + 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 { + 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 }, + }; +} diff --git a/src/kbo/html-utils.ts b/src/kbo/html-utils.ts index 613cee0..0f197e8 100644 --- a/src/kbo/html-utils.ts +++ b/src/kbo/html-utils.ts @@ -13,6 +13,7 @@ export function stripTags(html: string): string { export function decodeHtmlEntities(text: string): string { return text + .replace(/ /g, " ") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") diff --git a/src/kbo/schedule.ts b/src/kbo/schedule.ts index 03c029f..a757f6c 100644 --- a/src/kbo/schedule.ts +++ b/src/kbo/schedule.ts @@ -57,6 +57,17 @@ export const SERIES_CODES: Record = { postseason: "3,4,5,7", }; +// 일부 게임센터 엔드포인트(GetScoreBoardScroll, GetBoxScoreScroll, GetKeyPlayer*)는 +// srId를 단일 정수로만 받는다(콤마 리스트 거부). SERIES_CODES와는 별개의 매핑. +export const SERIES_ID: Record = { + 정규: 0, + regular: 0, + 시범: 1, + exhibition: 1, + 포스트: 3, + postseason: 3, +}; + // ── API Response Types ── interface Cell { diff --git a/src/scheduled/kboRefresh.ts b/src/scheduled/kboRefresh.ts index 4a43fa5..dedb3b3 100644 --- a/src/scheduled/kboRefresh.ts +++ b/src/scheduled/kboRefresh.ts @@ -31,6 +31,7 @@ export const kboDailyRefresh = onSchedule( await invalidateByPrefix("rank__"); await invalidateByPrefix("schedule_day__"); + await invalidateByPrefix("game_detail__"); try { await fetchRankFromKbo([year]); diff --git a/src/services/gameDetailService.ts b/src/services/gameDetailService.ts new file mode 100644 index 0000000..8e37965 --- /dev/null +++ b/src/services/gameDetailService.ts @@ -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 { + 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(key, async () => { + const detail = await fetchGameDetail(filters); + const ttlMs = gameDetailTtlMs(detail.scoreBoard.meta); + return { value: detail, ttlMs }; + }); +} + +interface DynamicResult { + value: T; + ttlMs: number; +} + +/** + * fetcher가 응답을 보고 동적으로 TTL을 결정해야 하는 경우 쓰는 헬퍼. + * + * `kboCacheRepository.getOrFetch`는 호출 시점에 TTL을 받기 때문에 응답 의존 + * TTL을 표현할 수 없다. 여기선 캐시 doc의 `ttlMs` 필드(getCached가 우선 사용)에 + * 의존하므로 read 시 fallback TTL은 무한대로 두고, write 시 fetcher가 정한 값을 쓴다. + * 락 흐름은 kboCacheRepository와 동일. + */ +async function getOrFetchDynamic( + key: string, + fetcher: () => Promise> +): Promise { + const cached = await getCached(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(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); + } +} diff --git a/src/types/kbo.ts b/src/types/kbo.ts index 5c28cda..fd32252 100644 --- a/src/types/kbo.ts +++ b/src/types/kbo.ts @@ -32,3 +32,24 @@ export type { } 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"; diff --git a/tests/services/gameDetailService.test.ts b/tests/services/gameDetailService.test.ts new file mode 100644 index 0000000..fc46cfb --- /dev/null +++ b/tests/services/gameDetailService.test.ts @@ -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의  가 디코딩되어 사라져야 함 + for (const cell of firstHitter.inningResults) { + expect(cell).not.toMatch(/ /); + } + 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); +});