Add KBO CLI and hitter stats functionality

- KBO 팀 순위 및 타자 기록 조회를 위한 CLI 도구 추가
- 타자 통계 데이터 크롤링 및 파싱 기능 구현
- ASP.NET 세션 쿠키 및 가변 ScriptManager 키 처리 로직 추가
- 프로젝트 설정을 위한 IDE 및 도구 구성 파일 추가
This commit is contained in:
BaekRyang 2026-04-02 10:45:07 +09:00
parent a3a13e408a
commit 73239b5519
5 changed files with 547 additions and 9 deletions

View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(curl -s \"https://www.koreabaseball.com/Record/Player/HitterBasic/Basic1.aspx\" -H \"User-Agent: Mozilla/5.0\")"
]
}
}

6
.idea/AICommit.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="com.github.blarc.ai.commits.intellij.plugin.settings.ProjectSettings">
<option name="splitButtonActionSelectedLLMClientId" value="2f900cba-1f90-431b-acb2-e4e6ac66b31e" />
</component>
</project>

View File

@ -7,12 +7,38 @@ export interface PageState {
viewState: string; viewState: string;
viewStateGenerator: string; viewStateGenerator: string;
eventValidation: string; eventValidation: string;
/**
* ASP.NET .
*
* KBO (: HitterBasic) ViewState/EventValidation을
* , Set-Cookie로
* postback .
* ViewState .
*
* TeamRank.aspx ,
* .
*/
cookies: string;
} }
export interface PostbackRequest { export interface PostbackRequest {
url: string; url: string;
eventTarget: string; eventTarget: string;
scriptManager: string; scriptManager: string;
/**
* ASP.NET ScriptManager form field .
*
* KBO ASP.NET ScriptManager ID가 .
* - TeamRank.aspx: PREFIX + "ScriptManager" ( )
* - HitterBasic/Basic1.aspx: PREFIX + "smData" ( )
*
* ScriptManager는 ASP.NET AJAX의 partial postback(UpdatePanel)
* , form data에 ID를 .
* ID를 , .
*
* PREFIX + "ScriptManager" .
*/
scriptManagerKey?: string;
formFields: Record<string, string>; formFields: Record<string, string>;
state: PageState; state: PageState;
} }
@ -33,12 +59,16 @@ export async function getInitialPageState(
}); });
const html = await res.text(); const html = await res.text();
const setCookies = res.headers.getSetCookie();
const cookies = setCookies.map((c) => c.split(";")[0]).join("; ");
return { return {
html, html,
state: { state: {
viewState: extractHiddenField(html, "__VIEWSTATE"), viewState: extractHiddenField(html, "__VIEWSTATE"),
viewStateGenerator: extractHiddenField(html, "__VIEWSTATEGENERATOR"), viewStateGenerator: extractHiddenField(html, "__VIEWSTATEGENERATOR"),
eventValidation: extractHiddenField(html, "__EVENTVALIDATION"), eventValidation: extractHiddenField(html, "__EVENTVALIDATION"),
cookies,
}, },
}; };
} }
@ -48,7 +78,10 @@ export async function postback(
): Promise<PostbackResponse> { ): Promise<PostbackResponse> {
const formData = new URLSearchParams(); const formData = new URLSearchParams();
formData.set(`${PREFIX}ScriptManager`, request.scriptManager); formData.set(
request.scriptManagerKey ?? `${PREFIX}ScriptManager`,
request.scriptManager
);
for (const [key, value] of Object.entries(request.formFields)) { for (const [key, value] of Object.entries(request.formFields)) {
formData.set(key, value); formData.set(key, value);
@ -62,16 +95,22 @@ export async function postback(
formData.set("__EVENTVALIDATION", request.state.eventValidation); formData.set("__EVENTVALIDATION", request.state.eventValidation);
formData.set("__ASYNCPOST", "true"); formData.set("__ASYNCPOST", "true");
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-MicrosoftAjax": "Delta=true",
"X-Requested-With": "XMLHttpRequest",
"User-Agent": USER_AGENT,
Referer: request.url,
Origin: new URL(request.url).origin,
};
if (request.state.cookies) {
headers["Cookie"] = request.state.cookies;
}
const res = await fetch(request.url, { const res = await fetch(request.url, {
method: "POST", method: "POST",
headers: { headers,
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-MicrosoftAjax": "Delta=true",
"X-Requested-With": "XMLHttpRequest",
"User-Agent": USER_AGENT,
Referer: request.url,
Origin: new URL(request.url).origin,
},
body: formData.toString(), body: formData.toString(),
}); });
@ -90,6 +129,7 @@ export async function postback(
viewState: vsMatch?.[1] ?? request.state.viewState, viewState: vsMatch?.[1] ?? request.state.viewState,
viewStateGenerator: vsgMatch?.[1] ?? request.state.viewStateGenerator, viewStateGenerator: vsgMatch?.[1] ?? request.state.viewStateGenerator,
eventValidation: evMatch?.[1] ?? request.state.eventValidation, eventValidation: evMatch?.[1] ?? request.state.eventValidation,
cookies: request.state.cookies,
}; };
return { html, newState }; return { html, newState };

252
src/kbo/cli.ts Normal file
View File

@ -0,0 +1,252 @@
/**
* KBO CLI
* Usage: npx tsx src/kbo/cli.ts <command> [options]
*
* Commands:
* rank [year...] (기본: 현재 )
* hitter [year] [--team=]
*
* Options:
* --json JSON
* --all (hitter)
*/
import { padCell } from "./html-utils.js";
import {
fetchTeamRankInitial,
fetchTeamRank,
type TeamRank,
type TeamVsRecord,
type TeamRankResult,
type WinLossDraw,
} from "./team-rank.js";
import {
fetchHitterStatsInitial,
fetchHitterStats,
fetchAllHitterStats,
TEAM_CODES,
type HitterStatsResult,
} from "./hitter-stats.js";
// ── 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)
);
}
}
function printHitterTable(result: HitterStatsResult) {
const label = result.team
? `KBO ${result.year} Hitter Stats (${result.team})`
: `KBO ${result.year} Hitter Stats`;
console.log(`\n${"═".repeat(100)}`);
console.log(` ${label}`);
console.log(`${"═".repeat(100)}`);
if (result.hitters.length === 0) {
console.log(" No data found.");
return;
}
const header = ["#", "Player", "Team", "AVG", "G", "PA", "AB", "R", "H", "2B", "3B", "HR", "TB", "RBI", "SAC", "SF"];
const widths = [4, 10, 6, 6, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4];
console.log(" " + header.map((h, i) => padCell(h, widths[i])).join(" | "));
console.log(" " + widths.map((w) => "-".repeat(w)).join("-+-"));
for (const h of result.hitters) {
const row = [
h.rank, h.player, h.team, h.avg, h.games, h.pa, h.ab,
h.runs, h.hits, h.doubles, h.triples, h.hr, h.tb, h.rbi, h.sac, h.sf,
];
console.log(" " + row.map((val, i) => padCell(val, widths[i])).join(" | "));
}
console.log(`\n Total: ${result.hitters.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 hitterCommand(years: number[], jsonMode: boolean, teamName: string, allPages: boolean) {
console.log("Fetching KBO hitter stats...\n");
const teamCode = teamName ? (TEAM_CODES[teamName] ?? teamName) : "";
const { state: initState } = await fetchHitterStatsInitial();
let state = initState;
const results: HitterStatsResult[] = [];
for (const year of years) {
process.stdout.write(` ${year}${teamName ? ` (${teamName})` : ""}...`);
const fetchFn = allPages ? fetchAllHitterStats : fetchHitterStats;
const { result, newState } = await fetchFn(year, state, teamCode);
state = newState;
results.push(result);
console.log(` ${result.hitters.length} players`);
}
if (jsonMode) {
console.log(JSON.stringify(results, null, 2));
} else {
for (const result of results) {
printHitterTable(result);
}
}
}
// ── Main ──
async function main() {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === "help" || command === "--help") {
console.log("Usage: npx tsx src/kbo/cli.ts <command> [options]");
console.log("");
console.log("Commands:");
console.log(" rank [year...] Team rankings (default: current year)");
console.log(" hitter [year] [--team=팀명] Hitter stats (default: current year)");
console.log("");
console.log("Options:");
console.log(" --json JSON output");
console.log(" --all Fetch all pages (hitter)");
console.log(" --team=팀명 Filter by team (e.g. --team=LG, --team=삼성)");
return;
}
const rest = args.slice(1);
let jsonMode = false;
let allPages = false;
let teamName = "";
const years: number[] = [];
for (const arg of rest) {
if (arg === "--json") {
jsonMode = true;
} else if (arg === "--all") {
allPages = true;
} else if (arg.startsWith("--team=")) {
teamName = arg.slice(7);
} 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;
case "hitter":
await hitterCommand(years, jsonMode, teamName, allPages);
break;
default:
console.error(`Unknown command: ${command}`);
process.exit(1);
}
}
main().catch((err) => {
console.error("Error:", err);
process.exit(1);
});

233
src/kbo/hitter-stats.ts Normal file
View File

@ -0,0 +1,233 @@
import { stripTags, decodeHtmlEntities } from "./html-utils.js";
import {
PREFIX,
postback,
getInitialPageState,
type PageState,
type PostbackResponse,
} from "./aspnet-client.js";
const PAGE_URL =
"https://www.koreabaseball.com/Record/Player/HitterBasic/Basic1.aspx";
const SM_KEY = `${PREFIX}smData`;
const UPDATE_PANEL = `${PREFIX}udpContent`;
// ── Types ──
export interface HitterStats {
rank: string;
player: string;
team: string;
avg: string;
games: string;
pa: string;
ab: string;
runs: string;
hits: string;
doubles: string;
triples: string;
hr: string;
tb: string;
rbi: string;
sac: string;
sf: string;
}
export interface HitterStatsResult {
year: number;
team: string;
hitters: HitterStats[];
}
/**
* KBO .
*
* KBO value로 .
* : SSG는 SK "SK", "WO" .
*/
export const TEAM_CODES: Record<string, string> = {
KT: "KT",
NC: "NC",
SSG: "SK",
: "LT",
: "HH",
: "OB",
: "SS",
: "WO",
KIA: "HT",
LG: "LG",
};
// ── Parsing ──
export function parseHitterTable(html: string): HitterStats[] {
const hitters: HitterStats[] = [];
const trRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let trMatch: RegExpExecArray | null;
while ((trMatch = trRegex.exec(html)) !== null) {
const trContent = trMatch[1];
const tds: string[] = [];
const tdRegex = /<td[^>]*>([\s\S]*?)<\/td>/gi;
let tdMatch: RegExpExecArray | null;
while ((tdMatch = tdRegex.exec(trContent)) !== null) {
tds.push(stripTags(decodeHtmlEntities(tdMatch[1])));
}
// 타자 기록 행: 16컬럼 (순위, 선수명, 팀, AVG, G, PA, AB, R, H, 2B, 3B, HR, TB, RBI, SAC, SF)
if (tds.length >= 16) {
const rank = tds[0];
if (/^\d+$/.test(rank)) {
hitters.push({
rank,
player: tds[1],
team: tds[2],
avg: tds[3],
games: tds[4],
pa: tds[5],
ab: tds[6],
runs: tds[7],
hits: tds[8],
doubles: tds[9],
triples: tds[10],
hr: tds[11],
tb: tds[12],
rbi: tds[13],
sac: tds[14],
sf: tds[15],
});
}
}
}
return hitters;
}
// ── Form fields ──
function buildFormFields(
year: number,
teamCode: string
): Record<string, string> {
return {
[`${PREFIX}ddlSeason$ddlSeason`]: String(year),
[`${PREFIX}ddlSeries$ddlSeries`]: "0",
[`${PREFIX}ddlTeam$ddlTeam`]: teamCode,
[`${PREFIX}ddlPos$ddlPos`]: "",
[`${PREFIX}ddlSituation$ddlSituation`]: "",
[`${PREFIX}ddlSituationDetail$ddlSituationDetail`]: "",
[`${PREFIX}hfOrderByCol`]: "HRA_RT",
[`${PREFIX}hfOrderBy`]: "DESC",
[`${PREFIX}hfPage`]: "1",
};
}
// ── Fetch ──
export async function fetchHitterStatsInitial(): Promise<{
result: HitterStatsResult;
state: PageState;
}> {
const { html, state } = await getInitialPageState(PAGE_URL);
const yearMatch = html.match(
/ddlSeason_ddlSeason[\s\S]*?selected="selected"\s+value="(\d{4})"/
);
const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
return {
result: { year, team: "", hitters: parseHitterTable(html) },
state,
};
}
export async function fetchHitterStats(
year: number,
state: PageState,
teamCode = ""
): Promise<{ result: HitterStatsResult; newState: PageState }> {
const eventTarget = `${PREFIX}ddlSeason$ddlSeason`;
const { html, newState }: PostbackResponse = await postback({
url: PAGE_URL,
eventTarget,
scriptManagerKey: SM_KEY,
scriptManager: `${UPDATE_PANEL}|${eventTarget}`,
formFields: buildFormFields(year, teamCode),
state,
});
// 팀 필터가 있으면 추가 postback
if (teamCode) {
const teamTarget = `${PREFIX}ddlTeam$ddlTeam`;
const { html: teamHtml, newState: teamState } = await postback({
url: PAGE_URL,
eventTarget: teamTarget,
scriptManagerKey: SM_KEY,
scriptManager: `${UPDATE_PANEL}|${teamTarget}`,
formFields: buildFormFields(year, teamCode),
state: newState,
});
return {
result: { year, team: teamCode, hitters: parseHitterTable(teamHtml) },
newState: teamState,
};
}
return {
result: { year, team: "", hitters: parseHitterTable(html) },
newState,
};
}
/**
* .
* , postback을 .
*/
export async function fetchAllHitterStats(
year: number,
state: PageState,
teamCode = ""
): Promise<{ result: HitterStatsResult; newState: PageState }> {
// 첫 페이지 가져오기
const { result: firstResult, newState: firstState } = await fetchHitterStats(
year,
state,
teamCode
);
const allHitters = [...firstResult.hitters];
let currentState = firstState;
let page = 2;
// 다음 페이지가 있는 동안 반복
while (true) {
const pagerTarget = `${PREFIX}ucPager$btnNo${page}`;
const fields = buildFormFields(year, teamCode);
fields[`${PREFIX}hfPage`] = String(page);
const { html, newState }: PostbackResponse = await postback({
url: PAGE_URL,
eventTarget: pagerTarget,
scriptManagerKey: SM_KEY,
scriptManager: `${UPDATE_PANEL}|${pagerTarget}`,
formFields: fields,
state: currentState,
});
const pageHitters = parseHitterTable(html);
if (pageHitters.length === 0) break;
allHitters.push(...pageHitters);
currentState = newState;
page++;
}
return {
result: { year, team: teamCode, hitters: allHitters },
newState: currentState,
};
}