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 { 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();
|
||||||
|
|||||||
114
src/kbo/cli.ts
114
src/kbo/cli.ts
@ -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
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 {
|
export function decodeHtmlEntities(text: string): string {
|
||||||
return text
|
return text
|
||||||
|
.replace(/ /g, " ")
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
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";
|
} 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";
|
||||||
|
|||||||
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