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; 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; } { const panels: { id: string; content: string }[] = []; const fields: Record = {}; 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 { 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 = { "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 }; }