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:
parent
360530ad13
commit
5888d446b7
@ -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();
|
||||
|
||||
114
src/kbo/cli.ts
114
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<string, PlayerPageConfig> = {
|
||||
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 <hitter|pitcher|defense|runner> Player stats");
|
||||
console.log(" schedule [year] [month] 경기 일정/결과");
|
||||
console.log(" games [YYYYMMDD] 게임센터 라이브 조회");
|
||||
console.log(" detail <gameId> 경기 상세 (스코어보드/박스/키플레이어)");
|
||||
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 <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") {
|
||||
const rest = args.slice(1);
|
||||
let jsonMode = false;
|
||||
|
||||
644
src/kbo/game-detail.ts
Normal file
644
src/kbo/game-detail.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
@ -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, ">")
|
||||
|
||||
@ -57,6 +57,17 @@ export const SERIES_CODES: Record<string, string> = {
|
||||
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 ──
|
||||
|
||||
interface Cell {
|
||||
|
||||
@ -31,6 +31,7 @@ export const kboDailyRefresh = onSchedule(
|
||||
|
||||
await invalidateByPrefix("rank__");
|
||||
await invalidateByPrefix("schedule_day__");
|
||||
await invalidateByPrefix("game_detail__");
|
||||
|
||||
try {
|
||||
await fetchRankFromKbo([year]);
|
||||
|
||||
114
src/services/gameDetailService.ts
Normal file
114
src/services/gameDetailService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
97
tests/services/gameDetailService.test.ts
Normal file
97
tests/services/gameDetailService.test.ts
Normal 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의 가 디코딩되어 사라져야 함
|
||||
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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user