Add KBO CLI and hitter stats functionality
- KBO 팀 순위 및 타자 기록 조회를 위한 CLI 도구 추가 - 타자 통계 데이터 크롤링 및 파싱 기능 구현 - ASP.NET 세션 쿠키 및 가변 ScriptManager 키 처리 로직 추가 - 프로젝트 설정을 위한 IDE 및 도구 구성 파일 추가
This commit is contained in:
parent
a3a13e408a
commit
73239b5519
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(curl -s \"https://www.koreabaseball.com/Record/Player/HitterBasic/Basic1.aspx\" -H \"User-Agent: Mozilla/5.0\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
.idea/AICommit.xml
generated
Normal file
6
.idea/AICommit.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="com.github.blarc.ai.commits.intellij.plugin.settings.ProjectSettings">
|
||||||
|
<option name="splitButtonActionSelectedLLMClientId" value="2f900cba-1f90-431b-acb2-e4e6ac66b31e" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -7,12 +7,38 @@ export interface PageState {
|
|||||||
viewState: string;
|
viewState: string;
|
||||||
viewStateGenerator: string;
|
viewStateGenerator: string;
|
||||||
eventValidation: string;
|
eventValidation: string;
|
||||||
|
/**
|
||||||
|
* ASP.NET 세션 쿠키.
|
||||||
|
*
|
||||||
|
* 일부 KBO 페이지(예: HitterBasic)는 서버가 ViewState/EventValidation을
|
||||||
|
* 세션과 연결하여 검증하므로, 초기 응답의 Set-Cookie로 받은 세션 쿠키를
|
||||||
|
* 후속 postback 요청에 포함해야 한다. 쿠키 없이 요청하면 서버가
|
||||||
|
* ViewState 검증에 실패하여 에러 페이지로 리다이렉트된다.
|
||||||
|
*
|
||||||
|
* TeamRank.aspx 같은 일부 페이지는 쿠키 없이도 동작하지만,
|
||||||
|
* 일관성을 위해 모든 요청에 쿠키를 포함한다.
|
||||||
|
*/
|
||||||
|
cookies: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostbackRequest {
|
export interface PostbackRequest {
|
||||||
url: string;
|
url: string;
|
||||||
eventTarget: string;
|
eventTarget: string;
|
||||||
scriptManager: string;
|
scriptManager: string;
|
||||||
|
/**
|
||||||
|
* ASP.NET ScriptManager 컨트롤의 form field 키 이름.
|
||||||
|
*
|
||||||
|
* KBO 사이트의 ASP.NET 페이지마다 ScriptManager 컨트롤의 ID가 다르다.
|
||||||
|
* - TeamRank.aspx: PREFIX + "ScriptManager" (기본 이름)
|
||||||
|
* - HitterBasic/Basic1.aspx: PREFIX + "smData" (커스텀 이름)
|
||||||
|
*
|
||||||
|
* ScriptManager는 ASP.NET AJAX의 partial postback(UpdatePanel)을 관리하는
|
||||||
|
* 서버 컨트롤로, 요청 시 form data에 자신의 ID를 키로 포함해야 한다.
|
||||||
|
* 개발자가 페이지마다 다른 ID를 부여할 수 있으므로, 이 필드로 지정한다.
|
||||||
|
*
|
||||||
|
* 미지정 시 PREFIX + "ScriptManager"를 기본값으로 사용.
|
||||||
|
*/
|
||||||
|
scriptManagerKey?: string;
|
||||||
formFields: Record<string, string>;
|
formFields: Record<string, string>;
|
||||||
state: PageState;
|
state: PageState;
|
||||||
}
|
}
|
||||||
@ -33,12 +59,16 @@ export async function getInitialPageState(
|
|||||||
});
|
});
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
|
|
||||||
|
const setCookies = res.headers.getSetCookie();
|
||||||
|
const cookies = setCookies.map((c) => c.split(";")[0]).join("; ");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html,
|
html,
|
||||||
state: {
|
state: {
|
||||||
viewState: extractHiddenField(html, "__VIEWSTATE"),
|
viewState: extractHiddenField(html, "__VIEWSTATE"),
|
||||||
viewStateGenerator: extractHiddenField(html, "__VIEWSTATEGENERATOR"),
|
viewStateGenerator: extractHiddenField(html, "__VIEWSTATEGENERATOR"),
|
||||||
eventValidation: extractHiddenField(html, "__EVENTVALIDATION"),
|
eventValidation: extractHiddenField(html, "__EVENTVALIDATION"),
|
||||||
|
cookies,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -48,7 +78,10 @@ export async function postback(
|
|||||||
): Promise<PostbackResponse> {
|
): Promise<PostbackResponse> {
|
||||||
const formData = new URLSearchParams();
|
const formData = new URLSearchParams();
|
||||||
|
|
||||||
formData.set(`${PREFIX}ScriptManager`, request.scriptManager);
|
formData.set(
|
||||||
|
request.scriptManagerKey ?? `${PREFIX}ScriptManager`,
|
||||||
|
request.scriptManager
|
||||||
|
);
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(request.formFields)) {
|
for (const [key, value] of Object.entries(request.formFields)) {
|
||||||
formData.set(key, value);
|
formData.set(key, value);
|
||||||
@ -62,16 +95,22 @@ export async function postback(
|
|||||||
formData.set("__EVENTVALIDATION", request.state.eventValidation);
|
formData.set("__EVENTVALIDATION", request.state.eventValidation);
|
||||||
formData.set("__ASYNCPOST", "true");
|
formData.set("__ASYNCPOST", "true");
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
"X-MicrosoftAjax": "Delta=true",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
Referer: request.url,
|
||||||
|
Origin: new URL(request.url).origin,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.state.cookies) {
|
||||||
|
headers["Cookie"] = request.state.cookies;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(request.url, {
|
const res = await fetch(request.url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
||||||
"X-MicrosoftAjax": "Delta=true",
|
|
||||||
"X-Requested-With": "XMLHttpRequest",
|
|
||||||
"User-Agent": USER_AGENT,
|
|
||||||
Referer: request.url,
|
|
||||||
Origin: new URL(request.url).origin,
|
|
||||||
},
|
|
||||||
body: formData.toString(),
|
body: formData.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -90,6 +129,7 @@ export async function postback(
|
|||||||
viewState: vsMatch?.[1] ?? request.state.viewState,
|
viewState: vsMatch?.[1] ?? request.state.viewState,
|
||||||
viewStateGenerator: vsgMatch?.[1] ?? request.state.viewStateGenerator,
|
viewStateGenerator: vsgMatch?.[1] ?? request.state.viewStateGenerator,
|
||||||
eventValidation: evMatch?.[1] ?? request.state.eventValidation,
|
eventValidation: evMatch?.[1] ?? request.state.eventValidation,
|
||||||
|
cookies: request.state.cookies,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { html, newState };
|
return { html, newState };
|
||||||
|
|||||||
252
src/kbo/cli.ts
Normal file
252
src/kbo/cli.ts
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* KBO CLI
|
||||||
|
* Usage: npx tsx src/kbo/cli.ts <command> [options]
|
||||||
|
*
|
||||||
|
* Commands:
|
||||||
|
* rank [year...] 팀 순위 조회 (기본: 현재 연도)
|
||||||
|
* hitter [year] [--team=팀명] 타자 기록 조회
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* --json JSON 출력
|
||||||
|
* --all 모든 페이지 조회 (hitter)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { padCell } from "./html-utils.js";
|
||||||
|
import {
|
||||||
|
fetchTeamRankInitial,
|
||||||
|
fetchTeamRank,
|
||||||
|
type TeamRank,
|
||||||
|
type TeamVsRecord,
|
||||||
|
type TeamRankResult,
|
||||||
|
type WinLossDraw,
|
||||||
|
} from "./team-rank.js";
|
||||||
|
import {
|
||||||
|
fetchHitterStatsInitial,
|
||||||
|
fetchHitterStats,
|
||||||
|
fetchAllHitterStats,
|
||||||
|
TEAM_CODES,
|
||||||
|
type HitterStatsResult,
|
||||||
|
} from "./hitter-stats.js";
|
||||||
|
|
||||||
|
// ── Formatting ──
|
||||||
|
|
||||||
|
function fmtWLD(wld: WinLossDraw): string {
|
||||||
|
return `${wld[0]}-${wld[1]}-${wld[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printRankTable(year: number, teams: TeamRank[]) {
|
||||||
|
console.log(`\n${"═".repeat(90)}`);
|
||||||
|
console.log(` KBO ${year} Team Rankings`);
|
||||||
|
console.log(`${"═".repeat(90)}`);
|
||||||
|
|
||||||
|
if (teams.length === 0) {
|
||||||
|
console.log(" No data found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = ["#", "Team", "G", "W", "L", "D", "PCT", "GB", "L10", "STR"];
|
||||||
|
const widths = [4, 6, 4, 3, 3, 3, 6, 6, 12, 8];
|
||||||
|
|
||||||
|
console.log(" " + header.map((h, i) => padCell(h, widths[i])).join(" | "));
|
||||||
|
console.log(" " + widths.map((w) => "-".repeat(w)).join("-+-"));
|
||||||
|
|
||||||
|
for (const t of teams) {
|
||||||
|
const row = [
|
||||||
|
t.rank, t.team, t.games, t.wins, t.losses, t.draws,
|
||||||
|
t.winRate, t.gamesBehind, t.last10, t.streak,
|
||||||
|
];
|
||||||
|
console.log(" " + row.map((val, i) => padCell(val, widths[i])).join(" | "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printVsTable(year: number, vsRecords: TeamVsRecord[]) {
|
||||||
|
if (vsRecords.length === 0) return;
|
||||||
|
|
||||||
|
console.log(`\n${"─".repeat(90)}`);
|
||||||
|
console.log(` KBO ${year} Head-to-Head (W-L-D)`);
|
||||||
|
console.log(`${"─".repeat(90)}`);
|
||||||
|
|
||||||
|
const teamNames = vsRecords.map((r) => r.team);
|
||||||
|
const colW = 7;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
" " + padCell("", 6) + " | " +
|
||||||
|
teamNames.map((n) => padCell(n, colW)).join("| ") +
|
||||||
|
"| " + padCell("Total", colW)
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
" " + "-".repeat(6) + "-+-" +
|
||||||
|
teamNames.map(() => "-".repeat(colW)).join("+-") +
|
||||||
|
"+-" + "-".repeat(colW)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const rec of vsRecords) {
|
||||||
|
const cells = teamNames.map((name) => {
|
||||||
|
if (name === rec.team) return padCell(" -", colW);
|
||||||
|
const wld = rec.headToHead[name];
|
||||||
|
return padCell(wld ? fmtWLD(wld) : "-", colW);
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
" " + padCell(rec.team, 6) + " | " +
|
||||||
|
cells.join("| ") +
|
||||||
|
"| " + padCell(fmtWLD(rec.total), colW)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHitterTable(result: HitterStatsResult) {
|
||||||
|
const label = result.team
|
||||||
|
? `KBO ${result.year} Hitter Stats (${result.team})`
|
||||||
|
: `KBO ${result.year} Hitter Stats`;
|
||||||
|
|
||||||
|
console.log(`\n${"═".repeat(100)}`);
|
||||||
|
console.log(` ${label}`);
|
||||||
|
console.log(`${"═".repeat(100)}`);
|
||||||
|
|
||||||
|
if (result.hitters.length === 0) {
|
||||||
|
console.log(" No data found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = ["#", "Player", "Team", "AVG", "G", "PA", "AB", "R", "H", "2B", "3B", "HR", "TB", "RBI", "SAC", "SF"];
|
||||||
|
const widths = [4, 10, 6, 6, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4];
|
||||||
|
|
||||||
|
console.log(" " + header.map((h, i) => padCell(h, widths[i])).join(" | "));
|
||||||
|
console.log(" " + widths.map((w) => "-".repeat(w)).join("-+-"));
|
||||||
|
|
||||||
|
for (const h of result.hitters) {
|
||||||
|
const row = [
|
||||||
|
h.rank, h.player, h.team, h.avg, h.games, h.pa, h.ab,
|
||||||
|
h.runs, h.hits, h.doubles, h.triples, h.hr, h.tb, h.rbi, h.sac, h.sf,
|
||||||
|
];
|
||||||
|
console.log(" " + row.map((val, i) => padCell(val, widths[i])).join(" | "));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n Total: ${result.hitters.length} players`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commands ──
|
||||||
|
|
||||||
|
async function rankCommand(years: number[], jsonMode: boolean) {
|
||||||
|
console.log("Fetching KBO team rankings...\n");
|
||||||
|
|
||||||
|
const { result: initial, state: initState, currentYear } = await fetchTeamRankInitial();
|
||||||
|
let state = initState;
|
||||||
|
const results: TeamRankResult[] = [];
|
||||||
|
|
||||||
|
for (const year of years) {
|
||||||
|
process.stdout.write(` ${year}...`);
|
||||||
|
|
||||||
|
if (year === currentYear && results.length === 0) {
|
||||||
|
results.push(initial);
|
||||||
|
console.log(` ${initial.teams.length} teams (initial page)`);
|
||||||
|
} else {
|
||||||
|
const { result, newState } = await fetchTeamRank(year, state, currentYear);
|
||||||
|
state = newState;
|
||||||
|
results.push(result);
|
||||||
|
console.log(` ${result.teams.length} teams`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonMode) {
|
||||||
|
console.log(JSON.stringify(results, null, 2));
|
||||||
|
} else {
|
||||||
|
for (const { year, teams, vsRecords } of results) {
|
||||||
|
printRankTable(year, teams);
|
||||||
|
printVsTable(year, vsRecords);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hitterCommand(years: number[], jsonMode: boolean, teamName: string, allPages: boolean) {
|
||||||
|
console.log("Fetching KBO hitter stats...\n");
|
||||||
|
|
||||||
|
const teamCode = teamName ? (TEAM_CODES[teamName] ?? teamName) : "";
|
||||||
|
const { state: initState } = await fetchHitterStatsInitial();
|
||||||
|
let state = initState;
|
||||||
|
const results: HitterStatsResult[] = [];
|
||||||
|
|
||||||
|
for (const year of years) {
|
||||||
|
process.stdout.write(` ${year}${teamName ? ` (${teamName})` : ""}...`);
|
||||||
|
|
||||||
|
const fetchFn = allPages ? fetchAllHitterStats : fetchHitterStats;
|
||||||
|
const { result, newState } = await fetchFn(year, state, teamCode);
|
||||||
|
state = newState;
|
||||||
|
results.push(result);
|
||||||
|
console.log(` ${result.hitters.length} players`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonMode) {
|
||||||
|
console.log(JSON.stringify(results, null, 2));
|
||||||
|
} else {
|
||||||
|
for (const result of results) {
|
||||||
|
printHitterTable(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const command = args[0];
|
||||||
|
|
||||||
|
if (!command || command === "help" || command === "--help") {
|
||||||
|
console.log("Usage: npx tsx src/kbo/cli.ts <command> [options]");
|
||||||
|
console.log("");
|
||||||
|
console.log("Commands:");
|
||||||
|
console.log(" rank [year...] Team rankings (default: current year)");
|
||||||
|
console.log(" hitter [year] [--team=팀명] Hitter stats (default: current year)");
|
||||||
|
console.log("");
|
||||||
|
console.log("Options:");
|
||||||
|
console.log(" --json JSON output");
|
||||||
|
console.log(" --all Fetch all pages (hitter)");
|
||||||
|
console.log(" --team=팀명 Filter by team (e.g. --team=LG, --team=삼성)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = args.slice(1);
|
||||||
|
let jsonMode = false;
|
||||||
|
let allPages = false;
|
||||||
|
let teamName = "";
|
||||||
|
const years: number[] = [];
|
||||||
|
|
||||||
|
for (const arg of rest) {
|
||||||
|
if (arg === "--json") {
|
||||||
|
jsonMode = true;
|
||||||
|
} else if (arg === "--all") {
|
||||||
|
allPages = true;
|
||||||
|
} else if (arg.startsWith("--team=")) {
|
||||||
|
teamName = arg.slice(7);
|
||||||
|
} else {
|
||||||
|
const y = parseInt(arg, 10);
|
||||||
|
if (y >= 1982 && y <= 2100) {
|
||||||
|
years.push(y);
|
||||||
|
} else {
|
||||||
|
console.error(`Invalid year: ${arg} (1982~2100)`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (years.length === 0) {
|
||||||
|
years.push(new Date().getFullYear());
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case "rank":
|
||||||
|
await rankCommand(years, jsonMode);
|
||||||
|
break;
|
||||||
|
case "hitter":
|
||||||
|
await hitterCommand(years, jsonMode, teamName, allPages);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error(`Unknown command: ${command}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
233
src/kbo/hitter-stats.ts
Normal file
233
src/kbo/hitter-stats.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import { stripTags, decodeHtmlEntities } from "./html-utils.js";
|
||||||
|
import {
|
||||||
|
PREFIX,
|
||||||
|
postback,
|
||||||
|
getInitialPageState,
|
||||||
|
type PageState,
|
||||||
|
type PostbackResponse,
|
||||||
|
} from "./aspnet-client.js";
|
||||||
|
|
||||||
|
const PAGE_URL =
|
||||||
|
"https://www.koreabaseball.com/Record/Player/HitterBasic/Basic1.aspx";
|
||||||
|
|
||||||
|
const SM_KEY = `${PREFIX}smData`;
|
||||||
|
const UPDATE_PANEL = `${PREFIX}udpContent`;
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export interface HitterStats {
|
||||||
|
rank: string;
|
||||||
|
player: string;
|
||||||
|
team: string;
|
||||||
|
avg: string;
|
||||||
|
games: string;
|
||||||
|
pa: string;
|
||||||
|
ab: string;
|
||||||
|
runs: string;
|
||||||
|
hits: string;
|
||||||
|
doubles: string;
|
||||||
|
triples: string;
|
||||||
|
hr: string;
|
||||||
|
tb: string;
|
||||||
|
rbi: string;
|
||||||
|
sac: string;
|
||||||
|
sf: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HitterStatsResult {
|
||||||
|
year: number;
|
||||||
|
team: string;
|
||||||
|
hitters: HitterStats[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KBO 팀 코드 매핑.
|
||||||
|
*
|
||||||
|
* KBO 사이트의 팀 드롭다운은 현재 팀명이 아닌 구단 역사상의 코드를 value로 사용한다.
|
||||||
|
* 예: SSG는 SK 시절 코드 "SK", 키움은 우리 히어로즈 시절 코드 "WO" 등.
|
||||||
|
*/
|
||||||
|
export const TEAM_CODES: Record<string, string> = {
|
||||||
|
KT: "KT",
|
||||||
|
NC: "NC",
|
||||||
|
SSG: "SK",
|
||||||
|
롯데: "LT",
|
||||||
|
한화: "HH",
|
||||||
|
두산: "OB",
|
||||||
|
삼성: "SS",
|
||||||
|
키움: "WO",
|
||||||
|
KIA: "HT",
|
||||||
|
LG: "LG",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Parsing ──
|
||||||
|
|
||||||
|
export function parseHitterTable(html: string): HitterStats[] {
|
||||||
|
const hitters: HitterStats[] = [];
|
||||||
|
|
||||||
|
const trRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
||||||
|
let trMatch: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((trMatch = trRegex.exec(html)) !== null) {
|
||||||
|
const trContent = trMatch[1];
|
||||||
|
const tds: string[] = [];
|
||||||
|
const tdRegex = /<td[^>]*>([\s\S]*?)<\/td>/gi;
|
||||||
|
let tdMatch: RegExpExecArray | null;
|
||||||
|
while ((tdMatch = tdRegex.exec(trContent)) !== null) {
|
||||||
|
tds.push(stripTags(decodeHtmlEntities(tdMatch[1])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타자 기록 행: 16컬럼 (순위, 선수명, 팀, AVG, G, PA, AB, R, H, 2B, 3B, HR, TB, RBI, SAC, SF)
|
||||||
|
if (tds.length >= 16) {
|
||||||
|
const rank = tds[0];
|
||||||
|
if (/^\d+$/.test(rank)) {
|
||||||
|
hitters.push({
|
||||||
|
rank,
|
||||||
|
player: tds[1],
|
||||||
|
team: tds[2],
|
||||||
|
avg: tds[3],
|
||||||
|
games: tds[4],
|
||||||
|
pa: tds[5],
|
||||||
|
ab: tds[6],
|
||||||
|
runs: tds[7],
|
||||||
|
hits: tds[8],
|
||||||
|
doubles: tds[9],
|
||||||
|
triples: tds[10],
|
||||||
|
hr: tds[11],
|
||||||
|
tb: tds[12],
|
||||||
|
rbi: tds[13],
|
||||||
|
sac: tds[14],
|
||||||
|
sf: tds[15],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hitters;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form fields ──
|
||||||
|
|
||||||
|
function buildFormFields(
|
||||||
|
year: number,
|
||||||
|
teamCode: string
|
||||||
|
): Record<string, string> {
|
||||||
|
return {
|
||||||
|
[`${PREFIX}ddlSeason$ddlSeason`]: String(year),
|
||||||
|
[`${PREFIX}ddlSeries$ddlSeries`]: "0",
|
||||||
|
[`${PREFIX}ddlTeam$ddlTeam`]: teamCode,
|
||||||
|
[`${PREFIX}ddlPos$ddlPos`]: "",
|
||||||
|
[`${PREFIX}ddlSituation$ddlSituation`]: "",
|
||||||
|
[`${PREFIX}ddlSituationDetail$ddlSituationDetail`]: "",
|
||||||
|
[`${PREFIX}hfOrderByCol`]: "HRA_RT",
|
||||||
|
[`${PREFIX}hfOrderBy`]: "DESC",
|
||||||
|
[`${PREFIX}hfPage`]: "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch ──
|
||||||
|
|
||||||
|
export async function fetchHitterStatsInitial(): Promise<{
|
||||||
|
result: HitterStatsResult;
|
||||||
|
state: PageState;
|
||||||
|
}> {
|
||||||
|
const { html, state } = await getInitialPageState(PAGE_URL);
|
||||||
|
|
||||||
|
const yearMatch = html.match(
|
||||||
|
/ddlSeason_ddlSeason[\s\S]*?selected="selected"\s+value="(\d{4})"/
|
||||||
|
);
|
||||||
|
const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: { year, team: "", hitters: parseHitterTable(html) },
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHitterStats(
|
||||||
|
year: number,
|
||||||
|
state: PageState,
|
||||||
|
teamCode = ""
|
||||||
|
): Promise<{ result: HitterStatsResult; newState: PageState }> {
|
||||||
|
const eventTarget = `${PREFIX}ddlSeason$ddlSeason`;
|
||||||
|
|
||||||
|
const { html, newState }: PostbackResponse = await postback({
|
||||||
|
url: PAGE_URL,
|
||||||
|
eventTarget,
|
||||||
|
scriptManagerKey: SM_KEY,
|
||||||
|
scriptManager: `${UPDATE_PANEL}|${eventTarget}`,
|
||||||
|
formFields: buildFormFields(year, teamCode),
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 팀 필터가 있으면 추가 postback
|
||||||
|
if (teamCode) {
|
||||||
|
const teamTarget = `${PREFIX}ddlTeam$ddlTeam`;
|
||||||
|
const { html: teamHtml, newState: teamState } = await postback({
|
||||||
|
url: PAGE_URL,
|
||||||
|
eventTarget: teamTarget,
|
||||||
|
scriptManagerKey: SM_KEY,
|
||||||
|
scriptManager: `${UPDATE_PANEL}|${teamTarget}`,
|
||||||
|
formFields: buildFormFields(year, teamCode),
|
||||||
|
state: newState,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: { year, team: teamCode, hitters: parseHitterTable(teamHtml) },
|
||||||
|
newState: teamState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: { year, team: "", hitters: parseHitterTable(html) },
|
||||||
|
newState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 페이지의 타자 기록을 가져온다.
|
||||||
|
* 초기 페이지 로드 후, 페이지네이션 postback을 반복하여 전체 데이터를 수집한다.
|
||||||
|
*/
|
||||||
|
export async function fetchAllHitterStats(
|
||||||
|
year: number,
|
||||||
|
state: PageState,
|
||||||
|
teamCode = ""
|
||||||
|
): Promise<{ result: HitterStatsResult; newState: PageState }> {
|
||||||
|
// 첫 페이지 가져오기
|
||||||
|
const { result: firstResult, newState: firstState } = await fetchHitterStats(
|
||||||
|
year,
|
||||||
|
state,
|
||||||
|
teamCode
|
||||||
|
);
|
||||||
|
|
||||||
|
const allHitters = [...firstResult.hitters];
|
||||||
|
let currentState = firstState;
|
||||||
|
let page = 2;
|
||||||
|
|
||||||
|
// 다음 페이지가 있는 동안 반복
|
||||||
|
while (true) {
|
||||||
|
const pagerTarget = `${PREFIX}ucPager$btnNo${page}`;
|
||||||
|
const fields = buildFormFields(year, teamCode);
|
||||||
|
fields[`${PREFIX}hfPage`] = String(page);
|
||||||
|
|
||||||
|
const { html, newState }: PostbackResponse = await postback({
|
||||||
|
url: PAGE_URL,
|
||||||
|
eventTarget: pagerTarget,
|
||||||
|
scriptManagerKey: SM_KEY,
|
||||||
|
scriptManager: `${UPDATE_PANEL}|${pagerTarget}`,
|
||||||
|
formFields: fields,
|
||||||
|
state: currentState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageHitters = parseHitterTable(html);
|
||||||
|
if (pageHitters.length === 0) break;
|
||||||
|
|
||||||
|
allHitters.push(...pageHitters);
|
||||||
|
currentState = newState;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: { year, team: teamCode, hitters: allHitters },
|
||||||
|
newState: currentState,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user