diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..15f94c1 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(curl -s \"https://www.koreabaseball.com/Record/Player/HitterBasic/Basic1.aspx\" -H \"User-Agent: Mozilla/5.0\")" + ] + } +} diff --git a/.idea/AICommit.xml b/.idea/AICommit.xml new file mode 100644 index 0000000..7c2bd18 --- /dev/null +++ b/.idea/AICommit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/src/kbo/aspnet-client.ts b/src/kbo/aspnet-client.ts index 1ebfe57..3413f8a 100644 --- a/src/kbo/aspnet-client.ts +++ b/src/kbo/aspnet-client.ts @@ -7,12 +7,38 @@ export interface PageState { viewState: string; viewStateGenerator: string; eventValidation: string; + /** + * ASP.NET 세션 쿠키. + * + * 일부 KBO 페이지(예: HitterBasic)는 서버가 ViewState/EventValidation을 + * 세션과 연결하여 검증하므로, 초기 응답의 Set-Cookie로 받은 세션 쿠키를 + * 후속 postback 요청에 포함해야 한다. 쿠키 없이 요청하면 서버가 + * ViewState 검증에 실패하여 에러 페이지로 리다이렉트된다. + * + * TeamRank.aspx 같은 일부 페이지는 쿠키 없이도 동작하지만, + * 일관성을 위해 모든 요청에 쿠키를 포함한다. + */ + cookies: string; } export interface PostbackRequest { url: string; eventTarget: 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; state: PageState; } @@ -33,12 +59,16 @@ export async function getInitialPageState( }); const html = await res.text(); + const setCookies = res.headers.getSetCookie(); + const cookies = setCookies.map((c) => c.split(";")[0]).join("; "); + return { html, state: { viewState: extractHiddenField(html, "__VIEWSTATE"), viewStateGenerator: extractHiddenField(html, "__VIEWSTATEGENERATOR"), eventValidation: extractHiddenField(html, "__EVENTVALIDATION"), + cookies, }, }; } @@ -48,7 +78,10 @@ export async function postback( ): Promise { 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)) { formData.set(key, value); @@ -62,16 +95,22 @@ export async function postback( formData.set("__EVENTVALIDATION", request.state.eventValidation); formData.set("__ASYNCPOST", "true"); + const headers: Record = { + "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, { method: "POST", - 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, - }, + headers, body: formData.toString(), }); @@ -90,6 +129,7 @@ export async function postback( viewState: vsMatch?.[1] ?? request.state.viewState, viewStateGenerator: vsgMatch?.[1] ?? request.state.viewStateGenerator, eventValidation: evMatch?.[1] ?? request.state.eventValidation, + cookies: request.state.cookies, }; return { html, newState }; diff --git a/src/kbo/cli.ts b/src/kbo/cli.ts new file mode 100644 index 0000000..c9c1d0b --- /dev/null +++ b/src/kbo/cli.ts @@ -0,0 +1,252 @@ +/** + * KBO CLI + * Usage: npx tsx src/kbo/cli.ts [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 [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); +}); diff --git a/src/kbo/hitter-stats.ts b/src/kbo/hitter-stats.ts new file mode 100644 index 0000000..c3a7ccd --- /dev/null +++ b/src/kbo/hitter-stats.ts @@ -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 = { + 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 = /]*>([\s\S]*?)<\/tr>/gi; + let trMatch: RegExpExecArray | null; + + while ((trMatch = trRegex.exec(html)) !== null) { + const trContent = trMatch[1]; + const tds: string[] = []; + const tdRegex = /]*>([\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 { + 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, + }; +} \ No newline at end of file