// hyundaiCustomerVerifier.ts import axios, { type AxiosInstance, type AxiosRequestConfig } from "axios"; import qs from "qs"; import { parseCtmListTable, type ParsedTables, type ParsedRow, } from "./hyundaiCustomerParser"; import type { RowObject } from "../google/index"; type LoginPayload = Record; const loginPayload = { app_name: "크롬(chrome)", from: "pc", nhoj: "JH1204", mluap: "827ccb0eea8a706c4c34a16891f84e7b", auth_step: 1, force_sms: "n", }; function clean(value?: string | null): string | null { const normalized = (value ?? "").replace(/\s+/g, " ").trim(); return normalized || null; } function toVerificationResult( row: ParsedRow ): HyundaiCustomerVerificationResult { return { exists: true, receiptDate: clean(row["접수일자"]), customerGroup: clean(row["고객그룹"]), customerState: clean(row["진행상태"]), customerBranch: clean(row["개통처"]), store: clean(row["판매점"]), customerName: clean(row["고객명"]), phone: clean(row["핸드폰연락처"]), customerKey: clean(row["고객키"]), site: "", }; } export interface HyundaiCustomerVerifierOptions { /** 로그인 POST를 보낼 URL (기본값: hyun.bizmax.net 로그인 엔드포인트) */ loginUrl?: string; /** 로그인 시 전송할 payload (id/pw 등) */ loginPayload?: LoginPayload; /** 폰번호 조회(또는 원하는 특정 요청)를 보낼 URL */ requestUrl?: string; /** Referer/Host 등 헤더 커스터마이즈 */ referer?: string; host?: string; /** 기본 User-Agent (원본 UA 유지가 유리한 서비스면 그대로 둬도 됨) */ userAgent?: string; /** 요청 타임아웃(ms) */ timeoutMs?: number; } export interface HyundaiCustomerVerificationResult { exists?: boolean; receiptDate?: string | null; customerGroup?: string | null; customerState?: string | null; customerBranch?: string | null; store?: string | null; customerName?: string | null; phone: string | null; customerKey: string | null; site: string; } export type PhoneLookupResult = HyundaiCustomerVerificationResult; export class HyundaiCustomerVerifier { private cookie: string | null = null; private axios: AxiosInstance; private options: Required; constructor(options: HyundaiCustomerVerifierOptions) { // 옵션 기본값 세팅 this.options = { loginUrl: options.loginUrl ?? "https://hyun.bizmax.net/login_pcs_jr.php", requestUrl: options.requestUrl ?? "", loginPayload: loginPayload, referer: options.referer ?? "https://hyun.bizmax.net/?from=&dmy=1712135481", host: options.host ?? "hyun.bizmax.net", userAgent: options.userAgent ?? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36", timeoutMs: options.timeoutMs ?? 15_000, }; this.axios = axios.create({ timeout: this.options.timeoutMs, // NOTE: 필요하면 proxy/httpsAgent 등도 여기에 설정 가능 // httpsAgent: new https.Agent({ keepAlive: true }), }); } /** 외부에서 한 번 호출해서 로그인 쿠키를 준비해줘 */ async init(): Promise { await this.refreshCookie(); } /** 현재 쿠키가 없거나 만료된 것으로 보이면 다시 로그인 */ private async ensureCookie(): Promise { if (!this.cookie) { await this.refreshCookie(); } } /** 로그인해서 Set-Cookie → cookie 문자열로 조합해 보관 */ private async refreshCookie(): Promise { const setCookies = await this.getCookie(); this.cookie = this.joinSetCookie(setCookies); } /** 실제 로그인 요청 */ private async getCookie(): Promise { const res = await this.axios.post( this.options.loginUrl, qs.stringify(this.options.loginPayload), { headers: { Host: this.options.host, Origin: "https://hyun.bizmax.net", "Accept-Encoding": "gzip, deflate", "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7,la;q=0.6,zh-CN;q=0.5,zh;q=0.4", "Cache-Control": "no-cache", Connection: "keep-alive", "Content-Type": "application/x-www-form-urlencoded", Cookie: "ds_yn=y; save_id=home3470", Pragma: "no-cache", Referer: this.options.referer, "User-Agent": this.options.userAgent, "X-BizMax-Ajax": "1", "X-Requested-With": "XMLHttpRequest", "X-Site-Gubun": "pc", }, // 일부 서버는 302로 세션 부여 → 따라가도 무방 maxRedirects: 5, validateStatus: (s) => s >= 200 && s < 400, } ); const setCookie = res.headers["set-cookie"]; if (!setCookie || setCookie.length === 0) { throw new Error("로그인 실패: Set-Cookie 헤더가 없습니다."); } return setCookie; } /** 다중 Set-Cookie 헤더를 요청용 Cookie 문자열로 합치기 */ private joinSetCookie(setCookies: string[]): string { // "key=value; Path=/; HttpOnly" → "key=value"만 추출해서 세미콜론으로 연결 const parts = setCookies .map((c) => c.split(";")[0]?.trim()) .filter(Boolean); return parts.join("; "); } /** 공통 요청 래퍼: 세션 만료(401/로그인 페이지 응답 등) 시 재로그인 후 재시도 */ private async requestWithCookie( config: AxiosRequestConfig ): Promise { await this.ensureCookie(); const headers = { Host: this.options.host, Referer: this.options.referer, "User-Agent": this.options.userAgent, Cookie: this.cookie!, "X-Requested-With": "XMLHttpRequest", "X-Site-Gubun": "pc", ...config.headers, }; try { const res = await this.axios.request({ ...config, headers }); // 어떤 서버는 세션만료 시 200 + 로그인 HTML을 돌려주기도 함 → 가벼운 탐지 if ( typeof res.data === "string" && /login|세션|expired|로그인/i.test(res.data) ) { // 재로그인 후 1회 재시도 await this.refreshCookie(); const res2 = await this.axios.request({ ...config, headers: { ...headers, Cookie: this.cookie! }, }); return res2.data; } return res.data; } catch (err: any) { // 401 등 명시적 인증 오류 → 재로그인 후 재시도 1회 const status = err?.response?.status; if (status === 401 || status === 403) { await this.refreshCookie(); const res2 = await this.axios.request({ ...config, headers: { ...headers, Cookie: this.cookie! }, }); return res2.data; } throw err; } } /** 폰번호 조회 페이로드 구성(서비스에 맞게 수정) */ private buildPhonePayload(phone: string): LoginPayload { // 실제 필드명/추가 파라미터는 서비스 스펙에 맞게 바꿔줘 return { hp: phone }; } /** * 단일 전화번호로 현대/BizMax 고객 존재 여부를 확인합니다. * 현재는 조회 결과 행을 그대로 반환하고, 결과가 1개 이상이면 존재한다고 판단할 수 있습니다. */ async verifyByPhone(phone: string): Promise { try { let response = await this.requestWithCookie({ url: "https://hyun.bizmax.net/customer/tpl/customer_list.php?list_type=&team_prim=&tdm_prim=&svc_gubun=0&prd_ds=&dss=&dv_prim=240", method: "POST", adapter: "fetch", data: qs.stringify({ s_customer_group: "", s_customer_group_src: "", s_customer_group_dsp: "- 고객그룹", s_open_branch_prim: "", s_open_branch_prim_src: "", s_open_branch_prim_dsp: "- 개통처", s_open_id: "", s_sale_branch_prim: "", s_sale_branch_prim_src: "", s_sale_branch_prim_dsp: "- 판매점", s_sale_id: "", s_sale_id_2: "", state: "", state_obc: "#fff", state_otc: "#000", state_sch_ni: "", state_src: "", state_dsp: "- 진행상태", open_state: "", open_state_obc: "#fff", open_state_otc: "#000", open_state_src: "", open_state_dsp: "- 개통상태", pre_model_return: "", pre_model_return_obc: "#fff", pre_model_return_otc: "#000", pre_model_return_src: "", pre_model_return_dsp: "- 결제구분", excel_ext: "html", conf_self: "", conf_self_obc: "#fff", conf_self_otc: "#000", conf_self_src: "", conf_self_dsp: "- 2차진행상태", new_model_return: "", new_model_return_obc: "#fff", new_model_return_otc: "#000", new_model_return_src: "", new_model_return_dsp: "- 결제현황", post_size: "20", data_orderby: "a.reg_date", clm: "1", search_word: phone, sch_match: "1", new_model: "", new_model_obc: "#fff", new_model_otc: "#000", new_model_src: "", new_model_dsp: "- 1차 상품", use_date_1: "a.reg_date", use_date_1_src: "a.reg_date", use_date_1_dsp: "- 접수일자", start_date_1: "1970-01-01", end_date_1: "2026-12-31", use_date_2: "", use_date_2_src: "a.reg_date", use_date_2_dsp: "- 일자검색", start_date_2: "2025-09-04", end_date_2: "2025-09-04", age_sch_1: "1", age_1: "", age_cdn_1: "1", age_cdn_join: "1", age_sch_2: "1", age_2: "", age_cdn_2: "1", ac_clm: "", ac_sbp: "", sale_name_clm_q: "88bec586866ee5197e45da6a07da7c35", sale_name_2_clm_q: "", addr_fdata_clm_q: "415e2e219904aa02cce9bdf222e9bbb4", mode: "search", page: "1", hcv_p2: "", list_type: "", ol_type: "data", dv_prim: "240", team_prim: "", tdm_prim: "", prd_ds: "", fav_group_prim: "", no_ctm_rd_spd: "", no_ctm_md_spd: "", smr_vs: "", no_sch_hl: "", tbs_start_date: "2025-09-04", tbs_end_date: "2025-09-04", sch_addr_fdata: "", ctm_tbs_param: "", ctm_list_total_cnt: "1", _ihr: "n", _ihl: "n", _content_only: "y", site_gubun: "pc", }), headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", Origin: "https://hyun.bizmax.net", Referer: "https://hyun.bizmax.net/main/?1756984315", "X-BizMax-Ajax": "1", "X-PVSN": "V1", "X-Requested-With": "XMLHttpRequest", "X-Site-Gubun": "pc", }, validateStatus: (s) => s >= 200 && s < 400, }); return parseCtmListTable(String(response)); } catch (error) { throw error; } } async lookupPhone(phone: string): Promise { return this.verifyByPhone(phone); } async lookupPhones(rows: RowObject[]): Promise { const expandedRows: RowObject[] = []; for (const row of rows) { const phone = row.phone_number?.trim(); if (!phone) { expandedRows.push(row); continue; } const verificationRows = await this.lookupPhone(phone); if (verificationRows.length === 0) { expandedRows.push({ ...row, dup_check: "중복 없음", }); continue; } for (const verificationRow of verificationRows) { expandedRows.push({ ...row, ...verificationRow }); } } return expandedRows; } }