- KBO 선수 기록 조회 로직을 공통화하고 투수, 수비, 주루 카테고리 추가 - CLI 명령어를 hitter에서 player <type>으로 통합 및 필터 옵션 강화 - ASP.NET AJAX 응답 파싱 방식을 개선하여 데이터 누락 문제 해결 - 정규시즌과 포스트시즌의 서로 다른 페이지 구조 및 컬럼 대응 지원
353 lines
9.6 KiB
TypeScript
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);
|
|
});
|