2026-04-13 09:18:30 +09:00

431 lines
13 KiB
TypeScript

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 PlayerValue = string | number | null;
export type PlayerRecord = Record<string, PlayerValue>;
/** 숫자로 변환하지 않고 문자열 그대로 유지할 컬럼 */
const STRING_COLUMNS: ReadonlySet<string> = new Set(["player", "team", "pos"]);
function parsePlayerCell(col: string, text: string): PlayerValue {
if (STRING_COLUMNS.has(col)) return text;
const trimmed = text.trim();
if (trimmed === "" || trimmed === "-") return null;
const n = Number(trimmed);
return Number.isNaN(n) ? trimmed : n;
}
/**
* 선수 기록 조회 필터.
*
* @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>`에 매핑할 컬럼 이름 배열 (순서대로 매핑)
* @return 각 행을 `{ [컬럼명]: 값 }` 형태로 변환한 배열.
* 첫 번째 `<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]] = parsePlayerCell(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 등)
* @return `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 등)
* @return 초기 데이터(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 등)
* @return 조회 결과(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 - 조회 필터
* @return 모든 페이지의 레코드를 합친 결과와 갱신된 상태
*/
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;
// eslint-disable-next-line no-constant-condition
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,
};
}