Generalize KBO player stats and add pitcher/defense/runner support

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

View File

@ -1,7 +1,12 @@
{ {
"permissions": { "permissions": {
"allow": [ "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:*)"
] ]
} }
} }

View File

@ -48,6 +48,52 @@ export interface PostbackResponse {
newState: PageState; 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<string, string>;
} {
const panels: { id: string; content: string }[] = [];
const fields: Record<string, string> = {};
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 = const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36"; "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 text = await res.text();
const { panels, fields } = parseDeltaResponse(text);
const panelMatch = text.match( const html = panels[0]?.content ?? text;
/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 newState: PageState = { const newState: PageState = {
viewState: vsMatch?.[1] ?? request.state.viewState, viewState: fields["__VIEWSTATE"] ?? request.state.viewState,
viewStateGenerator: vsgMatch?.[1] ?? request.state.viewStateGenerator, viewStateGenerator:
eventValidation: evMatch?.[1] ?? request.state.eventValidation, fields["__VIEWSTATEGENERATOR"] ?? request.state.viewStateGenerator,
eventValidation:
fields["__EVENTVALIDATION"] ?? request.state.eventValidation,
cookies: request.state.cookies, cookies: request.state.cookies,
}; };

View File

@ -3,12 +3,13 @@
* Usage: npx tsx src/kbo/cli.ts <command> [options] * Usage: npx tsx src/kbo/cli.ts <command> [options]
* *
* Commands: * Commands:
* rank [year...] (기본: 현재 ) * rank [year...]
* hitter [year] [--team=] * player <hitter|pitcher|defense|runner>
* *
* Options: * Options:
* --json JSON * --json JSON
* --all (hitter) * --all (player)
* --team= (e.g. --team=LG)
*/ */
import { padCell } from "./html-utils.js"; import { padCell } from "./html-utils.js";
@ -21,14 +22,27 @@ import {
type WinLossDraw, type WinLossDraw,
} from "./team-rank.js"; } from "./team-rank.js";
import { import {
fetchHitterStatsInitial, fetchPlayerStatsInitial,
fetchHitterStats, fetchPlayerStats,
fetchAllHitterStats, fetchAllPlayerStats,
TEAM_CODES, TEAM_CODES,
type HitterStatsResult, type PlayerPageConfig,
} from "./hitter-stats.js"; 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<string, PlayerPageConfig> = {
hitter: HITTER_CONFIG,
pitcher: PITCHER_CONFIG,
defense: DEFENSE_CONFIG,
runner: RUNNER_CONFIG,
};
// ── Rank Formatting ──
function fmtWLD(wld: WinLossDraw): string { function fmtWLD(wld: WinLossDraw): string {
return `${wld[0]}-${wld[1]}-${wld[2]}`; return `${wld[0]}-${wld[1]}-${wld[2]}`;
@ -94,35 +108,48 @@ function printVsTable(year: number, vsRecords: TeamVsRecord[]) {
} }
} }
function printHitterTable(result: HitterStatsResult) { // ── Player Formatting ──
const label = result.team
? `KBO ${result.year} Hitter Stats (${result.team})` function printPlayerTable(
: `KBO ${result.year} Hitter Stats`; 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(`\n${"═".repeat(100)}`);
console.log(` ${label}`); console.log(` ${label}`);
console.log(`${"═".repeat(100)}`); console.log(`${"═".repeat(100)}`);
if (result.hitters.length === 0) { if (result.records.length === 0) {
console.log(" No data found."); console.log(" No data found.");
return; return;
} }
const header = ["#", "Player", "Team", "AVG", "G", "PA", "AB", "R", "H", "2B", "3B", "HR", "TB", "RBI", "SAC", "SF"]; const widths = columns.map((col) => {
const widths = [4, 10, 6, 6, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]; 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("-+-")); console.log(" " + widths.map((w) => "-".repeat(w)).join("-+-"));
for (const h of result.hitters) { for (const rec of result.records) {
const row = [ const row = columns.map((c) => rec[c] ?? "");
h.rank, h.player, h.team, h.avg, h.games, h.pa, h.ab, console.log(
h.runs, h.hits, h.doubles, h.triples, h.hr, h.tb, h.rbi, h.sac, h.sf, " " + row.map((val, i) => padCell(val, widths[i])).join(" | ")
]; );
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 ── // ── Commands ──
@ -158,88 +185,161 @@ async function rankCommand(years: number[], jsonMode: boolean) {
} }
} }
async function hitterCommand(years: number[], jsonMode: boolean, teamName: string, allPages: boolean) { async function playerCommand(
console.log("Fetching KBO hitter stats...\n"); 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) : ""; console.log(`Fetching KBO ${subcommand} stats...\n`);
const { state: initState } = await fetchHitterStatsInitial();
// 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; let state = initState;
const results: HitterStatsResult[] = []; const results: PlayerStatsResult[] = [];
for (const year of years) { for (const year of years) {
process.stdout.write(` ${year}${teamName ? ` (${teamName})` : ""}...`); process.stdout.write(` ${year}${filterDesc ? ` (${filterDesc})` : ""}...`);
const fetchFn = allPages ? fetchAllHitterStats : fetchHitterStats; const fetchFn = allPages ? fetchAllPlayerStats : fetchPlayerStats;
const { result, newState } = await fetchFn(year, state, teamCode); const { result, newState } = await fetchFn(config, year, state, resolved);
state = newState; state = newState;
results.push(result); results.push(result);
console.log(` ${result.hitters.length} players`); console.log(` ${result.records.length} players`);
} }
if (jsonMode) { if (jsonMode) {
console.log(JSON.stringify(results, null, 2)); console.log(JSON.stringify(results, null, 2));
} else { } else {
for (const result of results) { for (const result of results) {
printHitterTable(result); printPlayerTable(subcommand, result.columns, result);
} }
} }
} }
// ── Main ── // ── 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() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const command = args[0]; const command = args[0];
if (!command || command === "help" || command === "--help") { if (!command || command === "help" || command === "--help") {
console.log("Usage: npx tsx src/kbo/cli.ts <command> [options]"); printHelp();
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; 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); const rest = args.slice(1);
let jsonMode = false; let jsonMode = false;
let allPages = false;
let teamName = "";
const years: number[] = []; const years: number[] = [];
for (const arg of rest) { for (const arg of rest) {
if (arg === "--json") { if (arg === "--json") jsonMode = true;
jsonMode = true; else {
} else if (arg === "--all") {
allPages = true;
} else if (arg.startsWith("--team=")) {
teamName = arg.slice(7);
} else {
const y = parseInt(arg, 10); const y = parseInt(arg, 10);
if (y >= 1982 && y <= 2100) { if (y >= 1982 && y <= 2100) years.push(y);
years.push(y); else {
} else {
console.error(`Invalid year: ${arg} (1982~2100)`); console.error(`Invalid year: ${arg} (1982~2100)`);
process.exit(1); process.exit(1);
} }
} }
} }
if (years.length === 0) { if (years.length === 0) years.push(new Date().getFullYear());
years.push(new Date().getFullYear());
}
switch (command) { switch (command) {
case "rank": case "rank":
await rankCommand(years, jsonMode); await rankCommand(years, jsonMode);
break; break;
case "hitter":
await hitterCommand(years, jsonMode, teamName, allPages);
break;
default: default:
console.error(`Unknown command: ${command}`); console.error(`Unknown command: ${command}`);
process.exit(1); process.exit(1);

View File

@ -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<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,
};
}

417
src/kbo/player/common.ts Normal file
View File

@ -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<string, string>;
/**
* .
*
* @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<string, string> = {
KT: "KT",
NC: "NC",
SSG: "SK",
: "LT",
: "HH",
: "OB",
: "SS",
: "WO",
KIA: "HT",
LG: "LG",
};
// ── Parsing ──
/**
* HTML .
*
* @param html - `<tr>`, `<td>` HTML
* @param columns - `<td>` ( )
* @returns `{ [컬럼명]: 값 }` .
* `<td>` () .
*/
export function parsePlayerTable(
html: string,
columns: readonly string[]
): PlayerRecord[] {
const records: PlayerRecord[] = [];
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])));
}
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<string, string> = {
team: "ddlTeam",
series: "ddlSeries",
pos: "ddlPos",
situation: "ddlSituation",
situationDetail: "ddlSituationDetail",
};
const DDL_DEFAULTS: Record<string, string> = {
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<string, string> {
const fields: Record<string, string> = {
[`${PREFIX}hfOrderByCol`]: config.defaultSortCol,
[`${PREFIX}hfOrderBy`]: "DESC",
[`${PREFIX}hfPage`]: "1",
};
// 필터 키 → dropdown 이름으로 변환한 lookup
const filterByDdl: Record<string, string> = {};
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,
};
}

16
src/kbo/player/defense.ts Normal file
View File

@ -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,
};

23
src/kbo/player/hitter.ts Normal file
View File

@ -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,
};

24
src/kbo/player/pitcher.ts Normal file
View File

@ -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,
};

15
src/kbo/player/runner.ts Normal file
View File

@ -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,
};