BaekRyang fe0ae5b372 Generalize KBO player stats and add pitcher/defense/runner support
- KBO 선수 기록 조회 로직을 공통화하고 투수, 수비, 주루 카테고리 추가
- CLI 명령어를 hitter에서 player <type>으로 통합 및 필터 옵션 강화
- ASP.NET AJAX 응답 파싱 방식을 개선하여 데이터 누락 문제 해결
- 정규시즌과 포스트시즌의 서로 다른 페이지 구조 및 컬럼 대응 지원
2026-04-02 12:46:32 +09:00

353 lines
9.6 KiB
TypeScript

/**
* KBO CLI
* Usage: npx tsx src/kbo/cli.ts <command> [options]
*
* Commands:
* rank [year...] 팀 순위
* player <hitter|pitcher|defense|runner> 선수 기록
*
* Options:
* --json JSON 출력
* --all 모든 페이지 조회 (player)
* --team=팀명 팀 필터 (e.g. --team=LG)
*/
import { padCell } from "./html-utils.js";
import {
fetchTeamRankInitial,
fetchTeamRank,
type TeamRank,
type TeamVsRecord,
type TeamRankResult,
type WinLossDraw,
} from "./team-rank.js";
import {
fetchPlayerStatsInitial,
fetchPlayerStats,
fetchAllPlayerStats,
TEAM_CODES,
type PlayerPageConfig,
type PlayerFilters,
type PlayerStatsResult,
} from "./player/common.js";
import { HITTER_CONFIG } from "./player/hitter.js";
import { PITCHER_CONFIG } from "./player/pitcher.js";
import { DEFENSE_CONFIG } from "./player/defense.js";
import { RUNNER_CONFIG } from "./player/runner.js";
const PLAYER_CONFIGS: Record<string, PlayerPageConfig> = {
hitter: HITTER_CONFIG,
pitcher: PITCHER_CONFIG,
defense: DEFENSE_CONFIG,
runner: RUNNER_CONFIG,
};
// ── Rank Formatting ──
function fmtWLD(wld: WinLossDraw): string {
return `${wld[0]}-${wld[1]}-${wld[2]}`;
}
function printRankTable(year: number, teams: TeamRank[]) {
console.log(`\n${"═".repeat(90)}`);
console.log(` KBO ${year} Team Rankings`);
console.log(`${"═".repeat(90)}`);
if (teams.length === 0) {
console.log(" No data found.");
return;
}
const header = ["#", "Team", "G", "W", "L", "D", "PCT", "GB", "L10", "STR"];
const widths = [4, 6, 4, 3, 3, 3, 6, 6, 12, 8];
console.log(" " + header.map((h, i) => padCell(h, widths[i])).join(" | "));
console.log(" " + widths.map((w) => "-".repeat(w)).join("-+-"));
for (const t of teams) {
const row = [
t.rank, t.team, t.games, t.wins, t.losses, t.draws,
t.winRate, t.gamesBehind, t.last10, t.streak,
];
console.log(" " + row.map((val, i) => padCell(val, widths[i])).join(" | "));
}
}
function printVsTable(year: number, vsRecords: TeamVsRecord[]) {
if (vsRecords.length === 0) return;
console.log(`\n${"─".repeat(90)}`);
console.log(` KBO ${year} Head-to-Head (W-L-D)`);
console.log(`${"─".repeat(90)}`);
const teamNames = vsRecords.map((r) => r.team);
const colW = 7;
console.log(
" " + padCell("", 6) + " | " +
teamNames.map((n) => padCell(n, colW)).join("| ") +
"| " + padCell("Total", colW)
);
console.log(
" " + "-".repeat(6) + "-+-" +
teamNames.map(() => "-".repeat(colW)).join("+-") +
"+-" + "-".repeat(colW)
);
for (const rec of vsRecords) {
const cells = teamNames.map((name) => {
if (name === rec.team) return padCell(" -", colW);
const wld = rec.headToHead[name];
return padCell(wld ? fmtWLD(wld) : "-", colW);
});
console.log(
" " + padCell(rec.team, 6) + " | " +
cells.join("| ") +
"| " + padCell(fmtWLD(rec.total), colW)
);
}
}
// ── Player Formatting ──
function printPlayerTable(
subcommand: string,
columns: readonly string[],
result: PlayerStatsResult
) {
const filterDesc = Object.entries(result.filters)
.map(([k, v]) => `${k}=${v}`)
.join(", ");
const label = filterDesc
? `KBO ${result.year} ${subcommand} (${filterDesc})`
: `KBO ${result.year} ${subcommand}`;
console.log(`\n${"═".repeat(100)}`);
console.log(` ${label}`);
console.log(`${"═".repeat(100)}`);
if (result.records.length === 0) {
console.log(" No data found.");
return;
}
const widths = columns.map((col) => {
if (col === "player") return 10;
if (col === "team") return 6;
return Math.max(col.length, 4);
});
console.log(
" " + columns.map((c, i) => padCell(c, widths[i])).join(" | ")
);
console.log(" " + widths.map((w) => "-".repeat(w)).join("-+-"));
for (const rec of result.records) {
const row = columns.map((c) => rec[c] ?? "");
console.log(
" " + row.map((val, i) => padCell(val, widths[i])).join(" | ")
);
}
console.log(`\n Total: ${result.records.length} players`);
}
// ── Commands ──
async function rankCommand(years: number[], jsonMode: boolean) {
console.log("Fetching KBO team rankings...\n");
const { result: initial, state: initState, currentYear } = await fetchTeamRankInitial();
let state = initState;
const results: TeamRankResult[] = [];
for (const year of years) {
process.stdout.write(` ${year}...`);
if (year === currentYear && results.length === 0) {
results.push(initial);
console.log(` ${initial.teams.length} teams (initial page)`);
} else {
const { result, newState } = await fetchTeamRank(year, state, currentYear);
state = newState;
results.push(result);
console.log(` ${result.teams.length} teams`);
}
}
if (jsonMode) {
console.log(JSON.stringify(results, null, 2));
} else {
for (const { year, teams, vsRecords } of results) {
printRankTable(year, teams);
printVsTable(year, vsRecords);
}
}
}
async function playerCommand(
subcommand: string,
config: PlayerPageConfig,
years: number[],
jsonMode: boolean,
filters: PlayerFilters,
allPages: boolean
) {
const filterDesc = Object.entries(filters)
.map(([k, v]) => `${k}=${v}`)
.join(", ");
console.log(`Fetching KBO ${subcommand} stats...\n`);
// team 필터는 팀명 → 팀코드 변환
const resolved: PlayerFilters = { ...filters };
if (resolved.team) {
resolved.team = TEAM_CODES[resolved.team] ?? resolved.team;
}
const { state: initState } = await fetchPlayerStatsInitial(config);
let state = initState;
const results: PlayerStatsResult[] = [];
for (const year of years) {
process.stdout.write(` ${year}${filterDesc ? ` (${filterDesc})` : ""}...`);
const fetchFn = allPages ? fetchAllPlayerStats : fetchPlayerStats;
const { result, newState } = await fetchFn(config, year, state, resolved);
state = newState;
results.push(result);
console.log(` ${result.records.length} players`);
}
if (jsonMode) {
console.log(JSON.stringify(results, null, 2));
} else {
for (const result of results) {
printPlayerTable(subcommand, result.columns, result);
}
}
}
// ── Main ──
function printHelp() {
console.log("Usage: npx tsx src/kbo/cli.ts <command> [options]");
console.log("");
console.log("Commands:");
console.log(" rank [year...] Team rankings");
console.log(" player <hitter|pitcher|defense|runner> Player stats");
console.log("");
console.log("Options:");
console.log(" --json JSON output");
console.log(" --all Fetch all pages (player)");
console.log("");
console.log("Player filters:");
console.log(" --team=팀명 팀 (LG, 삼성, KT, ...)");
console.log(" --series=값 시리즈 (0=정규, 1=시범, 3=준PO, 4=와카, 5=PO, 7=한국시리즈)");
console.log(" --pos=값 포지션 (2=포수, 3,4,5,6=내야수, 7,8,9=외야수)");
console.log(" --situation=값 상황별 (MONTH_SC, WEEK_SC, STADIUM_SC, HOMEAYAY_SC, ...)");
console.log(" --situationDetail=값");
}
async function main() {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === "help" || command === "--help") {
printHelp();
return;
}
if (command === "player") {
const subcommand = args[1];
if (!subcommand || !(subcommand in PLAYER_CONFIGS)) {
console.error(
`Usage: player <${Object.keys(PLAYER_CONFIGS).join("|")}> [year] [options]`
);
process.exit(1);
}
const rest = args.slice(2);
let jsonMode = false;
let allPages = false;
const filters: PlayerFilters = {};
const years: number[] = [];
const filterKeys: (keyof PlayerFilters)[] = ["team", "series", "pos", "situation", "situationDetail"];
for (const arg of rest) {
if (arg === "--json") {
jsonMode = true;
} else if (arg === "--all") {
allPages = true;
} else if (arg.startsWith("--")) {
const eqIdx = arg.indexOf("=");
if (eqIdx === -1) {
console.error(`Invalid option: ${arg} (expected --key=value)`);
process.exit(1);
}
const key = arg.slice(2, eqIdx) as keyof PlayerFilters;
const value = arg.slice(eqIdx + 1);
if (filterKeys.includes(key)) {
filters[key] = value;
} else {
console.error(`Unknown filter: --${key}`);
process.exit(1);
}
} else {
const y = parseInt(arg, 10);
if (y >= 1982 && y <= 2100) years.push(y);
else {
console.error(`Invalid year: ${arg} (1982~2100)`);
process.exit(1);
}
}
}
if (years.length === 0) years.push(new Date().getFullYear());
await playerCommand(
subcommand,
PLAYER_CONFIGS[subcommand],
years,
jsonMode,
filters,
allPages
);
return;
}
// rank 및 기타 커맨드
const rest = args.slice(1);
let jsonMode = false;
const years: number[] = [];
for (const arg of rest) {
if (arg === "--json") jsonMode = true;
else {
const y = parseInt(arg, 10);
if (y >= 1982 && y <= 2100) years.push(y);
else {
console.error(`Invalid year: ${arg} (1982~2100)`);
process.exit(1);
}
}
}
if (years.length === 0) years.push(new Date().getFullYear());
switch (command) {
case "rank":
await rankCommand(years, jsonMode);
break;
default:
console.error(`Unknown command: ${command}`);
process.exit(1);
}
}
main().catch((err) => {
console.error("Error:", err);
process.exit(1);
});