Integrate KBO live game data and refine schedule caching logic.
- KBO 게임센터 API를 연동하여 실시간 경기 데이터(선발 투수, 이닝, 스코어 등) 조회 기능을 구현했습니다. - 경기 일정 캐시를 일 단위로 관리하도록 개선하고, 경기 상태에 따라 TTL을 동적으로 적용하는 로직을 도입했습니다. - 일정 데이터 요청 시 선발 투수 정보를 병합하여 제공하도록 기능을 강화했습니다. - CLI 명령과 API 엔드포인트에 실시간 경기 정보를 확인할 수 있는 경로를 추가했습니다.
This commit is contained in:
parent
697e0ff11a
commit
a153206391
@ -2,12 +2,22 @@ import { onRequest } from "firebase-functions/https";
|
||||
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 type { PlayerFilters } from "../types/kbo.js";
|
||||
|
||||
enum KboPath {
|
||||
Rank = "rank",
|
||||
Schedule = "schedule",
|
||||
Player = "player",
|
||||
Games = "games",
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
const n = new Date();
|
||||
const y = n.getFullYear();
|
||||
const m = String(n.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(n.getDate()).padStart(2, "0");
|
||||
return `${y}${m}${d}`;
|
||||
}
|
||||
|
||||
function parseIntParam(val: unknown, fallback: number): number {
|
||||
@ -19,6 +29,7 @@ function parseIntParam(val: unknown, fallback: number): number {
|
||||
// GET /kbo/rank?year=2026
|
||||
// GET /kbo/schedule?year=2026&month=4&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
|
||||
|
||||
export const kbo = onRequest(async (req, res) => {
|
||||
const path = req.path.replace(/^\/+|\/+$/g, "").split("/").pop() ?? "";
|
||||
@ -62,6 +73,21 @@ export const kbo = onRequest(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
case KboPath.Games: {
|
||||
const date = req.query.date ? String(req.query.date) : today();
|
||||
const series = req.query.series ? String(req.query.series) : undefined;
|
||||
const league = req.query.league ? String(req.query.league) : undefined;
|
||||
|
||||
if (!/^\d{8}$/.test(date)) {
|
||||
res.status(400).json({ error: `Invalid date: "${date}". Use YYYYMMDD.` });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await getGameList(date, series, league);
|
||||
res.status(200).json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
case KboPath.Player: {
|
||||
const type = String(req.query.type ?? "");
|
||||
const validTypes = getValidPlayerTypes();
|
||||
|
||||
@ -24,7 +24,30 @@ KBO(한국프로야구) 공식 사이트에서 팀 순위, 선수 기록, 경기
|
||||
- 반환 데이터: 날짜, 시간, 원정팀, 홈팀, 점수, 경기 상태(완료/취소/예정), 구장, 중계, gameId
|
||||
- 팀 필터 지원
|
||||
|
||||
### 3. 선수 기록
|
||||
### 3. 게임센터 라이브 조회
|
||||
|
||||
특정 날짜의 경기 목록을 게임센터(`Main.aspx`)와 동일한 엔드포인트로 조회한다.
|
||||
월 단위 `fetchSchedule`과 달리 **선발투수, 현재 이닝·볼카운트, 루상 주자, 현재 타자·투수,
|
||||
승·패·세이브 투수, 양팀 현재 순위** 등 경기 단위 라이브 정보를 포함한다.
|
||||
|
||||
- 조회 단위: 1일
|
||||
- 반환 데이터: `GameListRecord` — gameId, 선발/현재/결정 투수, 이닝, 카운트, 주자, 순위 등
|
||||
- 필터: `date`(필수), `league`(기본 KBO 1군), `series`(기본 정규시즌)
|
||||
|
||||
```typescript
|
||||
import { fetchGameList, LeagueCode } from "./kbo/game-list.js";
|
||||
|
||||
const result = await fetchGameList({ date: "20260414" });
|
||||
// { date, league, series, games: GameListRecord[] }
|
||||
|
||||
// 포스트시즌
|
||||
await fetchGameList({ date: "20251027", series: "3,4,5,7" });
|
||||
|
||||
// 퓨처스
|
||||
await fetchGameList({ date: "20260414", league: LeagueCode.Futures });
|
||||
```
|
||||
|
||||
### 4. 선수 기록
|
||||
|
||||
4가지 카테고리의 선수 기록을 조회한다.
|
||||
|
||||
|
||||
140
src/kbo/cli.ts
140
src/kbo/cli.ts
@ -42,6 +42,13 @@ import {
|
||||
type ScheduleGame,
|
||||
type ScheduleResult,
|
||||
} from "./schedule.js";
|
||||
import {
|
||||
fetchGameList,
|
||||
LEAGUE_CODES,
|
||||
LeagueCode,
|
||||
type GameListRecord,
|
||||
type GameListResult,
|
||||
} from "./game-list.js";
|
||||
|
||||
const PLAYER_CONFIGS: Record<string, PlayerPageConfig> = {
|
||||
hitter: HITTER_CONFIG,
|
||||
@ -212,6 +219,100 @@ function printScheduleTable(result: ScheduleResult) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Game List Formatting ──
|
||||
|
||||
function fmtGameScore(g: GameListRecord): string {
|
||||
if (g.status.cancelName && g.status.cancelName !== "정상경기") {
|
||||
return ` ${g.status.cancelName} `;
|
||||
}
|
||||
if (g.score.away != null && g.score.home != null &&
|
||||
(g.score.away > 0 || g.score.home > 0 || g.status.inning != null)) {
|
||||
return `${String(g.score.away).padStart(2)} - ${String(g.score.home).padStart(2)}`;
|
||||
}
|
||||
return " vs ";
|
||||
}
|
||||
|
||||
function fmtInning(g: GameListRecord): string {
|
||||
if (g.status.inning == null) return "";
|
||||
const tb = g.status.topBottom ?? "";
|
||||
return ` [${g.status.inning}회${tb}]`;
|
||||
}
|
||||
|
||||
function printGameListTable(result: GameListResult) {
|
||||
console.log(`\n${"═".repeat(90)}`);
|
||||
console.log(` KBO Game Center — ${result.date} (league=${result.league}, series=${result.series})`);
|
||||
console.log(`${"═".repeat(90)}`);
|
||||
|
||||
if (result.games.length === 0) {
|
||||
console.log(" No games.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const g of result.games) {
|
||||
const time = padCell(g.time, 5);
|
||||
const away = padCell(g.awayTeamName, 4);
|
||||
const home = padCell(g.homeTeamName, 4);
|
||||
const score = fmtGameScore(g);
|
||||
const inning = fmtInning(g);
|
||||
const stadium = padCell(g.stadium, 5);
|
||||
const tv = g.broadcast || "";
|
||||
|
||||
console.log(` ${time} ${away} ${score} ${home} ${stadium} ${tv}${inning}`);
|
||||
|
||||
const sp = g.startingPitchers;
|
||||
if (sp.away || sp.home) {
|
||||
const a = sp.away ? sp.away.name : "-";
|
||||
const h = sp.home ? sp.home.name : "-";
|
||||
console.log(` 선발: ${padCell(a, 8)} vs ${h}`);
|
||||
}
|
||||
|
||||
if (g.currentBatter || g.currentPitcher) {
|
||||
const b = g.currentBatter?.name ?? "-";
|
||||
const p = g.currentPitcher?.name ?? "-";
|
||||
const c = g.count;
|
||||
const cnt = c.ball != null ?
|
||||
` (B${c.ball} S${c.strike} O${c.out})` : "";
|
||||
console.log(` 타석: P ${p} vs B ${b}${cnt}`);
|
||||
}
|
||||
|
||||
const d = g.decisions;
|
||||
if (d.winner || d.loser || d.save) {
|
||||
const w = d.winner ? `승 ${d.winner.name}` : "";
|
||||
const l = d.loser ? `패 ${d.loser.name}` : "";
|
||||
const s = d.save ? `세 ${d.save.name}` : "";
|
||||
console.log(` 결과: ${[w, l, s].filter(Boolean).join(" / ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n Total: ${result.games.length} games`);
|
||||
}
|
||||
|
||||
async function gamesCommand(
|
||||
date: string,
|
||||
jsonMode: boolean,
|
||||
series?: string,
|
||||
league?: string
|
||||
) {
|
||||
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 result = await fetchGameList({
|
||||
date,
|
||||
series: resolvedSeries,
|
||||
league: resolvedLeague,
|
||||
});
|
||||
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
printGameListTable(result);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commands ──
|
||||
|
||||
async function scheduleCommand(
|
||||
@ -326,6 +427,7 @@ function printHelp() {
|
||||
console.log(" rank [year...] Team rankings");
|
||||
console.log(" player <hitter|pitcher|defense|runner> Player stats");
|
||||
console.log(" schedule [year] [month] 경기 일정/결과");
|
||||
console.log(" games [YYYYMMDD] 게임센터 라이브 조회");
|
||||
console.log("");
|
||||
console.log("Options:");
|
||||
console.log(" --json JSON output");
|
||||
@ -341,6 +443,11 @@ function printHelp() {
|
||||
console.log("Schedule filters:");
|
||||
console.log(" --team=팀명 팀 (LG, 삼성, KT, ...)");
|
||||
console.log(" --series=값 시리즈 (정규, 시범, 포스트)");
|
||||
console.log("");
|
||||
console.log("Games filters:");
|
||||
console.log(" --date=YYYYMMDD 날짜 (기본: 오늘 KST)");
|
||||
console.log(" --series=값 시리즈 (정규, 시범, 포스트)");
|
||||
console.log(" --league=값 리그 (kbo, futures)");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@ -411,6 +518,39 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "games") {
|
||||
const rest = args.slice(1);
|
||||
let jsonMode = false;
|
||||
let date: string | undefined;
|
||||
let gSeries: string | undefined;
|
||||
let gLeague: string | undefined;
|
||||
|
||||
for (const arg of rest) {
|
||||
if (arg === "--json") {
|
||||
jsonMode = true;
|
||||
} else if (arg.startsWith("--date=")) {
|
||||
date = arg.slice("--date=".length);
|
||||
} else if (arg.startsWith("--series=")) {
|
||||
gSeries = arg.slice("--series=".length);
|
||||
} else if (arg.startsWith("--league=")) {
|
||||
gLeague = arg.slice("--league=".length);
|
||||
} else if (/^\d{8}$/.test(arg)) {
|
||||
date = arg;
|
||||
} else {
|
||||
console.error(`Invalid argument: ${arg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!date) {
|
||||
const n = new Date();
|
||||
date = `${n.getFullYear()}${String(n.getMonth() + 1).padStart(2, "0")}${String(n.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
await gamesCommand(date, jsonMode, gSeries, gLeague);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "schedule") {
|
||||
const rest = args.slice(1);
|
||||
let jsonMode = false;
|
||||
|
||||
270
src/kbo/game-list.ts
Normal file
270
src/kbo/game-list.ts
Normal file
@ -0,0 +1,270 @@
|
||||
import { SERIES_CODES } from "./schedule.js";
|
||||
|
||||
export { SERIES_CODES };
|
||||
|
||||
const GAME_LIST_URL =
|
||||
"https://www.koreabaseball.com/ws/Main.asmx/GetKboGameList";
|
||||
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36";
|
||||
|
||||
// ── Enums / Codes ──
|
||||
|
||||
export enum LeagueCode {
|
||||
KBO = "1",
|
||||
Futures = "2",
|
||||
}
|
||||
|
||||
export const LEAGUE_CODES: Record<string, LeagueCode> = {
|
||||
kbo: LeagueCode.KBO,
|
||||
KBO: LeagueCode.KBO,
|
||||
"1군": LeagueCode.KBO,
|
||||
futures: LeagueCode.Futures,
|
||||
퓨처스: LeagueCode.Futures,
|
||||
"2군": LeagueCode.Futures,
|
||||
};
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface GameListFilters {
|
||||
date: string | Date;
|
||||
league?: LeagueCode | string;
|
||||
series?: string;
|
||||
}
|
||||
|
||||
export interface PersonRef {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GameListRecord {
|
||||
gameId: string;
|
||||
date: string;
|
||||
time: string;
|
||||
season: number;
|
||||
stadium: string;
|
||||
homeTeamCode: string;
|
||||
awayTeamCode: string;
|
||||
homeTeamName: string;
|
||||
awayTeamName: string;
|
||||
homeRank: number | null;
|
||||
awayRank: number | null;
|
||||
broadcast: string;
|
||||
status: {
|
||||
stateCode: string;
|
||||
cancelCode: string;
|
||||
cancelName: string;
|
||||
inning: number | null;
|
||||
topBottom: string | null;
|
||||
};
|
||||
score: {
|
||||
home: number | null;
|
||||
away: number | null;
|
||||
};
|
||||
count: {
|
||||
ball: number | null;
|
||||
strike: number | null;
|
||||
out: number | null;
|
||||
};
|
||||
runners: {
|
||||
first: number | null;
|
||||
second: number | null;
|
||||
third: number | null;
|
||||
};
|
||||
currentBatter: PersonRef | null;
|
||||
currentPitcher: PersonRef | null;
|
||||
startingPitchers: {
|
||||
away: PersonRef | null;
|
||||
home: PersonRef | null;
|
||||
};
|
||||
decisions: {
|
||||
winner: PersonRef | null;
|
||||
loser: PersonRef | null;
|
||||
save: PersonRef | null;
|
||||
};
|
||||
flags: {
|
||||
lineupAvailable: boolean;
|
||||
vodAvailable: boolean;
|
||||
scoreAvailable: boolean;
|
||||
starterAnnounced: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GameListResult {
|
||||
date: string;
|
||||
league: LeagueCode | string;
|
||||
series: string;
|
||||
games: GameListRecord[];
|
||||
}
|
||||
|
||||
// ── Raw API response ──
|
||||
|
||||
interface RawGame {
|
||||
G_ID: string;
|
||||
G_DT: string;
|
||||
G_TM: string;
|
||||
SEASON_ID: number;
|
||||
S_NM: string;
|
||||
HOME_ID: string;
|
||||
AWAY_ID: string;
|
||||
HOME_NM: string;
|
||||
AWAY_NM: string;
|
||||
T_RANK_NO: number | null;
|
||||
B_RANK_NO: number | null;
|
||||
TV_IF: string;
|
||||
GAME_STATE_SC: string;
|
||||
CANCEL_SC_ID: string;
|
||||
CANCEL_SC_NM: string;
|
||||
GAME_INN_NO: number | null;
|
||||
GAME_TB_SC_NM: string | null;
|
||||
T_SCORE_CN: string | null;
|
||||
B_SCORE_CN: string | null;
|
||||
BALL_CN: number | null;
|
||||
STRIKE_CN: number | null;
|
||||
OUT_CN: number | null;
|
||||
B1_BAT_ORDER_NO: number | null;
|
||||
B2_BAT_ORDER_NO: number | null;
|
||||
B3_BAT_ORDER_NO: number | null;
|
||||
T_P_ID: number | null;
|
||||
T_P_NM: string;
|
||||
B_P_ID: number | null;
|
||||
B_P_NM: string;
|
||||
T_PIT_P_ID: number | null;
|
||||
T_PIT_P_NM: string;
|
||||
B_PIT_P_ID: number | null;
|
||||
B_PIT_P_NM: string;
|
||||
W_PIT_P_ID: number | null;
|
||||
W_PIT_P_NM: string;
|
||||
L_PIT_P_ID: number | null;
|
||||
L_PIT_P_NM: string;
|
||||
SV_PIT_P_ID: number | null;
|
||||
SV_PIT_P_NM: string;
|
||||
LINEUP_CK: number;
|
||||
VOD_CK: number;
|
||||
SCORE_CK: string;
|
||||
START_PIT_CK: number;
|
||||
}
|
||||
|
||||
interface GameListResponse {
|
||||
game: RawGame[];
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function formatDate(date: string | Date): string {
|
||||
if (typeof date === "string") {
|
||||
if (!/^\d{8}$/.test(date)) {
|
||||
throw new Error(`Invalid date: "${date}". Expected YYYYMMDD.`);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}${m}${d}`;
|
||||
}
|
||||
|
||||
function parseScore(v: string | null): number | null {
|
||||
if (v == null || v === "") return null;
|
||||
const n = parseInt(v, 10);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function person(id: number | null, name: string): PersonRef | null {
|
||||
const trimmed = name?.trim() ?? "";
|
||||
if (!id || !trimmed) return null;
|
||||
return { id, name: trimmed };
|
||||
}
|
||||
|
||||
// ── Parsing ──
|
||||
|
||||
export function parseGameListResponse(
|
||||
data: GameListResponse
|
||||
): GameListRecord[] {
|
||||
return data.game.map((g) => ({
|
||||
gameId: g.G_ID,
|
||||
date: g.G_DT,
|
||||
time: g.G_TM,
|
||||
season: g.SEASON_ID,
|
||||
stadium: g.S_NM,
|
||||
homeTeamCode: g.HOME_ID,
|
||||
awayTeamCode: g.AWAY_ID,
|
||||
homeTeamName: g.HOME_NM,
|
||||
awayTeamName: g.AWAY_NM,
|
||||
homeRank: g.B_RANK_NO,
|
||||
awayRank: g.T_RANK_NO,
|
||||
broadcast: g.TV_IF,
|
||||
status: {
|
||||
stateCode: g.GAME_STATE_SC,
|
||||
cancelCode: g.CANCEL_SC_ID,
|
||||
cancelName: g.CANCEL_SC_NM,
|
||||
inning: g.GAME_INN_NO,
|
||||
topBottom: g.GAME_TB_SC_NM,
|
||||
},
|
||||
score: {
|
||||
home: parseScore(g.B_SCORE_CN),
|
||||
away: parseScore(g.T_SCORE_CN),
|
||||
},
|
||||
count: {
|
||||
ball: g.BALL_CN,
|
||||
strike: g.STRIKE_CN,
|
||||
out: g.OUT_CN,
|
||||
},
|
||||
runners: {
|
||||
first: g.B1_BAT_ORDER_NO,
|
||||
second: g.B2_BAT_ORDER_NO,
|
||||
third: g.B3_BAT_ORDER_NO,
|
||||
},
|
||||
currentBatter: person(g.T_P_ID, g.T_P_NM),
|
||||
currentPitcher: person(g.B_P_ID, g.B_P_NM),
|
||||
startingPitchers: {
|
||||
away: person(g.T_PIT_P_ID, g.T_PIT_P_NM),
|
||||
home: person(g.B_PIT_P_ID, g.B_PIT_P_NM),
|
||||
},
|
||||
decisions: {
|
||||
winner: person(g.W_PIT_P_ID, g.W_PIT_P_NM),
|
||||
loser: person(g.L_PIT_P_ID, g.L_PIT_P_NM),
|
||||
save: person(g.SV_PIT_P_ID, g.SV_PIT_P_NM),
|
||||
},
|
||||
flags: {
|
||||
lineupAvailable: g.LINEUP_CK === 1,
|
||||
vodAvailable: g.VOD_CK === 1,
|
||||
scoreAvailable: g.SCORE_CK === "1",
|
||||
starterAnnounced: g.START_PIT_CK === 1,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Fetch ──
|
||||
|
||||
export async function fetchGameList(
|
||||
filters: GameListFilters
|
||||
): Promise<GameListResult> {
|
||||
const date = formatDate(filters.date);
|
||||
const league = filters.league ?? LeagueCode.KBO;
|
||||
const series = filters.series ?? SERIES_CODES["정규"];
|
||||
|
||||
const res = await fetch(GAME_LIST_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
"User-Agent": USER_AGENT,
|
||||
Referer: "https://www.koreabaseball.com/Schedule/GameCenter/Main.aspx",
|
||||
},
|
||||
body: JSON.stringify({ leId: league, srId: series, date }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`GetKboGameList failed: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
// KBO 응답은 종종 JSON 뒤에 HTML 에러 페이지가 이어 붙어 있다.
|
||||
// JSON 본문만 추출한다.
|
||||
const raw = await res.text();
|
||||
const htmlIdx = raw.search(/<!DOCTYPE|<html/i);
|
||||
const jsonText = htmlIdx >= 0 ? raw.slice(0, htmlIdx) : raw;
|
||||
const json: GameListResponse = JSON.parse(jsonText);
|
||||
const games = parseGameListResponse(json);
|
||||
|
||||
return { date, league, series, games };
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { stripTags, decodeHtmlEntities } from "./html-utils.js";
|
||||
import { TEAM_CODES } from "./player/common.js";
|
||||
import { fetchGameList, type PersonRef } from "./game-list.js";
|
||||
|
||||
function toTeamCode(name: string): string {
|
||||
return TEAM_CODES[name] ?? name;
|
||||
@ -28,6 +29,8 @@ export interface ScheduleGame {
|
||||
broadcast: string;
|
||||
note: string;
|
||||
gameId: string | null;
|
||||
awayStartingPitcher: PersonRef | null;
|
||||
homeStartingPitcher: PersonRef | null;
|
||||
}
|
||||
|
||||
export interface ScheduleFilters {
|
||||
@ -204,6 +207,8 @@ export function parseScheduleResponse(data: ScheduleResponse): ScheduleGame[] {
|
||||
broadcast,
|
||||
note,
|
||||
gameId,
|
||||
awayStartingPitcher: null,
|
||||
homeStartingPitcher: null,
|
||||
});
|
||||
}
|
||||
|
||||
@ -237,5 +242,53 @@ export async function fetchSchedule(
|
||||
const json: ScheduleResponse = await res.json();
|
||||
const games = parseScheduleResponse(json);
|
||||
|
||||
await enrichStartingPitchers(games, filters.year, filters.series);
|
||||
|
||||
return { year: filters.year, month: filters.month, games };
|
||||
}
|
||||
|
||||
// ── Enrichment ──
|
||||
|
||||
/**
|
||||
* 스케줄 응답에는 선발투수 정보가 없으므로 게임센터(GetKboGameList)를
|
||||
* 날짜별로 한 번씩 호출하여 gameId로 매칭해 채워넣는다.
|
||||
*/
|
||||
async function enrichStartingPitchers(
|
||||
games: ScheduleGame[],
|
||||
year: number,
|
||||
series?: string
|
||||
): Promise<void> {
|
||||
// "MM.DD" → "YYYYMMDD"
|
||||
const dateKeys = new Set<string>();
|
||||
for (const g of games) {
|
||||
if (!g.gameId) continue;
|
||||
const m = g.date.match(/^(\d{2})\.(\d{2})$/);
|
||||
if (!m) continue;
|
||||
dateKeys.add(`${year}${m[1]}${m[2]}`);
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
[...dateKeys].map((date) =>
|
||||
fetchGameList({ date, series }).catch(() => null)
|
||||
)
|
||||
);
|
||||
|
||||
const byGameId = new Map<string, { away: PersonRef | null; home: PersonRef | null }>();
|
||||
for (const r of results) {
|
||||
if (!r) continue;
|
||||
for (const rec of r.games) {
|
||||
byGameId.set(rec.gameId, {
|
||||
away: rec.startingPitchers.away,
|
||||
home: rec.startingPitchers.home,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const g of games) {
|
||||
if (!g.gameId) continue;
|
||||
const sp = byGameId.get(g.gameId);
|
||||
if (!sp) continue;
|
||||
g.awayStartingPitcher = sp.away;
|
||||
g.homeStartingPitcher = sp.home;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ const LOCK_TTL_MS = 30_000;
|
||||
interface CacheDoc<T> {
|
||||
data: T;
|
||||
updatedAt: FirebaseFirestore.Timestamp;
|
||||
ttlMs?: number;
|
||||
}
|
||||
|
||||
interface LockDoc {
|
||||
@ -40,22 +41,42 @@ export async function getCached<T>(key: string, ttlMs: number): Promise<T | null
|
||||
const snap = await firestore.collection(CACHE_COLLECTION).doc(key).get();
|
||||
if (!snap.exists) return null;
|
||||
const doc = snap.data() as CacheDoc<T>;
|
||||
const effectiveTtl = doc.ttlMs ?? ttlMs;
|
||||
const age = Date.now() - doc.updatedAt.toMillis();
|
||||
if (age > ttlMs) return null;
|
||||
if (age > effectiveTtl) return null;
|
||||
return doc.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 doc의 메타데이터를 조회한다. 부분 갱신이 필요한 키에 대해
|
||||
* `data`를 매번 전송받지 않고 유효성만 판단하고 싶을 때 사용한다.
|
||||
*/
|
||||
export async function getCachedMeta(
|
||||
key: string
|
||||
): Promise<{ updatedAt: FirebaseFirestore.Timestamp; ttlMs?: number } | null> {
|
||||
const snap = await firestore.collection(CACHE_COLLECTION).doc(key).get();
|
||||
if (!snap.exists) return null;
|
||||
const doc = snap.data() as CacheDoc<unknown>;
|
||||
return { updatedAt: doc.updatedAt, ttlMs: doc.ttlMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 문서를 저장한다. `updatedAt`은 서버 타임스탬프로 기록된다.
|
||||
*
|
||||
* @param key - 캐시 키
|
||||
* @param data - 저장할 데이터
|
||||
*/
|
||||
export async function setCached<T>(key: string, data: T): Promise<void> {
|
||||
await firestore.collection(CACHE_COLLECTION).doc(key).set({
|
||||
export async function setCached<T>(
|
||||
key: string,
|
||||
data: T,
|
||||
ttlMs?: number
|
||||
): Promise<void> {
|
||||
const payload: Record<string, unknown> = {
|
||||
data,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
};
|
||||
if (ttlMs !== undefined) payload.ttlMs = ttlMs;
|
||||
await firestore.collection(CACHE_COLLECTION).doc(key).set(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -137,9 +158,11 @@ export async function getOrFetch<T>(
|
||||
|
||||
try {
|
||||
const fresh = await fetcher();
|
||||
await setCached(key, fresh);
|
||||
await setCached(key, fresh, ttlMs);
|
||||
return fresh;
|
||||
} finally {
|
||||
await releaseLock(key);
|
||||
}
|
||||
}
|
||||
|
||||
export { acquireLock, releaseLock };
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
fetchSchedule,
|
||||
type ScheduleFilters,
|
||||
type ScheduleResult,
|
||||
type ScheduleGame,
|
||||
} from "../kbo/schedule.js";
|
||||
import {
|
||||
fetchPlayerStatsInitial,
|
||||
@ -16,7 +17,14 @@ import {
|
||||
type PlayerFilters,
|
||||
type PlayerStatsResult,
|
||||
} from "../kbo/player/common.js";
|
||||
import { encodeKey, getOrFetch } from "./kboCacheRepository.js";
|
||||
import {
|
||||
encodeKey,
|
||||
getOrFetch,
|
||||
setCached,
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
} from "./kboCacheRepository.js";
|
||||
import { firestore } from "../firebase.js";
|
||||
|
||||
const TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
@ -55,8 +63,132 @@ async function fetchSingleYearRank(year: number): Promise<TeamRankResult> {
|
||||
|
||||
// ── 경기 일정 ──
|
||||
|
||||
const CACHE_COLLECTION = "kboCache";
|
||||
const DAY_KEY_PREFIX = "schedule_day";
|
||||
const MONTH_LOCK_PREFIX = "schedule_month";
|
||||
const FALLBACK_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const SEVEN_DAYS_MS = 7 * ONE_DAY_MS;
|
||||
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const THIRTY_SEC_MS = 30_000;
|
||||
|
||||
interface DayCacheDoc {
|
||||
data: ScheduleGame[];
|
||||
updatedAt: FirebaseFirestore.Timestamp;
|
||||
ttlMs?: number;
|
||||
}
|
||||
|
||||
function dayKey(
|
||||
yyyymmdd: string,
|
||||
team: string | undefined,
|
||||
series: string | undefined
|
||||
): string {
|
||||
return encodeKey([DAY_KEY_PREFIX, yyyymmdd, team, series]);
|
||||
}
|
||||
|
||||
function formatYmd(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}${m}${dd}`;
|
||||
}
|
||||
|
||||
function enumerateMonthDays(year: number, month: number): string[] {
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const out: string[] = [];
|
||||
for (let d = 1; d <= lastDay; d++) {
|
||||
out.push(formatYmd(new Date(year, month - 1, d)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function gameDateToYmd(year: number, mmDot: string): string | null {
|
||||
const m = mmDot.match(/^(\d{2})\.(\d{2})$/);
|
||||
if (!m) return null;
|
||||
return `${year}${m[1]}${m[2]}`;
|
||||
}
|
||||
|
||||
function parseHHMM(t: string): number | null {
|
||||
const m = t.match(/^(\d{1,2}):(\d{2})$/);
|
||||
return m ? parseInt(m[1], 10) * 60 + parseInt(m[2], 10) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 조건에 맞는 KBO 경기 일정을 조회한다. 필터 조합별로 개별 캐싱된다.
|
||||
* 일자별 TTL 산출. 본 모듈 상단의 표 참조.
|
||||
*/
|
||||
function dayTtlMs(yyyymmdd: string, games: ScheduleGame[]): number {
|
||||
const y = parseInt(yyyymmdd.slice(0, 4), 10);
|
||||
const m = parseInt(yyyymmdd.slice(4, 6), 10);
|
||||
const d = parseInt(yyyymmdd.slice(6, 8), 10);
|
||||
const dayStart = new Date(y, m - 1, d).getTime();
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate()
|
||||
).getTime();
|
||||
|
||||
const diffDays = Math.round((dayStart - todayStart) / ONE_DAY_MS);
|
||||
|
||||
// 어제 이전: 그 날의 모든 경기가 종료/취소면 7d, 미종료가 남았으면 30s.
|
||||
if (diffDays < 0) {
|
||||
if (games.length === 0) return SEVEN_DAYS_MS;
|
||||
const allDone = games.every(
|
||||
(g) => g.status === "completed" || g.status === "cancelled"
|
||||
);
|
||||
return allDone ? SEVEN_DAYS_MS : THIRTY_SEC_MS;
|
||||
}
|
||||
if (diffDays === 1) return SIX_HOURS_MS;
|
||||
if (diffDays >= 2) return SEVEN_DAYS_MS;
|
||||
|
||||
// 오늘
|
||||
if (games.length === 0) return SIX_HOURS_MS;
|
||||
const allDone = games.every(
|
||||
(g) => g.status === "completed" || g.status === "cancelled"
|
||||
);
|
||||
if (allDone) return SEVEN_DAYS_MS;
|
||||
|
||||
const startTimes = games
|
||||
.map((g) => parseHHMM(g.time))
|
||||
.filter((m): m is number => m != null);
|
||||
const earliest = startTimes.length ? Math.min(...startTimes) : null;
|
||||
const nowMin = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
if (earliest != null && nowMin < earliest) {
|
||||
const untilStartMs = (earliest - nowMin) * 60_000;
|
||||
return Math.min(ONE_HOUR_MS, Math.max(untilStartMs, THIRTY_SEC_MS));
|
||||
}
|
||||
return THIRTY_SEC_MS;
|
||||
}
|
||||
|
||||
async function readDayDocs(keys: string[]): Promise<Map<string, ScheduleGame[] | null>> {
|
||||
if (keys.length === 0) return new Map();
|
||||
const refs = keys.map((k) => firestore.collection(CACHE_COLLECTION).doc(k));
|
||||
const snaps = await firestore.getAll(...refs);
|
||||
const out = new Map<string, ScheduleGame[] | null>();
|
||||
const nowMs = Date.now();
|
||||
snaps.forEach((snap, i) => {
|
||||
const k = keys[i];
|
||||
if (!snap.exists) {
|
||||
out.set(k, null);
|
||||
return;
|
||||
}
|
||||
const doc = snap.data() as DayCacheDoc;
|
||||
const ttl = doc.ttlMs ?? FALLBACK_TTL_MS;
|
||||
const age = nowMs - doc.updatedAt.toMillis();
|
||||
out.set(k, age > ttl ? null : doc.data);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 조건에 맞는 KBO 경기 일정을 조회한다. 일자별로 개별 캐싱한다.
|
||||
*
|
||||
* 외부 KBO 엔드포인트는 월 단위 응답이지만, 캐시는 일자 단위로 분리해
|
||||
* 변동성이 다른 일자가 서로의 TTL에 영향을 주지 않도록 한다.
|
||||
*
|
||||
* @param filters - 연도/월/팀/시리즈 필터
|
||||
* @returns 일정 결과
|
||||
@ -64,14 +196,70 @@ async function fetchSingleYearRank(year: number): Promise<TeamRankResult> {
|
||||
export async function fetchScheduleFromKbo(
|
||||
filters: ScheduleFilters
|
||||
): Promise<ScheduleResult> {
|
||||
const key = encodeKey([
|
||||
"schedule",
|
||||
filters.year,
|
||||
filters.month,
|
||||
filters.team,
|
||||
filters.series,
|
||||
]);
|
||||
return getOrFetch<ScheduleResult>(key, TTL_MS, () => fetchSchedule(filters));
|
||||
const allDays = enumerateMonthDays(filters.year, filters.month);
|
||||
const keys = allDays.map((d) => dayKey(d, filters.team, filters.series));
|
||||
|
||||
let cached = await readDayDocs(keys);
|
||||
let missing = allDays.filter((_, i) => cached.get(keys[i]) === null);
|
||||
|
||||
if (missing.length > 0) {
|
||||
const lockKey = encodeKey([
|
||||
MONTH_LOCK_PREFIX,
|
||||
filters.year,
|
||||
filters.month,
|
||||
filters.team,
|
||||
filters.series,
|
||||
]);
|
||||
const locked = await acquireLock(lockKey);
|
||||
|
||||
if (locked) {
|
||||
try {
|
||||
const fresh = await fetchSchedule(filters);
|
||||
|
||||
const byDate = new Map<string, ScheduleGame[]>();
|
||||
for (const g of fresh.games) {
|
||||
const ymd = gameDateToYmd(filters.year, g.date);
|
||||
if (!ymd) continue;
|
||||
const list = byDate.get(ymd) ?? [];
|
||||
list.push(g);
|
||||
byDate.set(ymd, list);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
missing.map((ymd) => {
|
||||
const games = byDate.get(ymd) ?? [];
|
||||
const ttl = dayTtlMs(ymd, games);
|
||||
return setCached(dayKey(ymd, filters.team, filters.series), games, ttl);
|
||||
})
|
||||
);
|
||||
|
||||
cached = await readDayDocs(keys);
|
||||
} finally {
|
||||
await releaseLock(lockKey);
|
||||
}
|
||||
} else {
|
||||
// 다른 요청이 fetch 중. 짧게 polling.
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 25_000) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
cached = await readDayDocs(keys);
|
||||
missing = allDays.filter((_, i) => cached.get(keys[i]) === null);
|
||||
if (missing.length === 0) break;
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
// 타임아웃 — 캐시 우회하여 직접 fetch (저장은 안 함).
|
||||
return fetchSchedule(filters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const games: ScheduleGame[] = [];
|
||||
for (const ymd of allDays) {
|
||||
const list = cached.get(dayKey(ymd, filters.team, filters.series)) ?? [];
|
||||
games.push(...list);
|
||||
}
|
||||
|
||||
return { year: filters.year, month: filters.month, games };
|
||||
}
|
||||
|
||||
// ── 선수 기록 ──
|
||||
|
||||
@ -30,7 +30,7 @@ export const kboDailyRefresh = onSchedule(
|
||||
logger.info(`KBO refresh start: ${year}-${month}`);
|
||||
|
||||
await invalidateByPrefix("rank__");
|
||||
await invalidateByPrefix("schedule__");
|
||||
await invalidateByPrefix("schedule_day__");
|
||||
|
||||
try {
|
||||
await fetchRankFromKbo([year]);
|
||||
|
||||
24
src/services/gameListService.ts
Normal file
24
src/services/gameListService.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import {
|
||||
fetchGameList,
|
||||
LEAGUE_CODES,
|
||||
LeagueCode,
|
||||
SERIES_CODES,
|
||||
type GameListResult,
|
||||
} from "../kbo/game-list.js";
|
||||
|
||||
export async function getGameList(
|
||||
date: string | Date,
|
||||
series?: string,
|
||||
league?: string
|
||||
): Promise<GameListResult> {
|
||||
const resolvedSeries = series ? (SERIES_CODES[series] ?? series) : undefined;
|
||||
const resolvedLeague = league
|
||||
? (LEAGUE_CODES[league] ?? (league as LeagueCode))
|
||||
: undefined;
|
||||
|
||||
return fetchGameList({
|
||||
date,
|
||||
series: resolvedSeries,
|
||||
league: resolvedLeague,
|
||||
});
|
||||
}
|
||||
@ -23,3 +23,12 @@ export type {
|
||||
|
||||
export { TEAM_CODES } from "../kbo/player/common.js";
|
||||
export { SERIES_CODES } from "../kbo/schedule.js";
|
||||
|
||||
export type {
|
||||
GameListFilters,
|
||||
GameListRecord,
|
||||
GameListResult,
|
||||
PersonRef,
|
||||
} from "../kbo/game-list.js";
|
||||
|
||||
export { LeagueCode, LEAGUE_CODES } from "../kbo/game-list.js";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user