Add starting lineup support and enhance pre-game data handling.
- KBO 경기 상세 정보에 선발 및 예상 라인업 정보를 포함하고 CLI에서 이를 확인할 수 있는 출력 기능을 추가했습니다. - 경기가 시작되기 전 KBO API가 빈 데이터를 반환하는 상황(code 200)을 예외 처리하고, 경기 상태에 따라 null을 반환하도록 개선했습니다. - 종료된 경기는 박스스코어에서 선발 명단을 추출하고, 시작 전 경기는 라인업 분석 API를 호출하는 이원화된 데이터 수집 로직을 구현했습니다. - 예상 라인업이 확정으로 전환되는 시점을 빠르게 반영하기 위해 라인업 출처에 따라 캐시 TTL을 동적으로 조절하는 기능을 도입했습니다. - 라인업 파싱 및 경기 시작 전후의 데이터 처리 로직에 대한 검증을 위해 서비스 레이어 테스트를 업데이트했습니다.
This commit is contained in:
parent
1101aedeb7
commit
eb17177f77
@ -49,7 +49,7 @@ import {
|
||||
type GameListRecord,
|
||||
type GameListResult,
|
||||
} from "./game-list";
|
||||
import { fetchGameDetail, type GameDetail } from "./game-detail";
|
||||
import { fetchGameDetail, type GameDetail, type Lineup } from "./game-detail";
|
||||
|
||||
const PLAYER_CONFIGS: Record<string, PlayerPageConfig> = {
|
||||
hitter: HITTER_CONFIG,
|
||||
@ -303,6 +303,15 @@ function printGameListTable(result: GameListResult) {
|
||||
}
|
||||
|
||||
function printGameDetail(d: GameDetail) {
|
||||
if (!d.scoreBoard) {
|
||||
console.log(`\n${"═".repeat(80)}`);
|
||||
console.log(` ${d.gameId} (시작 전 — KBO 점수판 데이터 없음)`);
|
||||
console.log(`${"═".repeat(80)}`);
|
||||
if (d.lineup) {
|
||||
printLineup(d.lineup);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { meta, innings, totals, maxInning } = d.scoreBoard;
|
||||
console.log(`\n${"═".repeat(80)}`);
|
||||
console.log(
|
||||
@ -341,21 +350,45 @@ function printGameDetail(d: GameDetail) {
|
||||
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 [기록]`);
|
||||
if (d.boxScore && 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)]`);
|
||||
if (d.keyPlayer.hitter) {
|
||||
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)]`);
|
||||
}
|
||||
if (d.keyPlayer.pitcher) {
|
||||
console.log("\n [키플레이어 - 투수 (WPA)]");
|
||||
for (const p of d.keyPlayer.pitcher.items) {
|
||||
console.log(` ${p.rank}. ${p.playerName}(${p.teamId}) ${p.recordText}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (d.lineup) {
|
||||
printLineup(d.lineup);
|
||||
}
|
||||
}
|
||||
|
||||
function printLineup(l: Lineup) {
|
||||
const srcLabel =
|
||||
l.source === "boxscore" ? "출장 라인업" :
|
||||
l.source === "preview-announced" ? "선발 라인업" :
|
||||
"예상 라인업(직전 경기 기준)";
|
||||
console.log(`\n [라인업 — ${srcLabel}]`);
|
||||
for (const team of [l.away, l.home] as const) {
|
||||
const tag = team === l.away ? "원정" : "홈";
|
||||
console.log(` ${tag} ${team.teamName} (출처 경기 ${team.sourceGameId})`);
|
||||
for (const s of team.slots) {
|
||||
const war = s.seasonWar == null ? "" : ` WAR ${s.seasonWar.toFixed(2)}`;
|
||||
console.log(` ${s.batOrder}. ${padCell(s.position, 6)} ${s.name}${war}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function detailCommand(
|
||||
@ -383,9 +416,9 @@ async function gamesCommand(
|
||||
console.log(`Fetching KBO game list for ${date}...\n`);
|
||||
|
||||
const resolvedSeries = series ? (SERIES_CODES[series] ?? series) : undefined;
|
||||
const resolvedLeague = league
|
||||
? (LEAGUE_CODES[league] ?? (league as LeagueCode))
|
||||
: undefined;
|
||||
const resolvedLeague = league ?
|
||||
(LEAGUE_CODES[league] ?? (league as LeagueCode)) :
|
||||
undefined;
|
||||
|
||||
const result = await fetchGameList({
|
||||
date,
|
||||
@ -411,9 +444,9 @@ async function scheduleCommand(
|
||||
day?: number
|
||||
) {
|
||||
const monthStr = String(month).padStart(2, "0");
|
||||
const label = day != null
|
||||
? `${year}-${monthStr}-${String(day).padStart(2, "0")}`
|
||||
: `${year}-${monthStr}`;
|
||||
const label = day != null ?
|
||||
`${year}-${monthStr}-${String(day).padStart(2, "0")}` :
|
||||
`${year}-${monthStr}`;
|
||||
console.log(`Fetching KBO schedule for ${label}...\n`);
|
||||
|
||||
const resolvedTeam = team ? (TEAM_CODES[team] ?? team) : undefined;
|
||||
@ -426,16 +459,16 @@ async function scheduleCommand(
|
||||
team: resolvedTeam,
|
||||
});
|
||||
|
||||
const result = day != null
|
||||
? {
|
||||
const result = day != null ?
|
||||
{
|
||||
...monthResult,
|
||||
day,
|
||||
games: monthResult.games.filter((g) => {
|
||||
const m = g.date.match(/^(\d{2})\.(\d{2})$/);
|
||||
return m ? parseInt(m[2], 10) === day : false;
|
||||
}),
|
||||
}
|
||||
: monthResult;
|
||||
} :
|
||||
monthResult;
|
||||
|
||||
console.log(` ${result.games.length} games found`);
|
||||
|
||||
|
||||
@ -34,15 +34,15 @@ interface ResolvedFilters {
|
||||
|
||||
function resolveFilters(f: GameDetailFilters): ResolvedFilters {
|
||||
const leId =
|
||||
typeof f.league === "number"
|
||||
? f.league
|
||||
: Number(LEAGUE_CODES[f.league ?? ""] ?? f.league ?? LeagueCode.KBO);
|
||||
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;
|
||||
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(
|
||||
@ -126,6 +126,11 @@ function toInt(s: string | undefined): number | null {
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// 시작 전 경기는 KBO가 code:"200"로 빈 응답을 내려준다. 진행/종료는 "100".
|
||||
function isEmptyResponse(raw: { code?: string } | null | undefined): boolean {
|
||||
return raw?.code === "200";
|
||||
}
|
||||
|
||||
// ── 1. ScoreBoardScroll ──
|
||||
|
||||
interface RawScoreBoardScroll {
|
||||
@ -158,7 +163,9 @@ interface RawScoreBoardScroll {
|
||||
table1?: string;
|
||||
table2?: string;
|
||||
table3?: string;
|
||||
maxInning: number;
|
||||
maxInning?: number;
|
||||
code?: string;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export interface ScoreBoardMeta {
|
||||
@ -270,18 +277,19 @@ export function parseScoreBoardScroll(raw: RawScoreBoardScroll): ScoreBoardScrol
|
||||
away: parseTotalsRow(t3.rows[0]?.cells ?? []),
|
||||
home: parseTotalsRow(t3.rows[1]?.cells ?? []),
|
||||
},
|
||||
maxInning: raw.maxInning,
|
||||
maxInning: raw.maxInning ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchScoreBoardScroll(
|
||||
filters: GameDetailFilters
|
||||
): Promise<ScoreBoardScroll> {
|
||||
): Promise<ScoreBoardScroll | null> {
|
||||
const { gameId, leId, srId, seasonId } = resolveFilters(filters);
|
||||
const raw = await postJson<RawScoreBoardScroll>(
|
||||
"GetScoreBoardScroll",
|
||||
bodyOf({ leId, srId, seasonId, gameId })
|
||||
);
|
||||
if (isEmptyResponse(raw)) return null;
|
||||
return parseScoreBoardScroll(raw);
|
||||
}
|
||||
|
||||
@ -291,8 +299,10 @@ interface RawBoxScoreScroll {
|
||||
tableEtc?: string;
|
||||
arrHitter?: { table1?: string; table2?: string; table3?: string }[];
|
||||
arrPitcher?: { table?: string }[];
|
||||
maxInning: number;
|
||||
realMaxInning: number;
|
||||
maxInning?: number;
|
||||
realMaxInning?: number;
|
||||
code?: string;
|
||||
msg?: string;
|
||||
}
|
||||
|
||||
export interface BoxScoreEtc {
|
||||
@ -467,19 +477,20 @@ export function parseBoxScoreScroll(raw: RawBoxScoreScroll): BoxScoreScroll {
|
||||
events: parseEvents(raw.tableEtc),
|
||||
away: buildTeam(hitters[0], pitchers[0], maxInning),
|
||||
home: buildTeam(hitters[1], pitchers[1], maxInning),
|
||||
maxInning: raw.maxInning,
|
||||
realMaxInning: raw.realMaxInning,
|
||||
maxInning: raw.maxInning ?? 0,
|
||||
realMaxInning: raw.realMaxInning ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchBoxScoreScroll(
|
||||
filters: GameDetailFilters
|
||||
): Promise<BoxScoreScroll> {
|
||||
): Promise<BoxScoreScroll | null> {
|
||||
const { gameId, leId, srId, seasonId } = resolveFilters(filters);
|
||||
const raw = await postJson<RawBoxScoreScroll>(
|
||||
"GetBoxScoreScroll",
|
||||
bodyOf({ leId, srId, seasonId, gameId })
|
||||
);
|
||||
if (isEmptyResponse(raw)) return null;
|
||||
return parseBoxScoreScroll(raw);
|
||||
}
|
||||
|
||||
@ -544,7 +555,7 @@ function parseKeyPlayer(raw: RawKeyPlayer, group: KeyPlayerGroup): KeyPlayerRank
|
||||
async function fetchKeyPlayer(
|
||||
path: "GetKeyPlayerHitter" | "GetKeyPlayerPitcher",
|
||||
options: KeyPlayerOptions
|
||||
): Promise<KeyPlayerRanking> {
|
||||
): Promise<KeyPlayerRanking | null> {
|
||||
const { gameId, leId, srId } = resolveFilters(options);
|
||||
const groupSc = options.groupSc ?? "GAME_WPA_RT";
|
||||
const sort = options.sort ?? "DESC";
|
||||
@ -552,27 +563,239 @@ async function fetchKeyPlayer(
|
||||
path,
|
||||
bodyOf({ leId, srId, gameId, groupSc, sort })
|
||||
);
|
||||
if (isEmptyResponse(raw)) return null;
|
||||
return parseKeyPlayer(raw, groupSc);
|
||||
}
|
||||
|
||||
export function fetchKeyPlayerHitter(options: KeyPlayerOptions): Promise<KeyPlayerRanking> {
|
||||
export function fetchKeyPlayerHitter(options: KeyPlayerOptions): Promise<KeyPlayerRanking | null> {
|
||||
return fetchKeyPlayer("GetKeyPlayerHitter", options);
|
||||
}
|
||||
|
||||
export function fetchKeyPlayerPitcher(options: KeyPlayerOptions): Promise<KeyPlayerRanking> {
|
||||
export function fetchKeyPlayerPitcher(options: KeyPlayerOptions): Promise<KeyPlayerRanking | null> {
|
||||
return fetchKeyPlayer("GetKeyPlayerPitcher", options);
|
||||
}
|
||||
|
||||
// ── 4. 통합 GameDetail ──
|
||||
// ── 4. LineUp Analysis (시작 전 라인업 / 직전 경기 fallback) ──
|
||||
|
||||
interface RawLineupCk {
|
||||
LINEUP_CK?: boolean;
|
||||
}
|
||||
|
||||
interface RawLineupTeamMeta {
|
||||
LE_ID?: number;
|
||||
SR_ID?: number;
|
||||
SEASON_ID?: number;
|
||||
T_ID?: string;
|
||||
T_NM?: string;
|
||||
T_EMBLEM_LK?: string;
|
||||
T_INITIAL_LK?: string;
|
||||
HITTER_12_WAR_RT?: string;
|
||||
HITTER_35_WAR_RT?: string;
|
||||
HITTER_69_WAR_RT?: string;
|
||||
G_ID?: string;
|
||||
}
|
||||
|
||||
// 응답은 인덱스 키("0".."4")를 가진 객체. 각 슬롯은 1행짜리 배열로 감싸져 있다.
|
||||
// "0": [{ LINEUP_CK }]
|
||||
// "1": [홈팀 메타]
|
||||
// "2": [원정팀 메타]
|
||||
// "3": [홈 라인업 테이블 JSON 문자열]
|
||||
// "4": [원정 라인업 테이블 JSON 문자열]
|
||||
type RawLineupAnalysis = {
|
||||
"0"?: RawLineupCk[];
|
||||
"1"?: RawLineupTeamMeta[];
|
||||
"2"?: RawLineupTeamMeta[];
|
||||
"3"?: string[];
|
||||
"4"?: string[];
|
||||
code?: string;
|
||||
msg?: string;
|
||||
};
|
||||
|
||||
export type LineupSource = "boxscore" | "preview-announced" | "preview-estimated";
|
||||
|
||||
export interface LineupSlot {
|
||||
/** 타순 1~9. */
|
||||
batOrder: number;
|
||||
/** 풀네임 ("유격수", "좌익수"). BoxScore 출처는 약어를 풀네임으로 확장한 값. */
|
||||
position: string;
|
||||
name: string;
|
||||
/** 시즌 WAR. preview 출처에서만 채워짐. boxscore 출처는 null. */
|
||||
seasonWar: number | null;
|
||||
}
|
||||
|
||||
export interface LineupTeam {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
/** 작은 엠블럼 URL. */
|
||||
emblem: string;
|
||||
/** announced=false면 직전 경기 ID(라인업 출처). 그 외엔 요청한 경기 자체. */
|
||||
sourceGameId: string;
|
||||
slots: LineupSlot[];
|
||||
}
|
||||
|
||||
export interface Lineup {
|
||||
/** 데이터 출처. 클라이언트 UI 라벨 결정용. */
|
||||
source: LineupSource;
|
||||
/** true=확정 라인업, false=직전 경기 fallback 추정. boxscore 출처는 항상 true. */
|
||||
announced: boolean;
|
||||
home: LineupTeam;
|
||||
away: LineupTeam;
|
||||
}
|
||||
|
||||
// 약어 → 풀네임. BoxScore의 시작 라인업 추출 시 사용. 약어 앞에 붙은 교체 prefix
|
||||
// (타/주/대/교) 가 보이면 떼고 매핑한다. 매핑 실패는 원본 그대로 반환.
|
||||
const POSITION_FULL_NAMES: Record<string, string> = {
|
||||
유: "유격수",
|
||||
좌: "좌익수",
|
||||
중: "중견수",
|
||||
우: "우익수",
|
||||
一: "1루수",
|
||||
二: "2루수",
|
||||
三: "3루수",
|
||||
포: "포수",
|
||||
지: "지명타자",
|
||||
투: "투수",
|
||||
};
|
||||
|
||||
function expandPosition(abbr: string): string {
|
||||
if (!abbr) return abbr;
|
||||
const cleaned = abbr.replace(/^[타주대교]/, "");
|
||||
return POSITION_FULL_NAMES[cleaned] ?? abbr;
|
||||
}
|
||||
|
||||
function parseLineupTable(tableJsonStr: string | undefined): LineupSlot[] {
|
||||
const t = parseTableJson(tableJsonStr);
|
||||
const slots: LineupSlot[] = [];
|
||||
for (const r of t.rows) {
|
||||
const cells = r.cells;
|
||||
const order = parseInt(cells[0] ?? "", 10);
|
||||
if (!Number.isFinite(order)) continue;
|
||||
slots.push({
|
||||
batOrder: order,
|
||||
position: cells[1] ?? "",
|
||||
name: cells[2] ?? "",
|
||||
seasonWar: toFloat(cells[3]),
|
||||
});
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
export function parseLineupAnalysis(raw: RawLineupAnalysis): Lineup | null {
|
||||
const flag = raw?.["0"]?.[0]?.LINEUP_CK;
|
||||
const homeMeta = raw?.["1"]?.[0];
|
||||
const awayMeta = raw?.["2"]?.[0];
|
||||
if (flag === undefined || !homeMeta || !awayMeta) return null;
|
||||
const homeSlots = parseLineupTable(raw["3"]?.[0]);
|
||||
const awaySlots = parseLineupTable(raw["4"]?.[0]);
|
||||
const announced = flag;
|
||||
return {
|
||||
source: announced ? "preview-announced" : "preview-estimated",
|
||||
announced,
|
||||
home: {
|
||||
teamId: homeMeta.T_ID ?? "",
|
||||
teamName: homeMeta.T_NM ?? "",
|
||||
emblem: homeMeta.T_INITIAL_LK ?? "",
|
||||
sourceGameId: homeMeta.G_ID ?? "",
|
||||
slots: homeSlots,
|
||||
},
|
||||
away: {
|
||||
teamId: awayMeta.T_ID ?? "",
|
||||
teamName: awayMeta.T_NM ?? "",
|
||||
emblem: awayMeta.T_INITIAL_LK ?? "",
|
||||
sourceGameId: awayMeta.G_ID ?? "",
|
||||
slots: awaySlots,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLineupAnalysis(
|
||||
filters: GameDetailFilters
|
||||
): Promise<Lineup | null> {
|
||||
const { gameId, leId, srId, seasonId } = resolveFilters(filters);
|
||||
const raw = await postJson<RawLineupAnalysis>(
|
||||
"GetLineUpAnalysis",
|
||||
bodyOf({ leId, srId, seasonId, gameId })
|
||||
);
|
||||
return parseLineupAnalysis(raw);
|
||||
}
|
||||
|
||||
// BoxScore.hitters는 교체 출장까지 포함한 전체 출장자 목록. 동일 batOrder 슬롯의
|
||||
// 첫 등장이 선발. 9명까지 수집해 타순 오름차순으로 반환한다.
|
||||
function extractStartersFromHitters(hitters: BoxHitter[]): LineupSlot[] {
|
||||
const seen = new Set<number>();
|
||||
const slots: LineupSlot[] = [];
|
||||
for (const h of hitters) {
|
||||
const order = parseInt(h.order, 10);
|
||||
if (!Number.isFinite(order) || seen.has(order)) continue;
|
||||
seen.add(order);
|
||||
slots.push({
|
||||
batOrder: order,
|
||||
position: expandPosition(h.position),
|
||||
name: h.name,
|
||||
seasonWar: null,
|
||||
});
|
||||
}
|
||||
return slots.sort((a, b) => a.batOrder - b.batOrder);
|
||||
}
|
||||
|
||||
function buildLineupFromBoxScore(
|
||||
boxScore: BoxScoreScroll,
|
||||
scoreBoard: ScoreBoardScroll | null,
|
||||
gameId: string
|
||||
): Lineup {
|
||||
const sb = scoreBoard?.meta;
|
||||
return {
|
||||
source: "boxscore",
|
||||
announced: true,
|
||||
home: {
|
||||
teamId: sb?.homeTeam.id ?? "",
|
||||
teamName: sb?.homeTeam.name ?? "",
|
||||
emblem: sb?.homeTeam.emblem ?? "",
|
||||
sourceGameId: gameId,
|
||||
slots: extractStartersFromHitters(boxScore.home.hitters),
|
||||
},
|
||||
away: {
|
||||
teamId: sb?.awayTeam.id ?? "",
|
||||
teamName: sb?.awayTeam.name ?? "",
|
||||
emblem: sb?.awayTeam.emblem ?? "",
|
||||
sourceGameId: gameId,
|
||||
slots: extractStartersFromHitters(boxScore.away.hitters),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// gameId 앞 8자리(YYYYMMDD, KST)가 오늘(KST)보다 미래인지.
|
||||
function isGameDateInFuture(gameId: string): boolean {
|
||||
const m = gameId.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (!m) return false;
|
||||
const gameDate = `${m[1]}-${m[2]}-${m[3]}`;
|
||||
const nowKst = new Date(Date.now() + 9 * 60 * 60 * 1000);
|
||||
const today =
|
||||
`${nowKst.getUTCFullYear()}-` +
|
||||
`${String(nowKst.getUTCMonth() + 1).padStart(2, "0")}-` +
|
||||
`${String(nowKst.getUTCDate()).padStart(2, "0")}`;
|
||||
return gameDate > today;
|
||||
}
|
||||
|
||||
// ── 5. 통합 GameDetail ──
|
||||
|
||||
export interface GameDetail {
|
||||
gameId: string;
|
||||
scoreBoard: ScoreBoardScroll;
|
||||
boxScore: BoxScoreScroll;
|
||||
/** 시작 전 경기는 null (KBO code:"200"). */
|
||||
scoreBoard: ScoreBoardScroll | null;
|
||||
/** 시작 전 경기는 null. */
|
||||
boxScore: BoxScoreScroll | null;
|
||||
keyPlayer: {
|
||||
hitter: KeyPlayerRanking;
|
||||
pitcher: KeyPlayerRanking;
|
||||
hitter: KeyPlayerRanking | null;
|
||||
pitcher: KeyPlayerRanking | null;
|
||||
};
|
||||
/**
|
||||
* 선발 라인업. 사실 우선 + 프리뷰 fallback:
|
||||
* - boxScore가 있으면 거기서 추출 (source: "boxscore")
|
||||
* - 시작 전이면 GetLineUpAnalysis 호출 (source: "preview-announced" 또는 "preview-estimated")
|
||||
* - 모두 실패하면 null
|
||||
*/
|
||||
lineup: Lineup | null;
|
||||
/** Naver highLight에서 추출한 득점 타석 목록. 호출 실패 시 null. */
|
||||
scoringPlays: GameScoringPlays | null;
|
||||
}
|
||||
@ -580,19 +803,36 @@ export interface GameDetail {
|
||||
export async function fetchGameDetail(
|
||||
filters: GameDetailFilters
|
||||
): Promise<GameDetail> {
|
||||
const [scoreBoard, boxScore, keyHitter, keyPitcher, scoringPlays] =
|
||||
// 게임 날짜가 미래면 BoxScore가 빈 것이 확정이므로 프리뷰를 병렬로 묶어
|
||||
// 둘째 라운드를 생략한다. 오늘/과거는 BoxScore가 빈 경우에만 추가 호출.
|
||||
const future = isGameDateInFuture(filters.gameId);
|
||||
|
||||
const [scoreBoard, boxScore, keyHitter, keyPitcher, scoringPlays, futureLineup] =
|
||||
await Promise.all([
|
||||
fetchScoreBoardScroll(filters),
|
||||
fetchBoxScoreScroll(filters),
|
||||
fetchKeyPlayerHitter(filters),
|
||||
fetchKeyPlayerPitcher(filters),
|
||||
fetchBoxScoreScroll(filters).catch(() => null),
|
||||
fetchKeyPlayerHitter(filters).catch(() => null),
|
||||
fetchKeyPlayerPitcher(filters).catch(() => null),
|
||||
fetchScoringPlays(filters.gameId).catch(() => null),
|
||||
future ? fetchLineupAnalysis(filters).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
let lineup: Lineup | null = futureLineup;
|
||||
if (!lineup) {
|
||||
if (boxScore !== null) {
|
||||
lineup = buildLineupFromBoxScore(boxScore, scoreBoard, filters.gameId);
|
||||
} else {
|
||||
// 오늘이지만 아직 시작 안 한 경기: BoxScore가 비었으므로 프리뷰 보조 호출.
|
||||
lineup = await fetchLineupAnalysis(filters).catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gameId: filters.gameId,
|
||||
scoreBoard,
|
||||
boxScore,
|
||||
keyPlayer: { hitter: keyHitter, pitcher: keyPitcher },
|
||||
lineup,
|
||||
scoringPlays,
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ import {
|
||||
fetchGameDetail,
|
||||
type GameDetail,
|
||||
type GameDetailFilters,
|
||||
type ScoreBoardMeta,
|
||||
} from "../kbo/game-detail";
|
||||
import {
|
||||
encodeKey,
|
||||
@ -14,6 +13,7 @@ import {
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const TEN_MIN_MS = 10 * 60 * 1000;
|
||||
const THIRTY_SEC_MS = 30_000;
|
||||
|
||||
/**
|
||||
@ -22,17 +22,27 @@ const THIRTY_SEC_MS = 30_000;
|
||||
* 시작 전이면 시작 시각까지 (최대 1h).
|
||||
*
|
||||
* 종료 판정: scoreBoard.meta.endTime이 채워져 있으면 종료.
|
||||
* scoreBoard가 null이면 KBO가 아직 경기 데이터를 만들지 않은 시작 전 상태 →
|
||||
* 라인업이 발표 전(`preview-estimated`)이면 곧 바뀔 수 있으니 10분,
|
||||
* 그 외엔 gameId에서 추정한 KST 18:30 시작 시각까지 캐시.
|
||||
*/
|
||||
function gameDetailTtlMs(meta: ScoreBoardMeta): number {
|
||||
if (meta.endTime && meta.endTime.trim() !== "") {
|
||||
function gameDetailTtlMs(detail: GameDetail): number {
|
||||
const sb = detail.scoreBoard;
|
||||
if (sb?.meta.endTime && sb.meta.endTime.trim() !== "") {
|
||||
return SEVEN_DAYS_MS;
|
||||
}
|
||||
|
||||
const startTs = parseGameStartTs(meta.gameDate, meta.startTime);
|
||||
const startTs = sb ?
|
||||
parseGameStartTs(sb.meta.gameDate, sb.meta.startTime) :
|
||||
parseGameStartFromGameId(detail.gameId);
|
||||
if (startTs == null) return THIRTY_SEC_MS;
|
||||
|
||||
const now = Date.now();
|
||||
if (now < startTs) {
|
||||
// 발표 전 fallback 라인업은 짧게 폴링 — false → true 전환 시점을 빠르게 잡기 위해.
|
||||
if (detail.lineup?.source === "preview-estimated") {
|
||||
return Math.min(TEN_MIN_MS, Math.max(startTs - now, THIRTY_SEC_MS));
|
||||
}
|
||||
return Math.min(ONE_HOUR_MS, Math.max(startTs - now, THIRTY_SEC_MS));
|
||||
}
|
||||
return THIRTY_SEC_MS;
|
||||
@ -57,6 +67,16 @@ function parseGameStartTs(
|
||||
);
|
||||
}
|
||||
|
||||
// scoreBoard가 없을 때(시작 전) gameId의 날짜만으로 시작 시각을 추정한다.
|
||||
// 정확한 시각은 모르지만 KBO 정규 경기의 평일 18:30 / 주말 14:00·17:00을
|
||||
// 일률적으로 18:30 KST로 가정해 캐시 TTL 상한선용으로 쓴다.
|
||||
function parseGameStartFromGameId(gameId: string): number | null {
|
||||
const m = gameId.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (!m) return null;
|
||||
const [, y, mo, d] = m;
|
||||
return Date.UTC(Number(y), Number(mo) - 1, Number(d), 18 - 9, 30);
|
||||
}
|
||||
|
||||
export async function getGameDetail(
|
||||
filters: GameDetailFilters
|
||||
): Promise<GameDetail> {
|
||||
@ -68,7 +88,7 @@ export async function getGameDetail(
|
||||
|
||||
return getOrFetchDynamic<GameDetail>(key, async () => {
|
||||
const detail = await fetchGameDetail(filters);
|
||||
const ttlMs = gameDetailTtlMs(detail.scoreBoard.meta);
|
||||
const ttlMs = gameDetailTtlMs(detail);
|
||||
return { value: detail, ttlMs };
|
||||
});
|
||||
}
|
||||
|
||||
@ -50,6 +50,10 @@ export type {
|
||||
KeyPlayerRanking,
|
||||
KeyPlayerItem,
|
||||
KeyPlayerGroup,
|
||||
Lineup,
|
||||
LineupTeam,
|
||||
LineupSlot,
|
||||
LineupSource,
|
||||
} from "../kbo/game-detail";
|
||||
|
||||
export type {
|
||||
|
||||
@ -20,23 +20,29 @@ describe("gameDetailService (실제 KBO + Firestore 에뮬레이터)", () => {
|
||||
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);
|
||||
expect(result.scoreBoard).not.toBeNull();
|
||||
expect(result.boxScore).not.toBeNull();
|
||||
const scoreBoard = result.scoreBoard!;
|
||||
const boxScore = result.boxScore!;
|
||||
expect(scoreBoard.meta.gameId).toBe(ENDED_GAME_ID);
|
||||
expect(scoreBoard.meta.endTime).toBeTruthy();
|
||||
expect(scoreBoard.meta.awayTeam.id).toBe("SS");
|
||||
expect(scoreBoard.meta.homeTeam.id).toBe("LG");
|
||||
expect(scoreBoard.innings.length).toBeGreaterThan(0);
|
||||
expect(scoreBoard.totals.away.runs).toBeTypeOf("number");
|
||||
expect(boxScore.events.length).toBeGreaterThan(0);
|
||||
expect(boxScore.away.hitters.length).toBeGreaterThan(0);
|
||||
expect(boxScore.away.pitchers.length).toBeGreaterThan(0);
|
||||
expect(result.keyPlayer.hitter).not.toBeNull();
|
||||
expect(result.keyPlayer.pitcher).not.toBeNull();
|
||||
expect(result.keyPlayer.hitter!.items.length).toBeGreaterThan(0);
|
||||
expect(result.keyPlayer.pitcher!.items.length).toBeGreaterThan(0);
|
||||
|
||||
// 도메인 타입 — 첫 타자: 순번/위치/이름 + 이닝 결과 길이가 maxInning과 일치
|
||||
const firstHitter = result.boxScore.away.hitters[0];
|
||||
const firstHitter = boxScore.away.hitters[0];
|
||||
expect(firstHitter.name).toBeTruthy();
|
||||
expect(firstHitter.order).toBeTruthy();
|
||||
expect(firstHitter.inningResults.length).toBe(result.scoreBoard.maxInning);
|
||||
expect(firstHitter.inningResults.length).toBe(scoreBoard.maxInning);
|
||||
// 빈 이닝(타석에 안 들어선 칸)은 "" — KBO의 가 디코딩되어 사라져야 함
|
||||
for (const cell of firstHitter.inningResults) {
|
||||
expect(cell).not.toMatch(/ /);
|
||||
@ -45,17 +51,34 @@ describe("gameDetailService (실제 KBO + Firestore 에뮬레이터)", () => {
|
||||
expect(firstHitter.seasonAvg).toBeTypeOf("number");
|
||||
|
||||
// 첫 투수: 시즌 W/L/S + 이닝/실점/자책 모두 채워짐
|
||||
const firstPitcher = result.boxScore.away.pitchers[0];
|
||||
const firstPitcher = 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];
|
||||
const sampleRawRow = boxScore.away.raw.hitterByInning.rows[0];
|
||||
expect(sampleRawRow).toHaveProperty("cells");
|
||||
expect(Array.isArray(sampleRawRow.cells)).toBe(true);
|
||||
|
||||
// 종료 경기 라인업: BoxScore에서 추출됨 (사실 우선 원칙)
|
||||
expect(result.lineup).not.toBeNull();
|
||||
const lineup = result.lineup!;
|
||||
expect(lineup.source).toBe("boxscore");
|
||||
expect(lineup.announced).toBe(true);
|
||||
expect(lineup.home.slots.length).toBe(9);
|
||||
expect(lineup.away.slots.length).toBe(9);
|
||||
expect(lineup.home.teamId).toBe("LG");
|
||||
expect(lineup.away.teamId).toBe("SS");
|
||||
// 9명이 1~9번 타순 1회씩 채워짐
|
||||
const homeOrders = lineup.home.slots.map((s) => s.batOrder).sort();
|
||||
expect(homeOrders).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
// 약어 → 풀네임 확장 (BoxScore "유" 등이 "유격수"로)
|
||||
for (const s of lineup.home.slots) {
|
||||
expect(s.position.length).toBeGreaterThan(1);
|
||||
}
|
||||
|
||||
// Firestore에 캐시 doc이 실제로 써졌는지 확인 (nested-array 회귀 방지)
|
||||
const snap = await firestore
|
||||
.collection("kboCache")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user