/** * 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; 합계: WinLossDraw; } function parseRankTable(html: string): TeamRank[] { const teams: TeamRank[] = []; // 안의 들을 추출 const trRegex = /]*>([\s\S]*?)<\/tr>/gi; let trMatch: RegExpExecArray | null; while ((trMatch = trRegex.exec(html)) !== null) { const trContent = trMatch[1]; const tds: string[] = []; const tdRegex = /]*>([\s\S]*?)<\/td>/gi; let tdMatch: RegExpExecArray | null; while ((tdMatch = tdRegex.exec(trContent)) !== null) { tds.push(stripTags(decodeHtmlEntities(tdMatch[1]))); } // 팀 순위 행은 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( /]*summary="팀간승패표"[^>]*>([\s\S]*?)<\/table>/ ); if (!tableMatch) return []; const tableHtml = tableMatch[1]; // 헤더에서 팀명 목록 추출 const theadMatch = tableHtml.match(/]*>([\s\S]*?)<\/thead>/); const teamNames: string[] = []; if (theadMatch) { const thRegex = /]*>([\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(/]*>([\s\S]*?)<\/tbody>/); if (!tbodyMatch) return []; const trRegex = /]*>([\s\S]*?)<\/tr>/gi; let trMatch: RegExpExecArray | null; while ((trMatch = trRegex.exec(tbodyMatch[1])) !== null) { const cells: string[] = []; const tdRegex = /]*>([\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 = {}; 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); });