431 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|