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; /** 숫자로 변환하지 않고 문자열 그대로 유지할 컬럼 */ const STRING_COLUMNS: ReadonlySet = 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 = { KT: "KT", NC: "NC", SSG: "SK", 롯데: "LT", 한화: "HH", 두산: "OB", 삼성: "SS", 키움: "WO", KIA: "HT", LG: "LG", }; // ── Parsing ── /** * HTML 테이블에서 선수 기록을 파싱한다. * * @param html - ``, `` 태그가 포함된 HTML 문자열 * @param columns - 각 ``에 매핑할 컬럼 이름 배열 (순서대로 매핑) * @return 각 행을 `{ [컬럼명]: 값 }` 형태로 변환한 배열. * 첫 번째 ``가 숫자(순위)인 행만 포함한다. */ export function parsePlayerTable( html: string, columns: readonly string[] ): PlayerRecord[] { const records: PlayerRecord[] = []; 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]))); } 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 = { team: "ddlTeam", series: "ddlSeries", pos: "ddlPos", situation: "ddlSituation", situationDetail: "ddlSituationDetail", }; const DDL_DEFAULTS: Record = { 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 { const fields: Record = { [`${PREFIX}hfOrderByCol`]: config.defaultSortCol, [`${PREFIX}hfOrderBy`]: "DESC", [`${PREFIX}hfPage`]: "1", }; // 필터 키 → dropdown 이름으로 변환한 lookup const filterByDdl: Record = {}; 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, }; }