diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 15f94c1..4e3bb82 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,12 @@ { "permissions": { "allow": [ - "Bash(curl -s \"https://www.koreabaseball.com/Record/Player/HitterBasic/Basic1.aspx\" -H \"User-Agent: Mozilla/5.0\")" + "Bash(curl -s \"https://www.koreabaseball.com/Record/Player/HitterBasic/Basic1.aspx\" -H \"User-Agent: Mozilla/5.0\")", + "Bash(curl -s \"https://www.koreabaseball.com/Record/Player/PitcherBasic/Basic1.aspx\" -H \"User-Agent: Mozilla/5.0\")", + "Bash(sed 's/<[^>]*>//g')", + "Bash(curl -s \"https://www.koreabaseball.com/Record/Player/Defense/Basic.aspx\" -H \"User-Agent: Mozilla/5.0\")", + "Bash(curl -s \"https://www.koreabaseball.com/Record/Player/Runner/Basic.aspx\" -H \"User-Agent: Mozilla/5.0\")", + "Bash(npx tsx:*)" ] } } diff --git a/src/kbo/aspnet-client.ts b/src/kbo/aspnet-client.ts index 3413f8a..bc7267d 100644 --- a/src/kbo/aspnet-client.ts +++ b/src/kbo/aspnet-client.ts @@ -48,6 +48,52 @@ export interface PostbackResponse { newState: PageState; } +/** + * ASP.NET AJAX delta 응답 파서. + * + * delta 응답은 `length|type|id|content|` 레코드의 연속이다. + * 기존 regex 방식(`[\s\S]*?`)은 content 내부에 `|숫자|` 패턴이 있으면 + * 잘못된 위치에서 매칭이 끊기는 문제가 있었다. + * length 필드를 이용해 정확한 바이트 수만큼 content를 읽어 이 문제를 해결한다. + */ +function parseDeltaResponse(text: string): { + panels: { id: string; content: string }[]; + fields: Record; +} { + const panels: { id: string; content: string }[] = []; + const fields: Record = {}; + let pos = 0; + + while (pos < text.length) { + const p1 = text.indexOf("|", pos); + if (p1 === -1) break; + const len = parseInt(text.substring(pos, p1), 10); + if (isNaN(len)) break; + pos = p1 + 1; + + const p2 = text.indexOf("|", pos); + if (p2 === -1) break; + const type = text.substring(pos, p2); + pos = p2 + 1; + + const p3 = text.indexOf("|", pos); + if (p3 === -1) break; + const id = text.substring(pos, p3); + pos = p3 + 1; + + const content = text.substring(pos, pos + len); + pos = pos + len + 1; + + if (type === "updatePanel") { + panels.push({ id, content }); + } else if (type === "hiddenField") { + fields[id] = content; + } + } + + return { panels, fields }; +} + const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36"; @@ -115,20 +161,16 @@ export async function postback( }); const text = await res.text(); + const { panels, fields } = parseDeltaResponse(text); - const panelMatch = text.match( - /updatePanel\|[^|]*\|([\s\S]*?)\|[0-9]+\|hiddenField\|/ - ); - const html = panelMatch?.[1] ?? text; - - const vsMatch = text.match(/__VIEWSTATE\|([^|]*)\|/); - const vsgMatch = text.match(/__VIEWSTATEGENERATOR\|([^|]*)\|/); - const evMatch = text.match(/__EVENTVALIDATION\|([^|]*)\|/); + const html = panels[0]?.content ?? text; const newState: PageState = { - viewState: vsMatch?.[1] ?? request.state.viewState, - viewStateGenerator: vsgMatch?.[1] ?? request.state.viewStateGenerator, - eventValidation: evMatch?.[1] ?? request.state.eventValidation, + viewState: fields["__VIEWSTATE"] ?? request.state.viewState, + viewStateGenerator: + fields["__VIEWSTATEGENERATOR"] ?? request.state.viewStateGenerator, + eventValidation: + fields["__EVENTVALIDATION"] ?? request.state.eventValidation, cookies: request.state.cookies, }; diff --git a/src/kbo/cli.ts b/src/kbo/cli.ts index c9c1d0b..bb172eb 100644 --- a/src/kbo/cli.ts +++ b/src/kbo/cli.ts @@ -3,12 +3,13 @@ * Usage: npx tsx src/kbo/cli.ts [options] * * Commands: - * rank [year...] 팀 순위 조회 (기본: 현재 연도) - * hitter [year] [--team=팀명] 타자 기록 조회 + * rank [year...] 팀 순위 + * player 선수 기록 * * Options: * --json JSON 출력 - * --all 모든 페이지 조회 (hitter) + * --all 모든 페이지 조회 (player) + * --team=팀명 팀 필터 (e.g. --team=LG) */ import { padCell } from "./html-utils.js"; @@ -21,14 +22,27 @@ import { type WinLossDraw, } from "./team-rank.js"; import { - fetchHitterStatsInitial, - fetchHitterStats, - fetchAllHitterStats, + fetchPlayerStatsInitial, + fetchPlayerStats, + fetchAllPlayerStats, TEAM_CODES, - type HitterStatsResult, -} from "./hitter-stats.js"; + 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"; -// ── Formatting ── +const PLAYER_CONFIGS: Record = { + 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]}`; @@ -94,35 +108,48 @@ function printVsTable(year: number, vsRecords: TeamVsRecord[]) { } } -function printHitterTable(result: HitterStatsResult) { - const label = result.team - ? `KBO ${result.year} Hitter Stats (${result.team})` - : `KBO ${result.year} Hitter Stats`; +// ── 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.hitters.length === 0) { + if (result.records.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]; + const widths = columns.map((col) => { + if (col === "player") return 10; + if (col === "team") return 6; + return Math.max(col.length, 4); + }); - console.log(" " + header.map((h, i) => padCell(h, widths[i])).join(" | ")); + console.log( + " " + columns.map((c, i) => padCell(c, 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(" | ")); + 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.hitters.length} players`); + console.log(`\n Total: ${result.records.length} players`); } // ── Commands ── @@ -158,88 +185,161 @@ async function rankCommand(years: number[], jsonMode: boolean) { } } -async function hitterCommand(years: number[], jsonMode: boolean, teamName: string, allPages: boolean) { - console.log("Fetching KBO hitter stats...\n"); +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(", "); - const teamCode = teamName ? (TEAM_CODES[teamName] ?? teamName) : ""; - const { state: initState } = await fetchHitterStatsInitial(); + 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: HitterStatsResult[] = []; + const results: PlayerStatsResult[] = []; for (const year of years) { - process.stdout.write(` ${year}${teamName ? ` (${teamName})` : ""}...`); + process.stdout.write(` ${year}${filterDesc ? ` (${filterDesc})` : ""}...`); - const fetchFn = allPages ? fetchAllHitterStats : fetchHitterStats; - const { result, newState } = await fetchFn(year, state, teamCode); + const fetchFn = allPages ? fetchAllPlayerStats : fetchPlayerStats; + const { result, newState } = await fetchFn(config, year, state, resolved); state = newState; results.push(result); - console.log(` ${result.hitters.length} players`); + console.log(` ${result.records.length} players`); } if (jsonMode) { console.log(JSON.stringify(results, null, 2)); } else { for (const result of results) { - printHitterTable(result); + printPlayerTable(subcommand, result.columns, result); } } } // ── Main ── +function printHelp() { + console.log("Usage: npx tsx src/kbo/cli.ts [options]"); + console.log(""); + console.log("Commands:"); + console.log(" rank [year...] Team rankings"); + console.log(" player 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") { - 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=삼성)"); + 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; - 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 { + if (arg === "--json") jsonMode = true; + else { const y = parseInt(arg, 10); - if (y >= 1982 && y <= 2100) { - years.push(y); - } else { + 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()); - } + 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); diff --git a/src/kbo/hitter-stats.ts b/src/kbo/hitter-stats.ts deleted file mode 100644 index c3a7ccd..0000000 --- a/src/kbo/hitter-stats.ts +++ /dev/null @@ -1,233 +0,0 @@ -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 diff --git a/src/kbo/player/common.ts b/src/kbo/player/common.ts new file mode 100644 index 0000000..65f9221 --- /dev/null +++ b/src/kbo/player/common.ts @@ -0,0 +1,417 @@ +import { stripTags, decodeHtmlEntities } from "../html-utils.js"; +import { + PREFIX, + postback, + getInitialPageState, + type PageState, + type PostbackResponse, +} from "../aspnet-client.js"; + +const SM_KEY = `${PREFIX}smData`; +const UPDATE_PANEL = `${PREFIX}udpContent`; + +// ── Types ── + +export interface PlayerPageConfig { + url: string; + /** + * 포스트시즌(시리즈 != 0) 데이터용 URL. + * + * KBO 사이트는 정규시즌과 포스트시즌에 서로 다른 ASP.NET 페이지를 사용한다. + * 정규시즌은 Basic1.aspx, 포스트시즌은 BasicOld.aspx를 사용하며 + * 테이블 컬럼 구성도 다르다. series를 변경하면 서버가 formAction을 + * BasicOld.aspx로 바꾸지만, 실제 데이터는 해당 URL에 새로 요청해야 한다. + * + * 미지정 시 url과 동일한 페이지를 사용한다 (Defense, Runner 등). + */ + postseasonUrl?: string; + postseasonColumns?: readonly string[]; + defaultSortCol: string; + dropdowns: string[]; + columns: readonly string[]; +} + +/** + * 선수 기록 한 행을 나타내는 제네릭 타입. + * + * 컬럼 구성이 페이지(hitter/pitcher/defense/runner)와 + * 시리즈(정규시즌 vs 포스트시즌)에 따라 달라지므로 동적 키를 사용한다. + * 구체적인 컬럼 타입이 필요하면 각 페이지 모듈의 타입으로 캐스팅한다. + * + * @example + * import type { HitterStats } from "./hitter.js"; + * const hitter = record as HitterStats; + * console.log(hitter.avg, hitter.hr); + */ +export type PlayerRecord = Record; + +/** + * 선수 기록 조회 필터. + * + * @property team - 팀 코드 (TEAM_CODES의 값 또는 팀명) + * @property series - 시리즈 ("0"=정규시즌, "1"=시범, "4"=와카, "3"=준PO, "5"=PO, "7"=한국시리즈) + * @property pos - 포지션 ("2"=포수, "3,4,5,6"=내야수, "7,8,9"=외야수) + * @property situation - 경기상황별 ("MONTH_SC", "WEEK_SC", "STADIUM_SC", "HOMEAYAY_SC" 등) + * @property situationDetail - 경기상황 세부 (situation에 따라 동적으로 결정) + */ +export interface PlayerFilters { + team?: string; + series?: string; + pos?: string; + situation?: string; + situationDetail?: string; +} + +export interface PlayerStatsResult { + year: number; + filters: PlayerFilters; + columns: readonly string[]; + records: PlayerRecord[]; +} + +/** + * 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 ── + +/** + * HTML 테이블에서 선수 기록을 파싱한다. + * + * @param html - ``, `` 태그가 포함된 HTML 문자열 + * @param columns - 각 ``에 매핑할 컬럼 이름 배열 (순서대로 매핑) + * @returns 각 행을 `{ [컬럼명]: 값 }` 형태로 변환한 배열. + * 첫 번째 ``가 숫자(순위)인 행만 포함한다. + */ +export function parsePlayerTable( + html: string, + columns: readonly string[] +): PlayerRecord[] { + const records: PlayerRecord[] = []; + + 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]))); + } + + if (tds.length >= columns.length) { + const rank = tds[0]; + if (/^\d+$/.test(rank)) { + const record: PlayerRecord = {}; + for (let i = 0; i < columns.length; i++) { + record[columns[i]] = tds[i]; + } + records.push(record); + } + } + } + + return records; +} + +// ── Filter → dropdown 매핑 ── + +const FILTER_TO_DDL: Record = { + team: "ddlTeam", + series: "ddlSeries", + pos: "ddlPos", + situation: "ddlSituation", + situationDetail: "ddlSituationDetail", +}; + +const DDL_DEFAULTS: Record = { + ddlSeries: "0", +}; + +// ── Form fields ── + +/** + * ASP.NET postback에 필요한 form 필드를 구성한다. + * + * config.dropdowns에 정의된 각 dropdown에 대해 form 키(`PREFIX$ddl$ddl`)를 생성하고, + * filters에 해당 값이 있으면 적용, 없으면 기본값(DDL_DEFAULTS 또는 빈 문자열)을 사용한다. + * ddlSeason은 항상 year 파라미터 값으로 설정된다. + * + * @param config - 페이지 설정 (dropdowns, defaultSortCol 등) + * @param year - 조회 연도 + * @param filters - 사용자 지정 필터 (team, series, pos 등) + * @returns `URLSearchParams`에 설정할 키-값 쌍 객체 + */ +function buildFormFields( + config: PlayerPageConfig, + year: number, + filters: PlayerFilters +): Record { + const fields: Record = { + [`${PREFIX}hfOrderByCol`]: config.defaultSortCol, + [`${PREFIX}hfOrderBy`]: "DESC", + [`${PREFIX}hfPage`]: "1", + }; + + // 필터 키 → dropdown 이름으로 변환한 lookup + const filterByDdl: Record = {}; + for (const [filterKey, value] of Object.entries(filters)) { + const ddl = FILTER_TO_DDL[filterKey]; + if (ddl) filterByDdl[ddl] = value; + } + + for (const ddl of config.dropdowns) { + const key = `${PREFIX}${ddl}$${ddl}`; + if (ddl === "ddlSeason") { + fields[key] = String(year); + } else if (ddl in filterByDdl) { + fields[key] = filterByDdl[ddl]; + } else { + fields[key] = DDL_DEFAULTS[ddl] ?? ""; + } + } + + return fields; +} + +// ── Fetch ── + +/** + * 선수 기록 페이지의 초기 상태를 가져온다. + * + * 페이지를 GET으로 로드하여 현재 선택된 연도의 데이터를 파싱하고, + * 후속 postback에 필요한 ASP.NET 상태(ViewState, 쿠키 등)를 반환한다. + * + * @param config - 페이지 설정 (url, columns 등) + * @returns 초기 데이터(result)와 후속 요청용 상태(state) + */ +export async function fetchPlayerStatsInitial( + config: PlayerPageConfig +): Promise<{ result: PlayerStatsResult; state: PageState }> { + const { html, state } = await getInitialPageState(config.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, + filters: {}, + columns: config.columns, + records: parsePlayerTable(html, config.columns), + }, + state, + }; +} + +/** + * 지정된 연도와 필터 조건으로 선수 기록을 조회한다. + * + * 내부적으로 ASP.NET postback을 순차 실행하여 필터를 적용한다. + * 포스트시즌(series != "0")이면 별도 페이지(BasicOld.aspx)에 요청하고, + * 정규시즌이면 기본 페이지(Basic1.aspx)에 요청한다. + * + * ASP.NET은 dropdown 변경 시 서버에서 EventValidation을 갱신하므로, + * 여러 필터를 동시에 적용하려면 한 번에 하나씩 순서대로 postback해야 한다. + * + * @param config - 페이지 설정 + * @param year - 조회 연도 + * @param state - 이전 요청에서 받은 ASP.NET 상태 + * @param filters - 조회 필터 (team, series, pos, situation 등) + * @returns 조회 결과(result)와 갱신된 상태(newState) + */ +export async function fetchPlayerStats( + config: PlayerPageConfig, + year: number, + state: PageState, + filters: PlayerFilters = {} +): Promise<{ result: PlayerStatsResult; newState: PageState }> { + const isPostseason = + filters.series !== undefined && filters.series !== "0"; + const useUrl = + isPostseason && config.postseasonUrl ? config.postseasonUrl : config.url; + const useColumns = + isPostseason && config.postseasonColumns + ? config.postseasonColumns + : config.columns; + + // 포스트시즌은 별도 페이지이므로 해당 페이지의 초기 상태에서 시작 + if (isPostseason && config.postseasonUrl) { + const { state: psState } = await getInitialPageState(config.postseasonUrl); + let currentState = psState; + let html = ""; + + // 연도 postback + const seasonTarget = `${PREFIX}ddlSeason$ddlSeason`; + const r1 = await postback({ + url: config.postseasonUrl, + eventTarget: seasonTarget, + scriptManagerKey: SM_KEY, + scriptManager: `${UPDATE_PANEL}|${seasonTarget}`, + formFields: buildFormFields(config, year, filters), + state: currentState, + }); + html = r1.html; + currentState = r1.newState; + + // series postback + const seriesTarget = `${PREFIX}ddlSeries$ddlSeries`; + const r2 = await postback({ + url: config.postseasonUrl, + eventTarget: seriesTarget, + scriptManagerKey: SM_KEY, + scriptManager: `${UPDATE_PANEL}|${seriesTarget}`, + formFields: buildFormFields(config, year, filters), + state: currentState, + }); + html = r2.html; + currentState = r2.newState; + + // 추가 필터 (team, pos 등) + const extraFilters: (keyof PlayerFilters)[] = ["team", "pos"]; + for (const filterKey of extraFilters) { + const value = filters[filterKey]; + if (!value) continue; + const ddl = FILTER_TO_DDL[filterKey]; + if (!ddl || !config.dropdowns.includes(ddl)) continue; + const target = `${PREFIX}${ddl}$${ddl}`; + const resp = await postback({ + url: config.postseasonUrl, + eventTarget: target, + scriptManagerKey: SM_KEY, + scriptManager: `${UPDATE_PANEL}|${target}`, + formFields: buildFormFields(config, year, filters), + state: currentState, + }); + html = resp.html; + currentState = resp.newState; + } + + return { + result: { + year, + filters, + columns: useColumns, + records: parsePlayerTable(html, useColumns), + }, + newState: currentState, + }; + } + + // 정규시즌 flow + const seasonTarget = `${PREFIX}ddlSeason$ddlSeason`; + let { html, newState } = await postback({ + url: useUrl, + eventTarget: seasonTarget, + scriptManagerKey: SM_KEY, + scriptManager: `${UPDATE_PANEL}|${seasonTarget}`, + formFields: buildFormFields(config, year, filters), + state, + }); + + const filterOrder: (keyof PlayerFilters)[] = ["team", "pos", "situation", "situationDetail"]; + for (const filterKey of filterOrder) { + const value = filters[filterKey]; + if (!value) continue; + + const ddl = FILTER_TO_DDL[filterKey]; + if (!ddl || !config.dropdowns.includes(ddl)) continue; + + const target = `${PREFIX}${ddl}$${ddl}`; + const resp = await postback({ + url: useUrl, + eventTarget: target, + scriptManagerKey: SM_KEY, + scriptManager: `${UPDATE_PANEL}|${target}`, + formFields: buildFormFields(config, year, filters), + state: newState, + }); + html = resp.html; + newState = resp.newState; + } + + return { + result: { + year, + filters, + columns: useColumns, + records: parsePlayerTable(html, useColumns), + }, + newState, + }; +} + +/** + * 모든 페이지의 선수 기록을 조회한다. + * + * {@link fetchPlayerStats}로 첫 페이지를 가져온 뒤, + * 페이지네이션 postback(ucPager$btnNo2, btnNo3, ...)을 반복하여 + * 빈 페이지가 나올 때까지 전체 데이터를 수집한다. + * + * @param config - 페이지 설정 + * @param year - 조회 연도 + * @param state - 이전 요청에서 받은 ASP.NET 상태 + * @param filters - 조회 필터 + * @returns 모든 페이지의 레코드를 합친 결과와 갱신된 상태 + */ +export async function fetchAllPlayerStats( + config: PlayerPageConfig, + year: number, + state: PageState, + filters: PlayerFilters = {} +): Promise<{ result: PlayerStatsResult; newState: PageState }> { + const { result: firstResult, newState: firstState } = + await fetchPlayerStats(config, year, state, filters); + + const allRecords = [...firstResult.records]; + let currentState = firstState; + let page = 2; + + while (true) { + const pagerTarget = `${PREFIX}ucPager$btnNo${page}`; + const fields = buildFormFields(config, year, filters); + fields[`${PREFIX}hfPage`] = String(page); + + const { html, newState }: PostbackResponse = await postback({ + url: config.url, + eventTarget: pagerTarget, + scriptManagerKey: SM_KEY, + scriptManager: `${UPDATE_PANEL}|${pagerTarget}`, + formFields: fields, + state: currentState, + }); + + const pageRecords = parsePlayerTable(html, config.columns); + if (pageRecords.length === 0) break; + + allRecords.push(...pageRecords); + currentState = newState; + page++; + } + + return { + result: { year, filters, columns: firstResult.columns, records: allRecords }, + newState: currentState, + }; +} diff --git a/src/kbo/player/defense.ts b/src/kbo/player/defense.ts new file mode 100644 index 0000000..8a48849 --- /dev/null +++ b/src/kbo/player/defense.ts @@ -0,0 +1,16 @@ +import type { PlayerPageConfig } from "./common.js"; + +export const DEFENSE_COLUMNS = [ + "rank", "player", "team", "pos", "games", "gs", "ip", + "errors", "pko", "po", "assists", "dp", "fpct", "pb", + "sb", "cs", "csPct", +] as const; + +export type DefenseStats = Record<(typeof DEFENSE_COLUMNS)[number], string>; + +export const DEFENSE_CONFIG: PlayerPageConfig = { + url: "https://www.koreabaseball.com/Record/Player/Defense/Basic.aspx", + defaultSortCol: "GAME_CN", + dropdowns: ["ddlSeason", "ddlSeries", "ddlTeam", "ddlPos"], + columns: DEFENSE_COLUMNS, +}; diff --git a/src/kbo/player/hitter.ts b/src/kbo/player/hitter.ts new file mode 100644 index 0000000..4eeeaaa --- /dev/null +++ b/src/kbo/player/hitter.ts @@ -0,0 +1,23 @@ +import type { PlayerPageConfig } from "./common.js"; + +export const HITTER_COLUMNS = [ + "rank", "player", "team", "avg", "games", "pa", "ab", + "runs", "hits", "doubles", "triples", "hr", "tb", "rbi", "sac", "sf", +] as const; + +export const HITTER_POSTSEASON_COLUMNS = [ + "rank", "player", "team", "avg", "games", "pa", "ab", + "hits", "doubles", "triples", "hr", "rbi", "sb", "cs", + "bb", "hbp", "so", "gdp", "errors", +] as const; + +export type HitterStats = Record<(typeof HITTER_COLUMNS)[number], string>; + +export const HITTER_CONFIG: PlayerPageConfig = { + url: "https://www.koreabaseball.com/Record/Player/HitterBasic/Basic1.aspx", + postseasonUrl: "https://www.koreabaseball.com/Record/Player/HitterBasic/BasicOld.aspx", + postseasonColumns: HITTER_POSTSEASON_COLUMNS, + defaultSortCol: "HRA_RT", + dropdowns: ["ddlSeason", "ddlSeries", "ddlTeam", "ddlPos", "ddlSituation", "ddlSituationDetail"], + columns: HITTER_COLUMNS, +}; diff --git a/src/kbo/player/pitcher.ts b/src/kbo/player/pitcher.ts new file mode 100644 index 0000000..f372d35 --- /dev/null +++ b/src/kbo/player/pitcher.ts @@ -0,0 +1,24 @@ +import type { PlayerPageConfig } from "./common.js"; + +export const PITCHER_COLUMNS = [ + "rank", "player", "team", "era", "games", "wins", "losses", + "saves", "holds", "wpct", "ip", "hits", "hr", "bb", "hbp", + "so", "runs", "er", "whip", +] as const; + +export const PITCHER_POSTSEASON_COLUMNS = [ + "rank", "player", "team", "era", "games", "cg", "sho", + "wins", "losses", "saves", "holds", "wpct", "tbf", "ip", + "hits", "hr", "bb", "hbp", "so", "runs", "er", +] as const; + +export type PitcherStats = Record<(typeof PITCHER_COLUMNS)[number], string>; + +export const PITCHER_CONFIG: PlayerPageConfig = { + url: "https://www.koreabaseball.com/Record/Player/PitcherBasic/Basic1.aspx", + postseasonUrl: "https://www.koreabaseball.com/Record/Player/PitcherBasic/BasicOld.aspx", + postseasonColumns: PITCHER_POSTSEASON_COLUMNS, + defaultSortCol: "ERA_RT", + dropdowns: ["ddlSeason", "ddlSeries", "ddlTeam", "ddlSituation", "ddlSituationDetail"], + columns: PITCHER_COLUMNS, +}; diff --git a/src/kbo/player/runner.ts b/src/kbo/player/runner.ts new file mode 100644 index 0000000..73aafd2 --- /dev/null +++ b/src/kbo/player/runner.ts @@ -0,0 +1,15 @@ +import type { PlayerPageConfig } from "./common.js"; + +export const RUNNER_COLUMNS = [ + "rank", "player", "team", "games", "sba", "sb", "cs", + "sbPct", "oob", "pko", +] as const; + +export type RunnerStats = Record<(typeof RUNNER_COLUMNS)[number], string>; + +export const RUNNER_CONFIG: PlayerPageConfig = { + url: "https://www.koreabaseball.com/Record/Player/Runner/Basic.aspx", + defaultSortCol: "SB_CN", + dropdowns: ["ddlSeason", "ddlSeries", "ddlTeam", "ddlPos"], + columns: RUNNER_COLUMNS, +};