378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
// 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<string, string | number | boolean | undefined>;
|
|
|
|
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<HyundaiCustomerVerifierOptions>;
|
|
|
|
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<void> {
|
|
await this.refreshCookie();
|
|
}
|
|
|
|
/** 현재 쿠키가 없거나 만료된 것으로 보이면 다시 로그인 */
|
|
private async ensureCookie(): Promise<void> {
|
|
if (!this.cookie) {
|
|
await this.refreshCookie();
|
|
}
|
|
}
|
|
|
|
/** 로그인해서 Set-Cookie → cookie 문자열로 조합해 보관 */
|
|
private async refreshCookie(): Promise<void> {
|
|
const setCookies = await this.getCookie();
|
|
this.cookie = this.joinSetCookie(setCookies);
|
|
}
|
|
|
|
/** 실제 로그인 요청 */
|
|
private async getCookie(): Promise<string[]> {
|
|
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<T = any>(
|
|
config: AxiosRequestConfig
|
|
): Promise<T> {
|
|
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<T>({ ...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<T>({
|
|
...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<T>({
|
|
...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<any> {
|
|
try {
|
|
let response = await this.requestWithCookie<any>({
|
|
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<ParsedTables> {
|
|
return this.verifyByPhone(phone);
|
|
}
|
|
|
|
async lookupPhones(rows: RowObject[]): Promise<RowObject[]> {
|
|
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;
|
|
}
|
|
}
|