- TypeScript 소스 파일 내 모든 import 구문에서 불필요한 `.js` 확장자를 제거하여 모듈 참조 방식을 표준화했습니다. - 핸들러, 서비스, 리포지토리 및 테스트 코드를 포함한 프로젝트 전반의 import 경로를 일관성 있게 정리했습니다.
179 lines
5.4 KiB
TypeScript
179 lines
5.4 KiB
TypeScript
import { extractHiddenField } from "./html-utils";
|
|
|
|
export const PREFIX =
|
|
"ctl00$ctl00$ctl00$cphContents$cphContents$cphContents$";
|
|
|
|
export interface PageState {
|
|
viewState: string;
|
|
viewStateGenerator: string;
|
|
eventValidation: string;
|
|
/**
|
|
* ASP.NET 세션 쿠키.
|
|
*
|
|
* 일부 KBO 페이지(예: HitterBasic)는 서버가 ViewState/EventValidation을
|
|
* 세션과 연결하여 검증하므로, 초기 응답의 Set-Cookie로 받은 세션 쿠키를
|
|
* 후속 postback 요청에 포함해야 한다. 쿠키 없이 요청하면 서버가
|
|
* ViewState 검증에 실패하여 에러 페이지로 리다이렉트된다.
|
|
*
|
|
* TeamRank.aspx 같은 일부 페이지는 쿠키 없이도 동작하지만,
|
|
* 일관성을 위해 모든 요청에 쿠키를 포함한다.
|
|
*/
|
|
cookies: string;
|
|
}
|
|
|
|
export interface PostbackRequest {
|
|
url: string;
|
|
eventTarget: string;
|
|
scriptManager: string;
|
|
/**
|
|
* ASP.NET ScriptManager 컨트롤의 form field 키 이름.
|
|
*
|
|
* KBO 사이트의 ASP.NET 페이지마다 ScriptManager 컨트롤의 ID가 다르다.
|
|
* - TeamRank.aspx: PREFIX + "ScriptManager" (기본 이름)
|
|
* - HitterBasic/Basic1.aspx: PREFIX + "smData" (커스텀 이름)
|
|
*
|
|
* ScriptManager는 ASP.NET AJAX의 partial postback(UpdatePanel)을 관리하는
|
|
* 서버 컨트롤로, 요청 시 form data에 자신의 ID를 키로 포함해야 한다.
|
|
* 개발자가 페이지마다 다른 ID를 부여할 수 있으므로, 이 필드로 지정한다.
|
|
*
|
|
* 미지정 시 PREFIX + "ScriptManager"를 기본값으로 사용.
|
|
*/
|
|
scriptManagerKey?: string;
|
|
formFields: Record<string, string>;
|
|
state: PageState;
|
|
}
|
|
|
|
export interface PostbackResponse {
|
|
html: string;
|
|
newState: PageState;
|
|
}
|
|
|
|
/**
|
|
* ASP.NET AJAX delta 응답 파서.
|
|
*
|
|
* delta 응답은 `length|type|id|content|` 레코드의 연속이다.
|
|
* 기존 regex 방식(`[\s\S]*?`)은 content 내부에 `|숫자|` 패턴이 있으면
|
|
* 잘못된 위치에서 매칭이 끊기는 문제가 있었다.
|
|
* length 필드를 이용해 정확한 바이트 수만큼 content를 읽어 이 문제를 해결한다.
|
|
*/
|
|
function parseDeltaResponse(text: string): {
|
|
panels: { id: string; content: string }[];
|
|
fields: Record<string, string>;
|
|
} {
|
|
const panels: { id: string; content: string }[] = [];
|
|
const fields: Record<string, string> = {};
|
|
let pos = 0;
|
|
|
|
while (pos < text.length) {
|
|
const p1 = text.indexOf("|", pos);
|
|
if (p1 === -1) break;
|
|
const len = parseInt(text.substring(pos, p1), 10);
|
|
if (isNaN(len)) break;
|
|
pos = p1 + 1;
|
|
|
|
const p2 = text.indexOf("|", pos);
|
|
if (p2 === -1) break;
|
|
const type = text.substring(pos, p2);
|
|
pos = p2 + 1;
|
|
|
|
const p3 = text.indexOf("|", pos);
|
|
if (p3 === -1) break;
|
|
const id = text.substring(pos, p3);
|
|
pos = p3 + 1;
|
|
|
|
const content = text.substring(pos, pos + len);
|
|
pos = pos + len + 1;
|
|
|
|
if (type === "updatePanel") {
|
|
panels.push({ id, content });
|
|
} else if (type === "hiddenField") {
|
|
fields[id] = content;
|
|
}
|
|
}
|
|
|
|
return { panels, fields };
|
|
}
|
|
|
|
const USER_AGENT =
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36";
|
|
|
|
export async function getInitialPageState(
|
|
url: string
|
|
): Promise<{ html: string; state: PageState }> {
|
|
const res = await fetch(url, {
|
|
headers: { "User-Agent": USER_AGENT },
|
|
});
|
|
const html = await res.text();
|
|
|
|
const setCookies = res.headers.getSetCookie();
|
|
const cookies = setCookies.map((c) => c.split(";")[0]).join("; ");
|
|
|
|
return {
|
|
html,
|
|
state: {
|
|
viewState: extractHiddenField(html, "__VIEWSTATE"),
|
|
viewStateGenerator: extractHiddenField(html, "__VIEWSTATEGENERATOR"),
|
|
eventValidation: extractHiddenField(html, "__EVENTVALIDATION"),
|
|
cookies,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function postback(
|
|
request: PostbackRequest
|
|
): Promise<PostbackResponse> {
|
|
const formData = new URLSearchParams();
|
|
|
|
formData.set(
|
|
request.scriptManagerKey ?? `${PREFIX}ScriptManager`,
|
|
request.scriptManager
|
|
);
|
|
|
|
for (const [key, value] of Object.entries(request.formFields)) {
|
|
formData.set(key, value);
|
|
}
|
|
|
|
formData.set("__EVENTTARGET", request.eventTarget);
|
|
formData.set("__EVENTARGUMENT", "");
|
|
formData.set("__LASTFOCUS", "");
|
|
formData.set("__VIEWSTATE", request.state.viewState);
|
|
formData.set("__VIEWSTATEGENERATOR", request.state.viewStateGenerator);
|
|
formData.set("__EVENTVALIDATION", request.state.eventValidation);
|
|
formData.set("__ASYNCPOST", "true");
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
"X-MicrosoftAjax": "Delta=true",
|
|
"X-Requested-With": "XMLHttpRequest",
|
|
"User-Agent": USER_AGENT,
|
|
Referer: request.url,
|
|
Origin: new URL(request.url).origin,
|
|
};
|
|
|
|
if (request.state.cookies) {
|
|
headers["Cookie"] = request.state.cookies;
|
|
}
|
|
|
|
const res = await fetch(request.url, {
|
|
method: "POST",
|
|
headers,
|
|
body: formData.toString(),
|
|
});
|
|
|
|
const text = await res.text();
|
|
const { panels, fields } = parseDeltaResponse(text);
|
|
|
|
const html = panels[0]?.content ?? text;
|
|
|
|
const newState: PageState = {
|
|
viewState: fields["__VIEWSTATE"] ?? request.state.viewState,
|
|
viewStateGenerator:
|
|
fields["__VIEWSTATEGENERATOR"] ?? request.state.viewStateGenerator,
|
|
eventValidation:
|
|
fields["__EVENTVALIDATION"] ?? request.state.eventValidation,
|
|
cookies: request.state.cookies,
|
|
};
|
|
|
|
return { html, newState };
|
|
}
|