]*>([\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,
};
}
|