diff --git a/src/handlers/kboHandlers.ts b/src/handlers/kboHandlers.ts index 8d9dbfb..a0b0d41 100644 --- a/src/handlers/kboHandlers.ts +++ b/src/handlers/kboHandlers.ts @@ -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(); diff --git a/src/kbo/README.md b/src/kbo/README.md index b1b37d1..337c496 100644 --- a/src/kbo/README.md +++ b/src/kbo/README.md @@ -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가지 카테고리의 선수 기록을 조회한다. diff --git a/src/kbo/cli.ts b/src/kbo/cli.ts index 0dabe81..28dc853 100644 --- a/src/kbo/cli.ts +++ b/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 = { 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 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; diff --git a/src/kbo/game-list.ts b/src/kbo/game-list.ts new file mode 100644 index 0000000..d220fe0 --- /dev/null +++ b/src/kbo/game-list.ts @@ -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 = { + 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 { + 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(/= 0 ? raw.slice(0, htmlIdx) : raw; + const json: GameListResponse = JSON.parse(jsonText); + const games = parseGameListResponse(json); + + return { date, league, series, games }; +} diff --git a/src/kbo/schedule.ts b/src/kbo/schedule.ts index 6ca22ff..82da838 100644 --- a/src/kbo/schedule.ts +++ b/src/kbo/schedule.ts @@ -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 { + // "MM.DD" → "YYYYMMDD" + const dateKeys = new Set(); + 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(); + 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; + } +} diff --git a/src/repositories/kboCacheRepository.ts b/src/repositories/kboCacheRepository.ts index 0b25074..0a9b9bc 100644 --- a/src/repositories/kboCacheRepository.ts +++ b/src/repositories/kboCacheRepository.ts @@ -8,6 +8,7 @@ const LOCK_TTL_MS = 30_000; interface CacheDoc { data: T; updatedAt: FirebaseFirestore.Timestamp; + ttlMs?: number; } interface LockDoc { @@ -40,22 +41,42 @@ export async function getCached(key: string, ttlMs: number): Promise; + 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; + return { updatedAt: doc.updatedAt, ttlMs: doc.ttlMs }; +} + /** * 캐시 문서를 저장한다. `updatedAt`은 서버 타임스탬프로 기록된다. * * @param key - 캐시 키 * @param data - 저장할 데이터 */ -export async function setCached(key: string, data: T): Promise { - await firestore.collection(CACHE_COLLECTION).doc(key).set({ +export async function setCached( + key: string, + data: T, + ttlMs?: number +): Promise { + const payload: Record = { 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( try { const fresh = await fetcher(); - await setCached(key, fresh); + await setCached(key, fresh, ttlMs); return fresh; } finally { await releaseLock(key); } } + +export { acquireLock, releaseLock }; diff --git a/src/repositories/kboRepository.ts b/src/repositories/kboRepository.ts index 03f1e79..64cf84e 100644 --- a/src/repositories/kboRepository.ts +++ b/src/repositories/kboRepository.ts @@ -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 { // ── 경기 일정 ── +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> { + 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(); + 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 { export async function fetchScheduleFromKbo( filters: ScheduleFilters ): Promise { - const key = encodeKey([ - "schedule", - filters.year, - filters.month, - filters.team, - filters.series, - ]); - return getOrFetch(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(); + 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 }; } // ── 선수 기록 ── diff --git a/src/scheduled/kboRefresh.ts b/src/scheduled/kboRefresh.ts index 5ee8a61..4a43fa5 100644 --- a/src/scheduled/kboRefresh.ts +++ b/src/scheduled/kboRefresh.ts @@ -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]); diff --git a/src/services/gameListService.ts b/src/services/gameListService.ts new file mode 100644 index 0000000..8179722 --- /dev/null +++ b/src/services/gameListService.ts @@ -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 { + 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, + }); +} diff --git a/src/types/kbo.ts b/src/types/kbo.ts index db184d7..5c28cda 100644 --- a/src/types/kbo.ts +++ b/src/types/kbo.ts @@ -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";