Generalize KBO player stats and add pitcher/defense/runner support
- KBO 선수 기록 조회 로직을 공통화하고 투수, 수비, 주루 카테고리 추가 - CLI 명령어를 hitter에서 player <type>으로 통합 및 필터 옵션 강화 - ASP.NET AJAX 응답 파싱 방식을 개선하여 데이터 누락 문제 해결 - 정규시즌과 포스트시즌의 서로 다른 페이지 구조 및 컬럼 대응 지원
This commit is contained in:
parent
73239b5519
commit
fe0ae5b372
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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 =
|
||||
"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,
|
||||
};
|
||||
|
||||
|
||||
224
src/kbo/cli.ts
224
src/kbo/cli.ts
@ -3,12 +3,13 @@
|
||||
* Usage: npx tsx src/kbo/cli.ts <command> [options]
|
||||
*
|
||||
* Commands:
|
||||
* rank [year...] 팀 순위 조회 (기본: 현재 연도)
|
||||
* hitter [year] [--team=팀명] 타자 기록 조회
|
||||
* rank [year...] 팀 순위
|
||||
* player <hitter|pitcher|defense|runner> 선수 기록
|
||||
*
|
||||
* 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<string, PlayerPageConfig> = {
|
||||
hitter: HITTER_CONFIG,
|
||||
pitcher: PITCHER_CONFIG,
|
||||
defense: DEFENSE_CONFIG,
|
||||
runner: RUNNER_CONFIG,
|
||||
};
|
||||
|
||||
// ── Rank Formatting ──
|
||||
|
||||
function fmtWLD(wld: WinLossDraw): string {
|
||||
return `${wld[0]}-${wld[1]}-${wld[2]}`;
|
||||
@ -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 <command> [options]");
|
||||
console.log("");
|
||||
console.log("Commands:");
|
||||
console.log(" rank [year...] Team rankings");
|
||||
console.log(" player <hitter|pitcher|defense|runner> Player stats");
|
||||
console.log("");
|
||||
console.log("Options:");
|
||||
console.log(" --json JSON output");
|
||||
console.log(" --all Fetch all pages (player)");
|
||||
console.log("");
|
||||
console.log("Player filters:");
|
||||
console.log(" --team=팀명 팀 (LG, 삼성, KT, ...)");
|
||||
console.log(" --series=값 시리즈 (0=정규, 1=시범, 3=준PO, 4=와카, 5=PO, 7=한국시리즈)");
|
||||
console.log(" --pos=값 포지션 (2=포수, 3,4,5,6=내야수, 7,8,9=외야수)");
|
||||
console.log(" --situation=값 상황별 (MONTH_SC, WEEK_SC, STADIUM_SC, HOMEAYAY_SC, ...)");
|
||||
console.log(" --situationDetail=값");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
if (!command || command === "help" || command === "--help") {
|
||||
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=삼성)");
|
||||
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);
|
||||
|
||||
@ -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
417
src/kbo/player/common.ts
Normal 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
16
src/kbo/player/defense.ts
Normal 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
23
src/kbo/player/hitter.ts
Normal 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
24
src/kbo/player/pitcher.ts
Normal 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
15
src/kbo/player/runner.ts
Normal 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,
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user