- KBO 공식 홈페이지에서 연도별 팀 순위 및 상대 전적 데이터를 가져오는 크롤링 스크립트를 구현했습니다. - ASP.NET의 ViewState 및 EventValidation 필드를 처리하여 동적인 연도 전환 요청을 지원합니다. - 데이터를 가독성 높은 콘솔 테이블 형태로 출력하거나 JSON 형식으로 변환하는 기능을 제공합니다.
463 lines
13 KiB
TypeScript
463 lines
13 KiB
TypeScript
/**
|
|
* KBO 팀 순위 조회
|
|
* Usage: npx tsx kbo-team-rank.ts [연도] [연도2] ...
|
|
* 예시: npx tsx kbo-team-rank.ts 2024 2025
|
|
* npx tsx kbo-team-rank.ts (기본: 현재 연도)
|
|
*/
|
|
|
|
const BASE_URL =
|
|
"https://www.koreabaseball.com/Record/TeamRank/TeamRank.aspx";
|
|
|
|
const PREFIX = "ctl00$ctl00$ctl00$cphContents$cphContents$cphContents$";
|
|
|
|
// ── HTML 파싱 헬퍼 ──
|
|
|
|
function extractHiddenField(html: string, name: string): string {
|
|
// id="__VIEWSTATE" value="..."
|
|
const re = new RegExp(`id="${name}"[^>]*value="([^"]*)"`, "i");
|
|
const match = html.match(re);
|
|
if (match) return match[1];
|
|
// fallback: name="..." value="..."
|
|
const re2 = new RegExp(`name="${name}"[^>]*value="([^"]*)"`, "i");
|
|
const match2 = html.match(re2);
|
|
return match2?.[1] ?? "";
|
|
}
|
|
|
|
function stripTags(html: string): string {
|
|
return html.replace(/<[^>]*>/g, "").trim();
|
|
}
|
|
|
|
function decodeHtmlEntities(text: string): string {
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
|
|
}
|
|
|
|
// ── 테이블 파싱 ──
|
|
|
|
interface TeamRank {
|
|
순위: string;
|
|
팀명: string;
|
|
경기: string;
|
|
승: string;
|
|
패: string;
|
|
무: string;
|
|
승률: string;
|
|
게임차: string;
|
|
최근10경기: string;
|
|
연속: string;
|
|
홈: string;
|
|
방문: string;
|
|
}
|
|
|
|
type WinLossDraw = [win: number, loss: number, draw: number];
|
|
|
|
interface TeamVsRecord {
|
|
팀명: string;
|
|
상대전적: Record<string, WinLossDraw>;
|
|
합계: WinLossDraw;
|
|
}
|
|
|
|
function parseRankTable(html: string): TeamRank[] {
|
|
const teams: TeamRank[] = [];
|
|
|
|
// <tr> 안의 <td> 들을 추출
|
|
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])));
|
|
}
|
|
|
|
// 팀 순위 행은 12개 컬럼: 순위, 팀명, 경기, 승, 패, 무, 승률, 게임차, 최근10경기, 연속, 홈, 방문
|
|
if (tds.length >= 10) {
|
|
// 팀명이 한글/영문인지 간단 체크
|
|
const teamName = tds[1];
|
|
if (/^[A-Z가-힣]/.test(teamName)) {
|
|
teams.push({
|
|
순위: tds[0],
|
|
팀명: tds[1],
|
|
경기: tds[2],
|
|
승: tds[3],
|
|
패: tds[4],
|
|
무: tds[5],
|
|
승률: tds[6],
|
|
게임차: tds[7],
|
|
최근10경기: tds[8],
|
|
연속: tds[9],
|
|
홈: tds[10] ?? "",
|
|
방문: tds[11] ?? "",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return teams;
|
|
}
|
|
|
|
function parseWLD(s: string): WinLossDraw {
|
|
const parts = s.split("-").map(Number);
|
|
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
|
}
|
|
|
|
function parseVsTable(html: string): TeamVsRecord[] {
|
|
const tableMatch = html.match(
|
|
/<table[^>]*summary="팀간승패표"[^>]*>([\s\S]*?)<\/table>/
|
|
);
|
|
if (!tableMatch) return [];
|
|
|
|
const tableHtml = tableMatch[1];
|
|
|
|
// 헤더에서 팀명 목록 추출
|
|
const theadMatch = tableHtml.match(/<thead[^>]*>([\s\S]*?)<\/thead>/);
|
|
const teamNames: string[] = [];
|
|
if (theadMatch) {
|
|
const thRegex = /<th[^>]*>([\s\S]*?)<\/th>/gi;
|
|
let thMatch: RegExpExecArray | null;
|
|
while ((thMatch = thRegex.exec(theadMatch[1])) !== null) {
|
|
const text = stripTags(decodeHtmlEntities(thMatch[1])).replace(/\(승-패-무\)/g, "").trim();
|
|
if (text && text !== "팀명" && text !== "합계") {
|
|
teamNames.push(text);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 바디에서 각 팀의 상대전적 추출
|
|
const records: TeamVsRecord[] = [];
|
|
const tbodyMatch = tableHtml.match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/);
|
|
if (!tbodyMatch) return [];
|
|
|
|
const trRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
let trMatch: RegExpExecArray | null;
|
|
|
|
while ((trMatch = trRegex.exec(tbodyMatch[1])) !== null) {
|
|
const cells: string[] = [];
|
|
const tdRegex = /<td[^>]*>([\s\S]*?)<\/td>/gi;
|
|
let tdMatch: RegExpExecArray | null;
|
|
while ((tdMatch = tdRegex.exec(trMatch[1])) !== null) {
|
|
cells.push(stripTags(decodeHtmlEntities(tdMatch[1])));
|
|
}
|
|
|
|
if (cells.length < teamNames.length + 2) continue;
|
|
|
|
const 팀명 = cells[0];
|
|
const 상대전적: Record<string, WinLossDraw> = {};
|
|
|
|
for (let i = 0; i < teamNames.length; i++) {
|
|
const val = cells[i + 1];
|
|
if (val === "■" || val === "▲" || 팀명 === teamNames[i]) continue;
|
|
상대전적[teamNames[i]] = parseWLD(val);
|
|
}
|
|
|
|
const 합계 = parseWLD(cells[cells.length - 1]);
|
|
records.push({ 팀명, 상대전적, 합계 });
|
|
}
|
|
|
|
return records;
|
|
}
|
|
|
|
// ── 네트워크 ──
|
|
|
|
async function getInitialPage(): Promise<{
|
|
html: string;
|
|
viewState: string;
|
|
viewStateGenerator: string;
|
|
eventValidation: string;
|
|
currentYear: number;
|
|
}> {
|
|
const res = await fetch(BASE_URL, {
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36",
|
|
},
|
|
});
|
|
const html = await res.text();
|
|
|
|
// 현재 선택된 연도 추출
|
|
const yearMatch = html.match(
|
|
/id="cphContents_cphContents_cphContents_lblSearchDateTitle">(\d{4})/
|
|
);
|
|
const currentYear = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
|
|
|
|
return {
|
|
html,
|
|
viewState: extractHiddenField(html, "__VIEWSTATE"),
|
|
viewStateGenerator: extractHiddenField(html, "__VIEWSTATEGENERATOR"),
|
|
eventValidation: extractHiddenField(html, "__EVENTVALIDATION"),
|
|
currentYear,
|
|
};
|
|
}
|
|
|
|
interface PageState {
|
|
viewState: string;
|
|
viewStateGenerator: string;
|
|
eventValidation: string;
|
|
currentYear: number;
|
|
}
|
|
|
|
async function fetchYear(
|
|
year: number,
|
|
state: PageState
|
|
): Promise<{ teams: TeamRank[]; vsRecords: TeamVsRecord[]; newState: PageState }> {
|
|
const formData = new URLSearchParams();
|
|
formData.set(`${PREFIX}ScriptManager`, `${PREFIX}udpRecord|${PREFIX}ddlYear`);
|
|
formData.set(`${PREFIX}ddlYear`, String(year));
|
|
formData.set(`${PREFIX}ddlSeries`, "0");
|
|
// hfSearchYear/Date는 "현재 페이지가 보여주던 연도" (변경 전)
|
|
formData.set(`${PREFIX}hfSearchYear`, String(state.currentYear));
|
|
formData.set(`${PREFIX}hfSearchDate`, `${state.currentYear}1231`);
|
|
formData.set(`${PREFIX}hfSearchSeries`, "0");
|
|
formData.set("__EVENTTARGET", `${PREFIX}ddlYear`);
|
|
formData.set("__EVENTARGUMENT", "");
|
|
formData.set("__LASTFOCUS", "");
|
|
formData.set("__VIEWSTATE", state.viewState);
|
|
formData.set("__VIEWSTATEGENERATOR", state.viewStateGenerator);
|
|
formData.set("__EVENTVALIDATION", state.eventValidation);
|
|
formData.set("__ASYNCPOST", "true");
|
|
|
|
const res = await fetch(BASE_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
"X-MicrosoftAjax": "Delta=true",
|
|
"X-Requested-With": "XMLHttpRequest",
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36",
|
|
Referer: BASE_URL,
|
|
Origin: "https://www.koreabaseball.com",
|
|
},
|
|
body: formData.toString(),
|
|
});
|
|
|
|
const text = await res.text();
|
|
|
|
// ASP.NET AJAX 델타 응답에서 HTML 부분 추출 (첫 번째 updatePanel 섹션)
|
|
const panelMatch = text.match(
|
|
/updatePanel\|[^|]*\|([\s\S]*?)\|[0-9]+\|hiddenField\|/
|
|
);
|
|
const htmlContent = panelMatch?.[1] ?? text;
|
|
|
|
// 업데이트된 hidden field 추출
|
|
const vsMatch = text.match(/__VIEWSTATE\|([^|]*)\|/);
|
|
const vsgMatch = text.match(/__VIEWSTATEGENERATOR\|([^|]*)\|/);
|
|
const evMatch = text.match(/__EVENTVALIDATION\|([^|]*)\|/);
|
|
|
|
const newState: PageState = {
|
|
viewState: vsMatch?.[1] ?? state.viewState,
|
|
viewStateGenerator: vsgMatch?.[1] ?? state.viewStateGenerator,
|
|
eventValidation: evMatch?.[1] ?? state.eventValidation,
|
|
currentYear: year,
|
|
};
|
|
|
|
const teams = parseRankTable(htmlContent);
|
|
const vsRecords = parseVsTable(htmlContent);
|
|
|
|
return { teams, vsRecords, newState };
|
|
}
|
|
|
|
// ── 출력 ──
|
|
|
|
function printTable(year: number, teams: TeamRank[]) {
|
|
console.log(`\n${"═".repeat(90)}`);
|
|
console.log(` KBO ${year} 정규시즌 팀 순위`);
|
|
console.log(`${"═".repeat(90)}`);
|
|
|
|
if (teams.length === 0) {
|
|
console.log(" 데이터를 찾을 수 없습니다.");
|
|
return;
|
|
}
|
|
|
|
const header = [
|
|
"순위",
|
|
"팀명",
|
|
"경기",
|
|
"승",
|
|
"패",
|
|
"무",
|
|
"승률",
|
|
"게임차",
|
|
"최근10경기",
|
|
"연속",
|
|
];
|
|
const widths = [4, 6, 4, 3, 3, 3, 6, 6, 12, 8];
|
|
|
|
const padCell = (val: string, w: number) => {
|
|
// 한글 글자는 폭 2로 계산
|
|
const displayWidth = [...val].reduce(
|
|
(sum, ch) => sum + (ch.charCodeAt(0) > 0x7f ? 2 : 1),
|
|
0
|
|
);
|
|
return val + " ".repeat(Math.max(0, w - displayWidth));
|
|
};
|
|
|
|
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.순위,
|
|
t.팀명,
|
|
t.경기,
|
|
t.승,
|
|
t.패,
|
|
t.무,
|
|
t.승률,
|
|
t.게임차,
|
|
t.최근10경기,
|
|
t.연속,
|
|
];
|
|
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} 팀간 승패표 (승-패-무)`);
|
|
console.log(`${"─".repeat(90)}`);
|
|
|
|
const teamNames = vsRecords.map((r) => r.팀명);
|
|
const colW = 7;
|
|
|
|
const padCell = (val: string, w: number) => {
|
|
const displayWidth = [...val].reduce(
|
|
(sum, ch) => sum + (ch.charCodeAt(0) > 0x7f ? 2 : 1),
|
|
0
|
|
);
|
|
return val + " ".repeat(Math.max(0, w - displayWidth));
|
|
};
|
|
|
|
const fmtWLD = (wld: WinLossDraw) => `${wld[0]}-${wld[1]}-${wld[2]}`;
|
|
|
|
// 헤더
|
|
console.log(
|
|
" " +
|
|
padCell("", 6) +
|
|
" │ " +
|
|
teamNames.map((n) => padCell(n, colW)).join("│ ") +
|
|
"│ " +
|
|
padCell("합계", 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.팀명) return padCell(" ■", colW);
|
|
const wld = rec.상대전적[name];
|
|
return padCell(wld ? fmtWLD(wld) : "-", colW);
|
|
});
|
|
console.log(
|
|
" " +
|
|
padCell(rec.팀명, 6) +
|
|
" │ " +
|
|
cells.join("│ ") +
|
|
"│ " +
|
|
padCell(fmtWLD(rec.합계), colW)
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── JSON 출력 ──
|
|
|
|
interface YearResult {
|
|
year: number;
|
|
teams: TeamRank[];
|
|
vsRecords: TeamVsRecord[];
|
|
}
|
|
|
|
function printJson(results: YearResult[]) {
|
|
console.log(JSON.stringify(results, null, 2));
|
|
}
|
|
|
|
// ── Main ──
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
|
|
let jsonMode = false;
|
|
const years: number[] = [];
|
|
|
|
for (const arg of args) {
|
|
if (arg === "--json") {
|
|
jsonMode = true;
|
|
} else {
|
|
const y = parseInt(arg, 10);
|
|
if (y >= 1982 && y <= 2026) {
|
|
years.push(y);
|
|
} else {
|
|
console.error(`유효하지 않은 연도: ${arg} (1982~2026)`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (years.length === 0) {
|
|
years.push(new Date().getFullYear());
|
|
}
|
|
|
|
console.log("KBO 팀 순위 데이터를 가져오는 중...\n");
|
|
|
|
// 1단계: 초기 페이지 가져오기
|
|
const initial = await getInitialPage();
|
|
let state: PageState = {
|
|
viewState: initial.viewState,
|
|
viewStateGenerator: initial.viewStateGenerator,
|
|
eventValidation: initial.eventValidation,
|
|
currentYear: initial.currentYear,
|
|
};
|
|
|
|
const results: YearResult[] = [];
|
|
|
|
// 2단계: 연도별 데이터 가져오기 (순차 — 상태 의존)
|
|
for (const year of years) {
|
|
process.stdout.write(` ${year}년 데이터 요청 중...`);
|
|
|
|
if (year === initial.currentYear && results.length === 0) {
|
|
const teams = parseRankTable(initial.html);
|
|
const vsRecords = parseVsTable(initial.html);
|
|
results.push({ year, teams, vsRecords });
|
|
console.log(` ${teams.length}개 팀 로드 완료 (초기 페이지)`);
|
|
} else {
|
|
const { teams, vsRecords, newState } = await fetchYear(year, state);
|
|
state = newState;
|
|
results.push({ year, teams, vsRecords });
|
|
console.log(` ${teams.length}개 팀 로드 완료`);
|
|
}
|
|
}
|
|
|
|
// 3단계: 출력
|
|
if (jsonMode) {
|
|
printJson(results);
|
|
} else {
|
|
for (const { year, teams, vsRecords } of results) {
|
|
printTable(year, teams);
|
|
printVsTable(year, vsRecords);
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("오류 발생:", err);
|
|
process.exit(1);
|
|
});
|