googlesheet

This commit is contained in:
kjy
2026-05-12 22:37:36 +09:00
commit a77e19e4dd
22 changed files with 81174 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
import * as cheerio from "cheerio";
export type ParsedRow = Record<string, string>;
export type ParsedTables = ParsedRow[];
function normalizeText(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
export function parseTableRows(tableHtml: string, site?: string): ParsedRow[] {
const $ = cheerio.load(tableHtml);
const headers: string[] = [];
$("thead th").each((_: any, element: any) => {
headers.push(normalizeText($(element).text()));
});
const rows: ParsedRow[] = [];
$("tbody tr").each((_, row) => {
const cells: string[] = [];
$(row)
.find("td")
.each((__, cell) => {
cells.push(normalizeText($(cell).text()));
});
const parsedRow: ParsedRow = {};
headers.forEach((header, index) => {
if (!header) {
return;
}
parsedRow[header] = cells[index] ?? "";
});
if (site) {
parsedRow.site = site;
}
rows.push(parsedRow);
});
return rows;
}
export function parseCtmListTable(html: string) {
const $ = cheerio.load(html);
const tables = $("table[name='ctm_list_tbl']").toArray();
if (tables.length === 0) {
return [];
}
return tables.flatMap((table, index) => {
const site = `H${index + 1}`;
const tableHtml = $.html(table);
if (!tableHtml) {
return [];
}
return parseTableRows(tableHtml, site);
});
}

View File

@@ -0,0 +1,377 @@
// 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;
}
}