Integrate KBO live game data and refine schedule caching logic.

- KBO 게임센터 API를 연동하여 실시간 경기 데이터(선발 투수, 이닝, 스코어 등) 조회 기능을 구현했습니다.
- 경기 일정 캐시를 일 단위로 관리하도록 개선하고, 경기 상태에 따라 TTL을 동적으로 적용하는 로직을 도입했습니다.
- 일정 데이터 요청 시 선발 투수 정보를 병합하여 제공하도록 기능을 강화했습니다.
- CLI 명령과 API 엔드포인트에 실시간 경기 정보를 확인할 수 있는 경로를 추가했습니다.
This commit is contained in:
윤정민 2026-04-14 16:42:57 +09:00
parent 697e0ff11a
commit a153206391
10 changed files with 773 additions and 17 deletions

View File

@ -2,12 +2,22 @@ import { onRequest } from "firebase-functions/https";
import { getRank } from "../services/rankService.js"; 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 type { PlayerFilters } from "../types/kbo.js"; import type { PlayerFilters } from "../types/kbo.js";
enum KboPath { enum KboPath {
Rank = "rank", Rank = "rank",
Schedule = "schedule", Schedule = "schedule",
Player = "player", 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 { 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/rank?year=2026
// GET /kbo/schedule?year=2026&month=4&team=LG&series=정규 // 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/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) => { export const kbo = onRequest(async (req, res) => {
const path = req.path.replace(/^\/+|\/+$/g, "").split("/").pop() ?? ""; const path = req.path.replace(/^\/+|\/+$/g, "").split("/").pop() ?? "";
@ -62,6 +73,21 @@ export const kbo = onRequest(async (req, res) => {
return; 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: { case KboPath.Player: {
const type = String(req.query.type ?? ""); const type = String(req.query.type ?? "");
const validTypes = getValidPlayerTypes(); const validTypes = getValidPlayerTypes();

View File

@ -24,7 +24,30 @@ KBO(한국프로야구) 공식 사이트에서 팀 순위, 선수 기록, 경기
- 반환 데이터: 날짜, 시간, 원정팀, 홈팀, 점수, 경기 상태(완료/취소/예정), 구장, 중계, gameId - 반환 데이터: 날짜, 시간, 원정팀, 홈팀, 점수, 경기 상태(완료/취소/예정), 구장, 중계, 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가지 카테고리의 선수 기록을 조회한다. 4가지 카테고리의 선수 기록을 조회한다.

View File

@ -42,6 +42,13 @@ import {
type ScheduleGame, type ScheduleGame,
type ScheduleResult, type ScheduleResult,
} from "./schedule.js"; } from "./schedule.js";
import {
fetchGameList,
LEAGUE_CODES,
LeagueCode,
type GameListRecord,
type GameListResult,
} from "./game-list.js";
const PLAYER_CONFIGS: Record<string, PlayerPageConfig> = { const PLAYER_CONFIGS: Record<string, PlayerPageConfig> = {
hitter: HITTER_CONFIG, 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 ── // ── Commands ──
async function scheduleCommand( async function scheduleCommand(
@ -326,6 +427,7 @@ function printHelp() {
console.log(" rank [year...] Team rankings"); console.log(" rank [year...] Team rankings");
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(""); console.log("");
console.log("Options:"); console.log("Options:");
console.log(" --json JSON output"); console.log(" --json JSON output");
@ -341,6 +443,11 @@ function printHelp() {
console.log("Schedule filters:"); console.log("Schedule filters:");
console.log(" --team=팀명 팀 (LG, 삼성, KT, ...)"); console.log(" --team=팀명 팀 (LG, 삼성, KT, ...)");
console.log(" --series=값 시리즈 (정규, 시범, 포스트)"); 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() { async function main() {
@ -411,6 +518,39 @@ async function main() {
return; 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") { if (command === "schedule") {
const rest = args.slice(1); const rest = args.slice(1);
let jsonMode = false; let jsonMode = false;

270
src/kbo/game-list.ts Normal file
View 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 };
}

View File

@ -1,5 +1,6 @@
import { stripTags, decodeHtmlEntities } from "./html-utils.js"; import { stripTags, decodeHtmlEntities } from "./html-utils.js";
import { TEAM_CODES } from "./player/common.js"; import { TEAM_CODES } from "./player/common.js";
import { fetchGameList, type PersonRef } from "./game-list.js";
function toTeamCode(name: string): string { function toTeamCode(name: string): string {
return TEAM_CODES[name] ?? name; return TEAM_CODES[name] ?? name;
@ -28,6 +29,8 @@ export interface ScheduleGame {
broadcast: string; broadcast: string;
note: string; note: string;
gameId: string | null; gameId: string | null;
awayStartingPitcher: PersonRef | null;
homeStartingPitcher: PersonRef | null;
} }
export interface ScheduleFilters { export interface ScheduleFilters {
@ -204,6 +207,8 @@ export function parseScheduleResponse(data: ScheduleResponse): ScheduleGame[] {
broadcast, broadcast,
note, note,
gameId, gameId,
awayStartingPitcher: null,
homeStartingPitcher: null,
}); });
} }
@ -237,5 +242,53 @@ export async function fetchSchedule(
const json: ScheduleResponse = await res.json(); const json: ScheduleResponse = await res.json();
const games = parseScheduleResponse(json); const games = parseScheduleResponse(json);
await enrichStartingPitchers(games, filters.year, filters.series);
return { year: filters.year, month: filters.month, games }; 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;
}
}

View File

@ -8,6 +8,7 @@ const LOCK_TTL_MS = 30_000;
interface CacheDoc<T> { interface CacheDoc<T> {
data: T; data: T;
updatedAt: FirebaseFirestore.Timestamp; updatedAt: FirebaseFirestore.Timestamp;
ttlMs?: number;
} }
interface LockDoc { 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(); const snap = await firestore.collection(CACHE_COLLECTION).doc(key).get();
if (!snap.exists) return null; if (!snap.exists) return null;
const doc = snap.data() as CacheDoc<T>; const doc = snap.data() as CacheDoc<T>;
const effectiveTtl = doc.ttlMs ?? ttlMs;
const age = Date.now() - doc.updatedAt.toMillis(); const age = Date.now() - doc.updatedAt.toMillis();
if (age > ttlMs) return null; if (age > effectiveTtl) return null;
return doc.data; 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` . * . `updatedAt` .
* *
* @param key - * @param key -
* @param data - * @param data -
*/ */
export async function setCached<T>(key: string, data: T): Promise<void> { export async function setCached<T>(
await firestore.collection(CACHE_COLLECTION).doc(key).set({ key: string,
data: T,
ttlMs?: number
): Promise<void> {
const payload: Record<string, unknown> = {
data, data,
updatedAt: FieldValue.serverTimestamp(), 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 { try {
const fresh = await fetcher(); const fresh = await fetcher();
await setCached(key, fresh); await setCached(key, fresh, ttlMs);
return fresh; return fresh;
} finally { } finally {
await releaseLock(key); await releaseLock(key);
} }
} }
export { acquireLock, releaseLock };

View File

@ -7,6 +7,7 @@ import {
fetchSchedule, fetchSchedule,
type ScheduleFilters, type ScheduleFilters,
type ScheduleResult, type ScheduleResult,
type ScheduleGame,
} from "../kbo/schedule.js"; } from "../kbo/schedule.js";
import { import {
fetchPlayerStatsInitial, fetchPlayerStatsInitial,
@ -16,7 +17,14 @@ import {
type PlayerFilters, type PlayerFilters,
type PlayerStatsResult, type PlayerStatsResult,
} from "../kbo/player/common.js"; } 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 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 - /// * @param filters - ///
* @returns * @returns
@ -64,14 +196,70 @@ async function fetchSingleYearRank(year: number): Promise<TeamRankResult> {
export async function fetchScheduleFromKbo( export async function fetchScheduleFromKbo(
filters: ScheduleFilters filters: ScheduleFilters
): Promise<ScheduleResult> { ): Promise<ScheduleResult> {
const key = encodeKey([ const allDays = enumerateMonthDays(filters.year, filters.month);
"schedule", const keys = allDays.map((d) => dayKey(d, filters.team, filters.series));
filters.year,
filters.month, let cached = await readDayDocs(keys);
filters.team, let missing = allDays.filter((_, i) => cached.get(keys[i]) === null);
filters.series,
]); if (missing.length > 0) {
return getOrFetch<ScheduleResult>(key, TTL_MS, () => fetchSchedule(filters)); 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 };
} }
// ── 선수 기록 ── // ── 선수 기록 ──

View File

@ -30,7 +30,7 @@ export const kboDailyRefresh = onSchedule(
logger.info(`KBO refresh start: ${year}-${month}`); logger.info(`KBO refresh start: ${year}-${month}`);
await invalidateByPrefix("rank__"); await invalidateByPrefix("rank__");
await invalidateByPrefix("schedule__"); await invalidateByPrefix("schedule_day__");
try { try {
await fetchRankFromKbo([year]); await fetchRankFromKbo([year]);

View 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,
});
}

View File

@ -23,3 +23,12 @@ export type {
export { TEAM_CODES } from "../kbo/player/common.js"; export { TEAM_CODES } from "../kbo/player/common.js";
export { SERIES_CODES } from "../kbo/schedule.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";