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

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
GOOGLE_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
GOOGLE_SHEET_URL=https://docs.google.com/spreadsheets/d/1R7RHt84qel7laqH59crVbkVCNtcrcBXoJ8T0n5jb4pw/edit?gid=0#gid=0
GOOGLE_TARGET_SHEET_URL=https://docs.google.com/spreadsheets/d/1LGF93SGkrIqqniY-a3ReTPhizsEMtgm5QBC8tSALmVM/edit?gid=0#gid=0
# GOOGLE_SHEET_RANGE=A1:Z999
# GOOGLE_REDIRECT_PORT=3487

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.tokens
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

77
README.md Normal file
View File

@@ -0,0 +1,77 @@
# googlesheet
`bun`만으로 Google Sheets를 OAuth 인증 후 읽고, 다른 시트에 새 헤더 구조로 append 하는 예제입니다.
## 준비
1. Google Cloud Console에서 `Google Sheets API`를 활성화합니다.
2. `OAuth client ID`를 생성합니다.
3. 애플리케이션 유형은 `Web application`으로 만들고 아래 Redirect URI를 추가합니다.
```text
http://127.0.0.1:3487/oauth2callback
```
4. `.env.example`를 참고해서 `.env` 파일을 만듭니다.
## 실행
```bash
bun run index.ts
```
특정 날짜만 추출하려면 `--date` 옵션을 사용합니다.
```bash
bun run google/index.ts --date 2026-04-27
```
환경변수로도 줄 수 있습니다.
```bash
GOOGLE_FILTER_DATE=2026-04-27 bun run google/index.ts
```
특정 범위만 가져오려면 날짜 뒤에 A1 range를 넘기면 됩니다.
```bash
bun run google/index.ts --date 2026-04-27 "A1:C20"
```
대상 시트 URL까지 직접 넘기려면 그 다음 인자를 사용하면 됩니다.
```bash
bun run google/index.ts --date 2026-04-27 "A1:Z999" "대상시트URL"
```
## 동작 방식
- 시트 URL에서 `spreadsheetId``gid`를 자동 파싱합니다.
- 첫 실행 시 브라우저에서 Google OAuth 인증을 받습니다.
- 발급된 refresh token은 `.tokens/google-oauth-token.json`에 저장됩니다.
- 이후 실행부터는 저장된 refresh token으로 access token을 갱신합니다.
- 첫 번째 행을 원본 헤더로 해석한 뒤 객체 행으로 변환합니다.
- `created_time`이 실행 시 받은 날짜(`YYYY-MM-DD`, KST 기준)와 일치하는 행만 추출합니다.
- 대상 시트에는 아래 헤더를 1행에 보장한 뒤 데이터를 append 합니다.
```text
id
created_time
campaign_name
form_name
platform
full_name
phone_number
site
created_at
activation_channel
customer_group
sales_point
contact_number
primary_product
status
key
```
- 원본 헤더와 대상 헤더 이름이 같으면 해당 값을 복사합니다.
- 없는 컬럼은 빈 문자열로 들어갑니다.

144
bun.lock Normal file
View File

@@ -0,0 +1,144 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "googlesheet",
"dependencies": {
"@types/node": "^25.6.0",
"axios": "^1.15.2",
"es-toolkit": "^1.46.0",
"qs": "^6.15.1",
},
"devDependencies": {
"@types/bun": "latest",
"@types/qs": "^6.15.0",
"cheerio": "^1.2.0",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.15.2", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"es-toolkit": ["es-toolkit@1.46.0", "", {}, "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA=="],
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
}
}

6
cafe24/accounts.txt Normal file
View File

@@ -0,0 +1,6 @@
# 아이디 비번 그룹넘버 구분
# 공백, 탭, 콤마, | 구분자를 지원합니다.
# 구분이 2 또는 shop2 처럼 숫자로 해석되면 해당 shop 번호로 요청합니다.
geonuk0901 7@rkdhsflqld 2 롱맨카페
geonuk0901 7@rkdhsflqld 1 룰루뷰카페
bluemaxwith 7@rkdhsflqld 1 블루맥스카페

710
cafe24/cafe24.ts Normal file
View File

@@ -0,0 +1,710 @@
import axios from "axios";
import { take } from "es-toolkit";
import { mkdir } from "node:fs/promises";
import { HyundaiCustomerVerifier } from "../hyundai/hyundaiCustomerVerifier";
import { appendRowsToDailySheet } from "../sheet/googlesheetapi";
type MallUseAuthResponse = {
STATUS: string;
sEncData: string;
sEncKey: string;
sActionPath: string;
};
type Account = {
mallId: string;
password: string;
groupNo: string;
division: string;
shopNo: string;
};
// const INITIAL_COOKIE =
// "login_mode=1; PHPSESSID=df4dcdbed2d5be411d1ba57b28fe0572; _fwb=238EQK8z8Dup2CWqAOV9GHr.1777057879401; _gcl_au=1.1.1767964142.1777057879; _fbp=fb.1.1777057879522.988692189750068964; CUK45=cuk45_eckorea24_gkemvii10ntc5fghtaaqf9mg42vscrj4; CUK2Y=cuk2y_eckorea24_gkemvii10ntc5fghtaaqf9mg42vscrj4; CID=CIDR737c085badb1ba6805354e1bed9081de; CIDR737c085badb1ba6805354e1bed9081de=41a12ffaeb4b4614beaf5774fb1aaa15%3A%3A%3A%3A%3A%3A%3A%3A%3A%3A%3A%3A%3A%3A%3A%3A%3A%3A%3A%3A%2F%3A%3A1777057879%3A%3A%3A%3Appdp%3A%3A1777057879%3A%3A%3A%3A%3A%3A%3A%3A; basketcount_1=0; basketprice_1=0%EC%9B%90; wish_id=a6e247fd0eed9199543ec057cb0caf9e; wishcount_1=0; isviewtype=pc; _clck=1eo5hbc%5E2%5Eg5h%5E0%5E2305; analytics_session_id=analytics_session_id.eckorea24_1.E32D67B.1777057879666; analytics_longterm=analytics_longterm.eckorea24_1.B29FF1A.1775390402832; _hjSession_2368957=eyJpZCI6IjBmM2VkMzY4LTk1ZDItNGE3OS1hYjUyLTI2MDNjODhlZTg0YyIsImMiOjE3NzcwNTc4Nzk5MjcsInMiOjAsInIiOjAsInNiIjowLCJzciI6MCwic2UiOjAsImZzIjoxLCJzcCI6MX0=; vt=1777057880; _hjSessionUser_2368957=eyJpZCI6IjQzNmEwOGM5LWY0YmQtNTA3ZS05NTY5LTU2NTk1YjRkZjUwOSIsImNyZWF0ZWQiOjE3NzcwNTc4Nzk5MjYsImV4aXN0aW5nIjp0cnVlfQ==; CVID=CVID.54515f5b4a50510b076f05.1777057880748; CVID_Y=CVID_Y.54515f5b4a50510b076f05.1777057880748; ch-veil-id=5170f9b3-9125-4fa5-bc25-5a0588aa0193; ch-session-193477=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzZXMiLCJleHAiOjE3Nzk2NDk4ODEsImlhdCI6MTc3NzA1Nzg4MSwia2V5IjoiMTkzNDc3LTY5ZDQ1MmI1NDc3NjYxODRmZmY4In0.ubvM5Ul3_5n32fs3Zc5oQHAmrVOClz7CFFczwD5Wt-o; _ga_12RF674XCD=GS2.2.s1777057881$o1$g0$t1777057881$j60$l0$h0; _ga_JC3MGH4M4T=GS2.2.s1777057880$o1$g1$t1777057880$j59$l0$h0; _ga=GA1.1.1282638454.1777057025; cto_bundle=Pm-nMl9OcHp0eWh3dmlOSUV0UGV0eHI2cEZ5R0J1c1J0bG1oUDdzdDNISjl3ZHc3ZmVQc215REpDYVFRd0IyZFYlMkZseW9lQ0kxY0NBM2NwJTJGU1RNZ21DR3pITm90TlZCaVlXd0I4JTJCOU9EaGZ6Mkk0TEhURFFiUklsVzJTaDc4OGN4c045eCUyQldOUkluaFdUVGhCaHdJbWl5RCUyQnh3JTNEJTNE; _clsk=49y69s%5E1777057881420%5E1%5E1%5Ee.clarity.ms%2Fcollect; _ga_ZTM1Z99BLE=GS2.2.s1777057880$o1$g1$t1777057882$j57$l0$h0; _ga_Z6CSBGDNRT=GS2.1.s1777057880$o1$g0$t1777057882$j58$l0$h0; GMCC=1dpUipix8zMJMXeWxnayh6U2Z%252Bwn64de5%252BGBE9t8ecAjkaNREGa5nViF811clijfk1asqmTpCYYgWGkGUVnYYiZloR4ksXOD8yT5BB%252FeAxw%253D; _ga_TW9JR58492=GS2.1.s1777057025$o1$g1$t1777057921$j21$l0$h0";
const INITIAL_COOKIE = "";
const RESULT_DIR = "result";
export let cookie = INITIAL_COOKIE;
export let dynamicHiddenFieldName = "";
export let dynamicHiddenFieldValue = "";
let activeAccount: Account = {
mallId: "",
password: "",
groupNo: "",
division: "",
shopNo: "",
};
function getTodayKstDateString() {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.formatToParts(new Date())
.reduce<Record<string, string>>((acc, part) => {
if (part.type !== "literal") {
acc[part.type] = part.value;
}
return acc;
}, {});
return `${parts.year}-${parts.month}-${parts.day}`;
}
function isIsoDateString(value: string) {
return /^\d{4}-\d{2}-\d{2}$/.test(value);
}
function parseCliOptions() {
const firstArg = Bun.argv[2]?.trim();
const secondArg = Bun.argv[3]?.trim();
if (firstArg && isIsoDateString(firstArg)) {
return {
regDate: firstArg,
accountsPath: secondArg || "./cafe24/accounts.txt",
};
}
return {
regDate: getTodayKstDateString(),
accountsPath: firstArg || "./cafe24/accounts.txt",
};
}
const { regDate, accountsPath } = parseCliOptions();
function getAdminBaseUrl() {
return `https://${activeAccount.mallId}.cafe24.com`;
}
function getShopPath(path: string, shopNo = activeAccount.groupNo) {
return `/admin/php/shop${shopNo}${path}`;
}
function sanitizeFilePart(value: string) {
return value.replace(/[^a-zA-Z0-9._-]+/g, "_");
}
function getResultPrefix() {
return [
sanitizeFilePart(activeAccount.mallId),
`shop${sanitizeFilePart(activeAccount.shopNo)}`,
`group${sanitizeFilePart(activeAccount.groupNo)}`,
sanitizeFilePart(activeAccount.division),
].join("-");
}
function getResultPath(fileName: string) {
return `${RESULT_DIR}/${fileName}`;
}
function appendCookie(setCookie: string | string[] | undefined) {
const setCookieList = typeof setCookie === "string" ? [setCookie] : setCookie;
if (!setCookieList?.length) {
return;
}
const cookieMap = new Map(
cookie
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const equalIndex = part.indexOf("=");
return [part.slice(0, equalIndex), part.slice(equalIndex + 1)] as const;
})
);
for (const header of setCookieList) {
const nameValue = header.split(";")[0]?.trim();
if (!nameValue) {
continue;
}
const equalIndex = nameValue.indexOf("=");
if (equalIndex === -1) {
continue;
}
cookieMap.set(
nameValue.slice(0, equalIndex),
nameValue.slice(equalIndex + 1)
);
}
cookie = [...cookieMap.entries()]
.map(([name, value]) => `${name}=${value}`)
.join("; ");
}
function extractDynamicHiddenField(html: string) {
const hiddenInputs =
html.match(/<input\b[^>]*\btype=["']hidden["'][^>]*>/gi) ?? [];
for (const input of hiddenInputs) {
const nameMatch = input.match(/\bname=["']([^"']*)["']/i);
const valueMatch = input.match(/\bvalue=["']([^"']*)["']/i);
const name = nameMatch?.[1] ?? "";
const value = valueMatch?.[1] ?? "";
if (
name &&
/^eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)
) {
return { name, value };
}
}
return { name: "", value: "" };
}
function extractUserIdCheckUrl(html: string) {
const escapedMallId = activeAccount.mallId.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&"
);
const urlMatch = html.match(
new RegExp(
`https?:\\/\\/${escapedMallId}\\.cafe24\\.com\\/admin\\/php\\/user_id_check\\.php[^"'\\\`\\s<>]*`,
"i"
)
);
return urlMatch?.[0].replaceAll("&amp;", "&") ?? "";
}
function mergeRedirectSetCookie(responseDetails: {
headers?: Record<string, string | string[]>;
}) {
appendCookie(responseDetails.headers?.["set-cookie"]);
}
export async function requestShopLoginPage() {
const response = await axios.get("https://eclogin.cafe24.com/Shop/", {
headers: {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"cache-control": "no-cache",
cookie,
pragma: "no-cache",
priority: "u=0, i",
referer: "https://www.cafe24.com/",
"sec-ch-ua":
'"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-site",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
"user-agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
},
});
appendCookie(response.headers["set-cookie"]);
return response;
}
export async function requestMallUseAuth() {
const form = new URLSearchParams();
form.set("url", "MallUseAuth");
form.set("login_mode", "1");
form.set("mobile", "F");
form.set("onnode", "");
form.set("menu", "");
form.set("submenu", "");
form.set("mode", "");
form.set("c_name", "");
form.set("loan_type", "");
form.set("addsvc_suburl", "");
form.set("appID", "");
form.set("userid", activeAccount.mallId);
form.set("EncData", "");
form.set("EncKey", "");
form.set("loginId", activeAccount.mallId);
form.set("loginPasswd", activeAccount.password);
const response = await axios.post<MallUseAuthResponse>(
"https://eclogin.cafe24.com/Shop/",
form,
{
params: {
url: "MallUseAuth",
},
headers: {
accept: "application/json, text/javascript, */*; q=0.01",
"accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"cache-control": "no-cache",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
cookie,
origin: "https://eclogin.cafe24.com",
pragma: "no-cache",
priority: "u=1, i",
referer: "https://eclogin.cafe24.com/Shop/",
"sec-ch-ua":
'"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
"x-requested-with": "XMLHttpRequest",
},
}
);
appendCookie(response.headers["set-cookie"]);
return response;
}
export async function requestComLogin(auth: MallUseAuthResponse) {
const form = new URLSearchParams();
form.set("url", "Run");
form.set("login_mode", "1");
form.set("mobile", "F");
form.set("onnode", "");
form.set("menu", "");
form.set("submenu", "");
form.set("mode", "");
form.set("c_name", "");
form.set("loan_type", "");
form.set("addsvc_suburl", "");
form.set("appID", "");
form.set("userid", activeAccount.mallId);
form.set("EncData", auth.sEncData);
form.set("EncKey", auth.sEncKey);
form.set("loginId", activeAccount.mallId);
form.set("loginPasswd", activeAccount.password);
const response = await axios.post(auth.sActionPath, form, {
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400,
headers: {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"cache-control": "no-cache",
"content-type": "application/x-www-form-urlencoded",
cookie,
origin: "https://eclogin.cafe24.com",
pragma: "no-cache",
priority: "u=0, i",
referer: "https://eclogin.cafe24.com/",
"sec-ch-ua":
'"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-site",
"upgrade-insecure-requests": "1",
"user-agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
},
});
appendCookie(response.headers["set-cookie"]);
return response;
}
export async function requestUserIdCheck(html: string) {
const userIdCheckUrl = extractUserIdCheckUrl(html);
if (!userIdCheckUrl) {
throw new Error("user_id_check.php URL을 찾지 못했습니다.");
}
const response = await axios.get<string>(userIdCheckUrl, {
beforeRedirect: (_options, responseDetails) => {
mergeRedirectSetCookie(responseDetails);
_options.headers.cookie = cookie;
},
headers: {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"cache-control": "no-cache",
cookie,
pragma: "no-cache",
priority: "u=0, i",
referer: "https://user.cafe24.com/",
"sec-ch-ua":
'"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-site",
"upgrade-insecure-requests": "1",
"user-agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
},
});
appendCookie(response.headers["set-cookie"]);
return response;
}
export async function requestMemberAdminList() {
const response = await axios.get<string>(
`${getAdminBaseUrl()}${getShopPath("/c/member_admin_l.php", "1")}`,
{
validateStatus: (status) => status >= 200 && status < 500,
headers: {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"cache-control": "no-cache",
cookie,
pragma: "no-cache",
priority: "u=0, i",
referer: `${getAdminBaseUrl()}${getShopPath("/c/center.php", "1")}`,
"sec-ch-ua":
'"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-origin",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
"user-agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
},
}
);
const dynamicHiddenField = extractDynamicHiddenField(response.data);
dynamicHiddenFieldName = dynamicHiddenField.name;
dynamicHiddenFieldValue = dynamicHiddenField.value;
return response;
}
export async function requestShop2MemberAdminList() {
const response = await axios.get<string>(
`${getAdminBaseUrl()}${getShopPath("/c/member_admin_l.php")}`,
{
validateStatus: (status) => status >= 200 && status < 500,
headers: {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"cache-control": "no-cache",
cookie,
pragma: "no-cache",
priority: "u=0, i",
referer: `${getAdminBaseUrl()}${getShopPath("/c/center.php")}`,
"sec-ch-ua":
'"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-origin",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
"user-agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
},
}
);
appendCookie(response.headers["set-cookie"]);
const dynamicHiddenField = extractDynamicHiddenField(response.data);
dynamicHiddenFieldName = dynamicHiddenField.name;
dynamicHiddenFieldValue = dynamicHiddenField.value;
return response;
}
export async function requestShop2MemberAdminSearch() {
const form = new URLSearchParams();
form.set("mode", "search");
form.set("isStandardMode", "");
form.set("m_mode", "");
form.set("is_cti", "");
form.set("ord", "regist_date");
form.set("sort", "ASC");
form.set("page", "1");
form.set("rows", "1000");
form.set("excel_private_auth", "T");
form.set("mg_mode", "");
form.set("is_change_membergrade_sms", "F");
form.set("sSmsMemberGradeManualAuthCustomer", "T");
form.set("mg_group_no_fix_flag", "");
form.set(dynamicHiddenFieldName, dynamicHiddenFieldValue);
form.set("search_type", "member_id");
form.set("type", "");
form.set("grp_sel", "0");
form.set("group_no", "");
form.set("is_member_auth", "0");
form.set("input_channel", "");
form.set("entry_path_group", "");
form.set("entry_path", "");
form.set("day_type", "1");
form.set("regist_start_date", regDate);
form.set("regist_end_date", regDate);
form.set("mem_start_date", "04-25");
form.set("mem_end_date", "04-25");
form.set("age1", "");
form.set("age2", "");
form.set("gender", "1");
form.set("sales_amount", "1");
form.set("sales_type", "");
form.set("min_sales_amount", "");
form.set("max_sales_amount", "");
form.set("ord_date_kind", "order_date");
form.set("ord_start_date", "");
form.set("ord_end_date", "");
form.set("iOrderPrdtNo", "");
form.set("sOrderPrdtName", "");
form.set("login_start_date", "");
form.set("login_end_date", "");
form.set("visit_ip", "");
form.set("s_join_cnt", "");
form.set("e_join_cnt", "");
form.set("s_attend_cnt", "");
form.set("e_attend_cnt", "");
form.set("is_marry", "1");
form.set("child", "1");
form.set("is_sms", "1");
form.set("is_news_mail", "1");
form.set("phone", "");
form.set("mobile", "");
form.set("region", "region_00");
form.set("mileage_type", "avail_mileage");
form.set("mileage1", "");
form.set("mileage2", "");
form.set("start_restore_datetime", "");
form.set("end_restore_datetime", "");
form.set("mid_list[]", "");
form.set("group_list[]", "");
form.set("member_name[]", "");
form.set("mg_group_no", activeAccount.groupNo);
form.set("group_no_b", activeAccount.groupNo);
await Bun.write(
getResultPath(`${getResultPrefix()}-request.txt`),
form.toString()
);
const response = await axios.post<string>(
`${getAdminBaseUrl()}${getShopPath("/c/member_admin_l.php")}`,
form,
{
validateStatus: (status) => status >= 200 && status < 500,
headers: {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"cache-control": "no-cache",
"content-type": "application/x-www-form-urlencoded",
cookie,
origin: getAdminBaseUrl(),
pragma: "no-cache",
priority: "u=0, i",
referer: `${getAdminBaseUrl()}${getShopPath("/c/member_admin_l.php")}`,
"sec-ch-ua":
'"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-origin",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
"user-agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
},
}
);
appendCookie(response.headers["set-cookie"]);
return response;
}
function parseAccountLine(line: string, lineNo: number): Account {
const values = line.includes("\t")
? line.split("\t")
: line.includes("|")
? line.split("|")
: line.includes(",")
? line.split(",")
: line.trim().split(/\s+/);
const [mallId, password, groupNo, division] = values.map((value) =>
value.trim()
);
if (!mallId || !password || !groupNo || !division) {
throw new Error(
`${lineNo}번째 줄 형식이 잘못됐습니다. 아이디, 비번, 그룹넘버, 구분 순서로 넣어주세요.`
);
}
return {
mallId,
password,
groupNo,
division,
shopNo: groupNo,
};
}
async function readAccounts(path: string) {
const text = await Bun.file(path).text();
return text
.split(/\r?\n/)
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
.filter(({ line }) => line && !line.startsWith("#"))
.map(({ line, lineNo }) => parseAccountLine(line, lineNo));
}
async function runAccount(account: Account) {
activeAccount = account;
cookie = INITIAL_COOKIE;
dynamicHiddenFieldName = "";
dynamicHiddenFieldValue = "";
await requestShopLoginPage();
const authResponse = await requestMallUseAuth();
const loginResponse = await requestComLogin(authResponse.data);
await requestUserIdCheck(loginResponse.data);
const memberAdminResponse = await requestMemberAdminList();
await requestShop2MemberAdminList();
const shop2MemberAdminResponse = await requestShop2MemberAdminSearch();
const shop2ResponsePath = getResultPath(`${getResultPrefix()}-response.html`);
let members: any = extractMembers(shop2MemberAdminResponse.data);
console.log("추출행:", members.length);
if (members.length === 0) {
return;
}
const cafe24 = new HyundaiCustomerVerifier({});
await cafe24.init();
// members = take(members, 5);
members = await cafe24.lookupPhones(members);
console.log("작업행:", members.length);
const appendResult = await appendRowsToDailySheet({
rows: members,
sheetName: account.division,
baseDate: regDate,
});
console.log(
`구글 시트 추가 완료: ${appendResult.targetSheetTitle} / ${appendResult.appendedRows}`
);
await Bun.write(shop2ResponsePath, shop2MemberAdminResponse.data);
return {
mallId: account.mallId,
groupNo: account.groupNo,
division: account.division,
shopNo: account.shopNo,
status: memberAdminResponse.status,
shopStatus: shop2MemberAdminResponse.status,
responsePath: shop2ResponsePath,
requestPath: getResultPath(`${getResultPrefix()}-request.txt`),
dynamicHiddenFieldName,
};
}
await mkdir(RESULT_DIR, { recursive: true });
const accounts = await readAccounts(accountsPath);
const results = [];
for (const account of accounts) {
console.log(
`${account.mallId} / shop${account.shopNo} / group ${account.groupNo} 시작`
);
results.push(await runAccount(account));
}
await Bun.write(
getResultPath("results.json"),
JSON.stringify(results, null, 2)
);
console.log(results);
function decodeHtml(value: string) {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
.replace(/&#x([0-9a-f]+);/gi, (_, code) =>
String.fromCharCode(parseInt(code, 16))
);
}
function normalizeText(value: string) {
return decodeHtml(value.replace(/<[^>]*>/g, " "))
.replace(/\s+/g, " ")
.trim();
}
function getInputValue(rowHtml: string, name: string) {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(
`<input\\b(?=[^>]*\\bname=["']${escapedName}["'])[^>]*\\bvalue=["']([^"']*)["'][^>]*>`,
"i"
);
return decodeHtml(rowHtml.match(pattern)?.[1] ?? "").trim();
}
function getCellText(rowHtml: string, index: number) {
const cells = [...rowHtml.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)];
return normalizeText(cells[index]?.[1] ?? "");
}
export function extractMembers(html: string): MemberRow[] {
const tbodyHtml =
html.match(/<tbody class=["']center["'][^>]*>([\s\S]*?)<\/tbody>/i)?.[1] ??
"";
const rows = tbodyHtml.match(/<tr\b[\s\S]*?(?=<tr\b|$)/gi) ?? [];
return rows
.filter((rowHtml) => rowHtml.includes('name="mid_list[]"'))
.map((rowHtml) => {
const landline = getCellText(rowHtml, 5);
const mobile = getCellText(rowHtml, 6);
return {
id: getInputValue(rowHtml, "mid_list[]"),
full_name: getInputValue(rowHtml, "member_name[]"),
created_time: getCellText(rowHtml, 1),
phone_number: mobile || landline,
};
});
}
type MemberRow = {
id: string;
full_name: string;
created_time: string;
phone_number: string;
};

932
google/index.ts Normal file
View File

@@ -0,0 +1,932 @@
import { mkdir } from "node:fs/promises";
import { HyundaiCustomerVerifier } from "../hyundai/hyundaiCustomerVerifier";
import { take } from "es-toolkit";
const GOOGLE_AUTH_SCOPE = "https://www.googleapis.com/auth/spreadsheets";
const TOKEN_PATH = ".tokens/google-oauth-token.json";
const SHEET_URLS_PATH = "google/sheetUrls.txt";
const DEFAULT_REDIRECT_PORT = 3487;
const CALLBACK_PATH = "/oauth2callback";
const DEFAULT_TARGET_SHEET_URL =
"https://docs.google.com/spreadsheets/d/1LGF93SGkrIqqniY-a3ReTPhizsEMtgm5QBC8tSALmVM/edit?gid=0#gid=0";
const TARGET_HEADERS = [
"id",
"created_time",
"campaign_name",
"form_name",
"platform",
"full_name",
"phone_number",
"site",
"created_at",
"activation_channel",
"customer_group",
"sales_point",
"contact_number",
"primary_product",
"status",
"key",
] as const;
type TokenResponse = {
access_token: string;
expires_in: number;
refresh_token?: string;
scope: string;
token_type: string;
};
type SavedToken = {
access_token: string;
expiry_date: number;
refresh_token: string;
scope: string;
token_type: string;
};
type SpreadsheetMetadata = {
sheets?: Array<{
properties?: {
sheetId?: number;
title?: string;
};
}>;
};
type SheetValuesResponse = {
range?: string;
majorDimension?: string;
values?: string[][];
};
export type RowObject = Record<string, string>;
type ValueRangePayload = {
range: string;
majorDimension: "ROWS";
values: string[][];
};
type BatchUpdateSpreadsheetRequest = {
requests: Array<{
addSheet?: {
properties?: {
title?: string;
};
};
}>;
};
const TARGET_HEADER_ALIASES: Record<(typeof TARGET_HEADERS)[number], string[]> =
{
id: ["id"],
created_time: ["created_time"],
campaign_name: ["campaign_name", "campaign_name"],
form_name: ["form_name"],
platform: ["platform"],
full_name: ["full_name"],
phone_number: ["phone_number"],
site: ["site"],
created_at: ["접수일자"],
activation_channel: ["개통처"],
customer_group: ["고객그룹"],
sales_point: ["판매점"],
contact_number: ["phone_number"],
primary_product: ["1차 상품"],
status: ["진행상태"],
key: ["고객키"],
};
function readRequiredEnv(name: string): string {
const value = Bun.env[name]?.trim();
if (!value) {
throw new Error(`${name} 환경변수가 필요합니다.`);
}
return value;
}
function parseSpreadsheetUrl(input: string) {
const url = new URL(input);
const match = url.pathname.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
if (!match?.[1]) {
throw new Error("유효한 구글 시트 URL이 아닙니다.");
}
const spreadsheetId = match[1];
const gidValue =
url.searchParams.get("gid") ?? url.hash.match(/gid=(\d+)/)?.[1] ?? null;
const gid = gidValue ? Number(gidValue) : null;
if (gidValue && Number.isNaN(gid)) {
throw new Error("gid 값을 해석하지 못했습니다.");
}
return { spreadsheetId, gid };
}
function escapeSheetTitle(title: string): string {
return `'${title.replaceAll("'", "''")}'`;
}
type SourceSheetEntry = {
sheetName: string;
sheetUrl: string;
};
async function readSourceSheetEntries(): Promise<SourceSheetEntry[]> {
const content = await Bun.file(SHEET_URLS_PATH).text();
return content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"))
.map((line) => {
const [sheetUrl, ...sheetNameParts] = line.split(/\s+/);
const sheetName = sheetNameParts.join(" ").trim();
if (!sheetUrl || !sheetName) {
throw new Error(
`sheetUrls.txt 형식이 잘못되었습니다: "${line}". "sheetUrl sheetName" 형식이어야 합니다.`
);
}
return {
sheetUrl,
sheetName,
};
});
}
async function readSavedToken(): Promise<SavedToken | null> {
const tokenFile = Bun.file(TOKEN_PATH);
if (!(await tokenFile.exists())) {
return null;
}
return (await tokenFile.json()) as SavedToken;
}
async function saveToken(token: SavedToken) {
await mkdir(".tokens", { recursive: true });
await Bun.write(TOKEN_PATH, JSON.stringify(token, null, 2));
}
async function refreshAccessToken(
clientId: string,
clientSecret: string,
refreshToken: string
): Promise<SavedToken> {
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`토큰 갱신 실패: ${response.status} ${text}`);
}
const token = (await response.json()) as TokenResponse;
return {
access_token: token.access_token,
expiry_date: Date.now() + token.expires_in * 1000,
refresh_token: refreshToken,
scope: token.scope,
token_type: token.token_type,
};
}
async function requestNewToken(
clientId: string,
clientSecret: string
): Promise<SavedToken> {
const redirectPort = Number(
Bun.env.GOOGLE_REDIRECT_PORT ?? DEFAULT_REDIRECT_PORT
);
const redirectUri = `http://127.0.0.1:${redirectPort}${CALLBACK_PATH}`;
const state = crypto.randomUUID();
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", GOOGLE_AUTH_SCOPE);
authUrl.searchParams.set("access_type", "offline");
authUrl.searchParams.set("prompt", "consent");
authUrl.searchParams.set("state", state);
let resolveCode!: (value: string) => void;
let rejectCode!: (reason?: unknown) => void;
const codePromise = new Promise<string>((resolve, reject) => {
resolveCode = resolve;
rejectCode = reject;
});
const server = Bun.serve({
port: redirectPort,
fetch(request) {
const url = new URL(request.url);
if (url.pathname !== CALLBACK_PATH) {
return new Response("Not found", { status: 404 });
}
const returnedState = url.searchParams.get("state");
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
if (error) {
rejectCode(new Error(`OAuth 인증 실패: ${error}`));
return new Response(
"OAuth 인증이 취소되었습니다. 터미널을 확인해 주세요.",
{ status: 400 }
);
}
if (returnedState !== state || !code) {
rejectCode(new Error("OAuth 콜백 검증에 실패했습니다."));
return new Response("잘못된 OAuth 콜백입니다.", { status: 400 });
}
resolveCode(code);
return new Response("인증이 완료되었습니다. 터미널로 돌아가 주세요.");
},
});
console.log("브라우저에서 Google OAuth 인증을 진행해 주세요.");
console.log(authUrl.toString());
try {
Bun.file(authUrl.toString());
} catch {
// URL을 이미 출력했기 때문에 open 실패는 무시합니다.
}
let code: string;
try {
code = await Promise.race([
codePromise,
new Promise<string>((_, reject) => {
setTimeout(
() => reject(new Error("OAuth 인증 대기 시간이 초과되었습니다.")),
5 * 60 * 1000
);
}),
]);
} finally {
server.stop(true);
}
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`OAuth 토큰 발급 실패: ${response.status} ${text}`);
}
const token = (await response.json()) as TokenResponse;
if (!token.refresh_token) {
throw new Error(
"refresh_token 을 받지 못했습니다. OAuth 클라이언트 설정을 확인해 주세요."
);
}
return {
access_token: token.access_token,
expiry_date: Date.now() + token.expires_in * 1000,
refresh_token: token.refresh_token,
scope: token.scope,
token_type: token.token_type,
};
}
async function getAuthorizedToken() {
const clientId = readRequiredEnv("GOOGLE_CLIENT_ID");
const clientSecret = readRequiredEnv("GOOGLE_CLIENT_SECRET");
const savedToken = await readSavedToken();
const hasWriteScope = savedToken?.scope?.includes(GOOGLE_AUTH_SCOPE) ?? false;
if (savedToken?.refresh_token && hasWriteScope) {
const needsRefresh = savedToken.expiry_date <= Date.now() + 60_000;
if (!needsRefresh) {
return savedToken;
}
const refreshed = await refreshAccessToken(
clientId,
clientSecret,
savedToken.refresh_token
);
await saveToken(refreshed);
return refreshed;
}
if (savedToken?.refresh_token && !hasWriteScope) {
console.log(
"기존 토큰이 읽기 전용 scope 입니다. 브라우저에서 다시 OAuth 인증을 진행합니다."
);
}
const newToken = await requestNewToken(clientId, clientSecret);
await saveToken(newToken);
return newToken;
}
async function googleApiFetch<T>(accessToken: string, url: string): Promise<T> {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Google API 호출 실패: ${response.status} ${text}`);
}
return (await response.json()) as T;
}
async function googleApiRequest<T>(
accessToken: string,
url: string,
init?: RequestInit
): Promise<T> {
const response = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${accessToken}`,
...(init?.headers ?? {}),
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Google API 호출 실패: ${response.status} ${text}`);
}
return (await response.json()) as T;
}
async function getSheetTitleByGid(
accessToken: string,
spreadsheetId: string,
gid: number | null
): Promise<string> {
const metadata = await googleApiFetch<SpreadsheetMetadata>(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}?fields=sheets.properties(sheetId,title)`
);
const sheets = metadata.sheets ?? [];
if (sheets.length === 0) {
throw new Error("스프레드시트에 시트 탭이 없습니다.");
}
if (gid == null) {
const firstTitle = sheets[0]?.properties?.title;
if (!firstTitle) {
throw new Error("첫 번째 시트의 이름을 찾지 못했습니다.");
}
return firstTitle;
}
const matched = sheets.find((sheet) => sheet.properties?.sheetId === gid)
?.properties?.title;
if (!matched) {
throw new Error(`gid=${gid} 에 해당하는 시트 탭을 찾지 못했습니다.`);
}
return matched;
}
async function hasSheetTitle(
accessToken: string,
spreadsheetId: string,
sheetTitle: string
): Promise<boolean> {
const metadata = await googleApiFetch<SpreadsheetMetadata>(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}?fields=sheets.properties(title)`
);
return (
metadata.sheets?.some((sheet) => sheet.properties?.title === sheetTitle) ??
false
);
}
async function createSheet(
accessToken: string,
spreadsheetId: string,
sheetTitle: string
) {
const body: BatchUpdateSpreadsheetRequest = {
requests: [
{
addSheet: {
properties: {
title: sheetTitle,
},
},
},
],
};
return googleApiRequest(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}:batchUpdate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
}
async function ensureSheetExists(
accessToken: string,
spreadsheetId: string,
sheetTitle: string
) {
const exists = await hasSheetTitle(accessToken, spreadsheetId, sheetTitle);
if (exists) {
return;
}
await createSheet(accessToken, spreadsheetId, sheetTitle);
}
function buildRange(sheetTitle: string, userRange?: string): string {
if (!userRange) {
return escapeSheetTitle(sheetTitle);
}
if (userRange.includes("!")) {
return userRange;
}
return `${escapeSheetTitle(sheetTitle)}!${userRange}`;
}
function formatKstDateTime(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const date = new Date(trimmed);
if (Number.isNaN(date.getTime())) {
return value;
}
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
.formatToParts(date)
.reduce<Record<string, string>>((acc, part) => {
if (part.type !== "literal") {
acc[part.type] = part.value;
}
return acc;
}, {});
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
}
function formatPhoneNumber(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const normalized = trimmed.replace(/^p:/i, "").replace(/[^\d+]/g, "");
let digits = normalized;
if (digits.startsWith("+82")) {
digits = `0${digits.slice(3)}`;
} else if (digits.startsWith("82")) {
digits = `0${digits.slice(2)}`;
}
const phoneDigits = digits.replace(/\D/g, "");
if (phoneDigits.length === 10 && phoneDigits.startsWith("10")) {
return `010-${phoneDigits.slice(2, 6)}-${phoneDigits.slice(6)}`;
}
if (phoneDigits.length === 11) {
return `${phoneDigits.slice(0, 3)}-${phoneDigits.slice(3, 7)}-${phoneDigits.slice(7)}`;
}
return value;
}
function normalizeCellValue(header: string, value: string): string {
const normalizedHeader = normalizeHeader(header);
if (normalizedHeader === "created_time") {
return formatKstDateTime(value);
}
if (normalizedHeader === "phone_number") {
return formatPhoneNumber(value);
}
return value;
}
function formatKstDateString(date: Date): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.formatToParts(date)
.reduce<Record<string, string>>((acc, part) => {
if (part.type !== "literal") {
acc[part.type] = part.value;
}
return acc;
}, {});
return `${parts.year}-${parts.month}-${parts.day}`;
}
function getTodayKstDateString(): string {
return formatKstDateString(new Date());
}
function isValidKstDateString(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return false;
}
return formatKstDateString(new Date(`${value}T00:00:00+09:00`)) === value;
}
function filterRowsByCreatedDate(rows: RowObject[], filterDate: string): RowObject[] {
return rows.filter((row) => row.created_time?.startsWith(filterDate));
}
function getKstMonthDayString(dateString: string): string {
return dateString.slice(5).replace("-", "");
}
type RunOptions = {
filterDate: string;
sourceRange?: string;
targetSheetUrl: string;
};
function parseRunOptions(argv: string[]): RunOptions {
const args = argv.slice(2);
const positionalArgs: string[] = [];
let filterDate = Bun.env.GOOGLE_FILTER_DATE?.trim() || undefined;
let sourceRange = Bun.env.GOOGLE_SHEET_RANGE?.trim() || undefined;
let targetSheetUrl =
Bun.env.GOOGLE_TARGET_SHEET_URL?.trim() || DEFAULT_TARGET_SHEET_URL;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--date") {
const nextArg = args[index + 1]?.trim();
if (!nextArg) {
throw new Error("--date 옵션에는 YYYY-MM-DD 값을 함께 넣어야 합니다.");
}
filterDate = nextArg;
index += 1;
continue;
}
if (arg.startsWith("--date=")) {
filterDate = arg.slice("--date=".length).trim();
if (!filterDate) {
throw new Error("--date 옵션에는 YYYY-MM-DD 값을 함께 넣어야 합니다.");
}
continue;
}
positionalArgs.push(arg);
}
if (positionalArgs[0]) {
sourceRange = positionalArgs[0];
}
if (positionalArgs[1]) {
targetSheetUrl = positionalArgs[1];
}
const resolvedFilterDate = filterDate || getTodayKstDateString();
if (!isValidKstDateString(resolvedFilterDate)) {
throw new Error(
`추출 날짜 형식이 잘못되었습니다: "${resolvedFilterDate}". YYYY-MM-DD 형식으로 입력해 주세요.`
);
}
return {
filterDate: resolvedFilterDate,
sourceRange,
targetSheetUrl,
};
}
function rowsToObjects(values: string[][]): RowObject[] {
const [headerRow, ...dataRows] = values;
if (!headerRow || headerRow.length === 0) {
return [];
}
return dataRows.map((row) => {
const entry: RowObject = {};
headerRow.forEach((rawHeader, index) => {
const fallbackHeader = `column_${index + 1}`;
const header = rawHeader?.trim() || fallbackHeader;
entry[header] = normalizeCellValue(header, row[index] ?? "");
});
return entry;
});
}
function normalizeHeader(header: string): string {
return header.trim().toLowerCase().replaceAll(/\s+/g, "_");
}
function buildHeaderLookup(headers: string[]): Map<string, string> {
const lookup = new Map<string, string>();
headers.forEach((header, index) => {
const trimmed = header.trim() || `column_${index + 1}`;
lookup.set(trimmed, trimmed);
lookup.set(normalizeHeader(trimmed), trimmed);
});
return lookup;
}
function mapRowsForTarget(
rows: RowObject[],
sourceHeaders: string[]
): string[][] {
const lookup = buildHeaderLookup(sourceHeaders);
return rows.map((row) =>
TARGET_HEADERS.map((targetHeader) => {
const directValue = row[targetHeader];
if (directValue != null) {
return directValue;
}
const mappedSourceHeader =
lookup.get(targetHeader) ?? lookup.get(normalizeHeader(targetHeader));
if (mappedSourceHeader) {
return row[mappedSourceHeader] ?? "";
}
const aliases = TARGET_HEADER_ALIASES[targetHeader] ?? [];
for (const alias of aliases) {
if (row[alias] != null) {
return row[alias] ?? "";
}
const aliasHeader =
lookup.get(alias) ?? lookup.get(normalizeHeader(alias));
if (aliasHeader) {
return row[aliasHeader] ?? "";
}
}
return "";
})
);
}
async function getSheetValues(
accessToken: string,
spreadsheetId: string,
range: string
): Promise<SheetValuesResponse> {
const encodedRange = encodeURIComponent(range);
return googleApiFetch<SheetValuesResponse>(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedRange}`
);
}
async function updateSheetValues(
accessToken: string,
spreadsheetId: string,
range: string,
values: string[][]
) {
const encodedRange = encodeURIComponent(range);
const body: ValueRangePayload = {
range,
majorDimension: "ROWS",
values,
};
return googleApiRequest(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedRange}?valueInputOption=USER_ENTERED`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
}
async function appendSheetValues(
accessToken: string,
spreadsheetId: string,
range: string,
values: string[][]
) {
const encodedRange = encodeURIComponent(range);
const body: ValueRangePayload = {
range,
majorDimension: "ROWS",
values,
};
return googleApiRequest(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedRange}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
}
async function ensureTargetHeaderRow(
accessToken: string,
spreadsheetId: string,
sheetTitle: string
) {
const headerRange = `${escapeSheetTitle(sheetTitle)}!1:1`;
const current = await getSheetValues(accessToken, spreadsheetId, headerRange);
const existingHeaders = current.values?.[0] ?? [];
const hasSameHeaders =
existingHeaders.length === TARGET_HEADERS.length &&
TARGET_HEADERS.every((header, index) => existingHeaders[index] === header);
if (hasSameHeaders) {
return;
}
await updateSheetValues(accessToken, spreadsheetId, headerRange, [
Array.from(TARGET_HEADERS),
]);
}
async function processSourceSheet(
token: SavedToken,
targetSheet: ReturnType<typeof parseSpreadsheetUrl>,
filterDate: string,
sourceRange: string | undefined,
cafe24: HyundaiCustomerVerifier,
entry: SourceSheetEntry
) {
const sourceSheet = parseSpreadsheetUrl(entry.sheetUrl);
const sourceSheetTitle = await getSheetTitleByGid(
token.access_token,
sourceSheet.spreadsheetId,
sourceSheet.gid
);
const range = buildRange(sourceSheetTitle, sourceRange);
const sourceValues = await getSheetValues(
token.access_token,
sourceSheet.spreadsheetId,
range
);
const sourceRows = sourceValues.values ?? [];
console.log(`sourceSpreadsheetId: ${sourceSheet.spreadsheetId}`);
console.log(`sourceSheetTitle: ${sourceSheetTitle}`);
console.log(`sourceRange: ${sourceValues.range ?? range}`);
console.log("");
if (sourceRows.length === 0) {
console.log("가져온 데이터가 없습니다.");
return;
}
const [headerRow] = sourceRows;
let rows = rowsToObjects(sourceRows);
rows = filterRowsByCreatedDate(rows, filterDate);
// rows = take(rows, 5);
console.log(`filterDate: ${filterDate}`);
console.log("행갯수:", rows.length);
rows = await cafe24.lookupPhones(rows);
const mappedRows = mapRowsForTarget(rows, headerRow ?? []);
const datedTargetSheetTitle = `${entry.sheetName}${getKstMonthDayString(filterDate)}`;
// console.log("sourceHeaders:");
// console.log(JSON.stringify(headerRow ?? [], null, 2));
// console.log("");
// console.log("mappedPreview:");
// console.log(JSON.stringify(mappedRows.slice(0, 3), null, 2));
// console.log("");
if (mappedRows.length === 0) {
console.log("대상 시트에 추가할 데이터 행이 없습니다.");
return;
}
await ensureSheetExists(
token.access_token,
targetSheet.spreadsheetId,
datedTargetSheetTitle
);
await ensureTargetHeaderRow(
token.access_token,
targetSheet.spreadsheetId,
datedTargetSheetTitle
);
await appendSheetValues(
token.access_token,
targetSheet.spreadsheetId,
escapeSheetTitle(datedTargetSheetTitle),
mappedRows
);
console.log(`targetSpreadsheetId: ${targetSheet.spreadsheetId}`);
console.log(`targetSheetTitle: ${datedTargetSheetTitle}`);
console.log(`appendedRows: ${mappedRows.length}`);
}
async function main() {
const { filterDate, sourceRange, targetSheetUrl } = parseRunOptions(Bun.argv);
const entries = await readSourceSheetEntries();
if (entries.length === 0) {
throw new Error("sheetUrls.txt 에 처리할 시트가 없습니다.");
}
const targetSheet = parseSpreadsheetUrl(targetSheetUrl);
const token = await getAuthorizedToken();
const cafe24 = new HyundaiCustomerVerifier({});
await cafe24.init();
for (const entry of entries) {
await processSourceSheet(
token,
targetSheet,
filterDate,
sourceRange,
cafe24,
entry
);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});

2
google/sheetUrls.txt Normal file
View File

@@ -0,0 +1,2 @@
https://docs.google.com/spreadsheets/d/1R7RHt84qel7laqH59crVbkVCNtcrcBXoJ8T0n5jb4pw/edit?gid=0#gid=0 블루맥스구글
https://docs.google.com/spreadsheets/d/1RO7WHoOMO0EmQB_5kfeW64XjKDHLRM2acmAhowQNvVU/edit?gid=0#gid=0 룰루뷰구글

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;
}
}

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "googlesheet",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest",
"@types/qs": "^6.15.0",
"cheerio": "^1.2.0"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@types/node": "^25.6.0",
"axios": "^1.15.2",
"es-toolkit": "^1.46.0",
"qs": "^6.15.1"
}
}

View File

@@ -0,0 +1 @@
mode=search&isStandardMode=&m_mode=&is_cti=&ord=regist_date&sort=ASC&page=1&rows=1000&excel_private_auth=T&mg_mode=&is_change_membergrade_sms=F&sSmsMemberGradeManualAuthCustomer=T&mg_group_no_fix_flag=&b5fc=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJtIjoiYmx1ZW1heHdpdGgiLCJzIjoiNmZmZjQ3OGY3NmE4MjY0MzM1ODY2MmI2NzcwMDgyMjIiLCJ0IjoxNzc4NTMxNzg1LCJyIjoiYmx1ZW1heHdpdGguY2FmZTI0LmNvbSJ9.J6WhqHM9OuNp9qBQmHMmwpvfYmij2DpSVbT8slHREAlh0ThTX-JlpOgwbBORo_eu1vjd3_9dreddoqZbG05qzQ&search_type=member_id&type=&grp_sel=0&group_no=&is_member_auth=0&input_channel=&entry_path_group=&entry_path=&day_type=1&regist_start_date=2026-05-11&regist_end_date=2026-05-11&mem_start_date=04-25&mem_end_date=04-25&age1=&age2=&gender=1&sales_amount=1&sales_type=&min_sales_amount=&max_sales_amount=&ord_date_kind=order_date&ord_start_date=&ord_end_date=&iOrderPrdtNo=&sOrderPrdtName=&login_start_date=&login_end_date=&visit_ip=&s_join_cnt=&e_join_cnt=&s_attend_cnt=&e_attend_cnt=&is_marry=1&child=1&is_sms=1&is_news_mail=1&phone=&mobile=&region=region_00&mileage_type=avail_mileage&mileage1=&mileage2=&start_restore_datetime=&end_restore_datetime=&mid_list%5B%5D=&group_list%5B%5D=&member_name%5B%5D=&mg_group_no=1&group_no_b=1

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
mode=search&isStandardMode=&m_mode=&is_cti=&ord=regist_date&sort=ASC&page=1&rows=1000&excel_private_auth=T&mg_mode=&is_change_membergrade_sms=F&sSmsMemberGradeManualAuthCustomer=T&mg_group_no_fix_flag=&4bdf=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJtIjoiZ2VvbnVrMDkwMSIsInMiOiI0MDc3MDUyNTNkMGI2NGZkMTE3NDkwMDkwOTA0ZjNlOSIsInQiOjE3Nzg1MzE3NzksInIiOiJnZW9udWswOTAxLmNhZmUyNC5jb20ifQ.tjzK0ue58tDuxsQkyQOebSVTubSE_IxOwrBIL_xh9urzSiEo4z8OK2Zpn5PmE_LeO_ruTv39y9ncz5IZhl_dGA&search_type=member_id&type=&grp_sel=0&group_no=&is_member_auth=0&input_channel=&entry_path_group=&entry_path=&day_type=1&regist_start_date=2026-05-11&regist_end_date=2026-05-11&mem_start_date=04-25&mem_end_date=04-25&age1=&age2=&gender=1&sales_amount=1&sales_type=&min_sales_amount=&max_sales_amount=&ord_date_kind=order_date&ord_start_date=&ord_end_date=&iOrderPrdtNo=&sOrderPrdtName=&login_start_date=&login_end_date=&visit_ip=&s_join_cnt=&e_join_cnt=&s_attend_cnt=&e_attend_cnt=&is_marry=1&child=1&is_sms=1&is_news_mail=1&phone=&mobile=&region=region_00&mileage_type=avail_mileage&mileage1=&mileage2=&start_restore_datetime=&end_restore_datetime=&mid_list%5B%5D=&group_list%5B%5D=&member_name%5B%5D=&mg_group_no=1&group_no_b=1

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
mode=search&isStandardMode=&m_mode=&is_cti=&ord=regist_date&sort=ASC&page=1&rows=1000&excel_private_auth=T&mg_mode=&is_change_membergrade_sms=F&sSmsMemberGradeManualAuthCustomer=T&mg_group_no_fix_flag=&6572=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJtIjoiZ2VvbnVrMDkwMSIsInMiOiIxNDI4OGQ5ZDE0Y2UwODM4YTE5Y2ViMjA0YzM3YTU4OCIsInQiOjE3Nzg1MzE3NTgsInIiOiJnZW9udWswOTAxLmNhZmUyNC5jb20ifQ.7sGUSOdtNFCFVzcrssaaFCBtaccXupuZ3Ee-EL4jKbsc0m2IU4-0myZ9Ag-vgbT_7GCSUcGm2z9_Agc9VhZwZg&search_type=member_id&type=&grp_sel=0&group_no=&is_member_auth=0&input_channel=&entry_path_group=&entry_path=&day_type=1&regist_start_date=2026-05-11&regist_end_date=2026-05-11&mem_start_date=04-25&mem_end_date=04-25&age1=&age2=&gender=1&sales_amount=1&sales_type=&min_sales_amount=&max_sales_amount=&ord_date_kind=order_date&ord_start_date=&ord_end_date=&iOrderPrdtNo=&sOrderPrdtName=&login_start_date=&login_end_date=&visit_ip=&s_join_cnt=&e_join_cnt=&s_attend_cnt=&e_attend_cnt=&is_marry=1&child=1&is_sms=1&is_news_mail=1&phone=&mobile=&region=region_00&mileage_type=avail_mileage&mileage1=&mileage2=&start_restore_datetime=&end_restore_datetime=&mid_list%5B%5D=&group_list%5B%5D=&member_name%5B%5D=&mg_group_no=2&group_no_b=2

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
mode=search&isStandardMode=&m_mode=&is_cti=&ord=regist_date&sort=ASC&page=1&rows=1000&excel_private_auth=T&mg_mode=&is_change_membergrade_sms=F&sSmsMemberGradeManualAuthCustomer=T&mg_group_no_fix_flag=&0f28=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJtIjoiZ2VvbnVrMDkwMSIsInMiOiJmYzRkZjdhOGM0NWRiOTFmODI4Nzc3YzFjNmNlYjg3OCIsInQiOjE3NzczMDQ0NzcsInIiOiJnZW9udWswOTAxLmNhZmUyNC5jb20ifQ.oTeVAGNf7_zGzBK87LcSvVbyulI_2yyXf7n49AmTXbjPCp17zMcLXuQhzMpgtiL8kJCs8oZxBeF2gJJLDcPpkA&search_type=member_id&type=&grp_sel=0&group_no=&is_member_auth=0&input_channel=&entry_path_group=&entry_path=&day_type=1&regist_start_date=2026-04-24&regist_end_date=2026-04-24&mem_start_date=04-25&mem_end_date=04-25&age1=&age2=&gender=1&sales_amount=1&sales_type=&min_sales_amount=&max_sales_amount=&ord_date_kind=order_date&ord_start_date=&ord_end_date=&iOrderPrdtNo=&sOrderPrdtName=&login_start_date=&login_end_date=&visit_ip=&s_join_cnt=&e_join_cnt=&s_attend_cnt=&e_attend_cnt=&is_marry=1&child=1&is_sms=1&is_news_mail=1&phone=&mobile=&region=region_00&mileage_type=avail_mileage&mileage1=&mileage2=&start_restore_datetime=&end_restore_datetime=&mid_list%5B%5D=&group_list%5B%5D=&member_name%5B%5D=&mg_group_no=2&group_no_b=2

File diff suppressed because it is too large Load Diff

35
result/results.json Normal file
View File

@@ -0,0 +1,35 @@
[
{
"mallId": "geonuk0901",
"groupNo": "2",
"division": "롱맨카페",
"shopNo": "2",
"status": 200,
"shopStatus": 200,
"responsePath": "result/geonuk0901-shop2-group2-_-response.html",
"requestPath": "result/geonuk0901-shop2-group2-_-request.txt",
"dynamicHiddenFieldName": "6572"
},
{
"mallId": "geonuk0901",
"groupNo": "1",
"division": "룰루뷰카페",
"shopNo": "1",
"status": 200,
"shopStatus": 200,
"responsePath": "result/geonuk0901-shop1-group1-_-response.html",
"requestPath": "result/geonuk0901-shop1-group1-_-request.txt",
"dynamicHiddenFieldName": "4bdf"
},
{
"mallId": "bluemaxwith",
"groupNo": "1",
"division": "블루맥스카페",
"shopNo": "1",
"status": 200,
"shopStatus": 200,
"responsePath": "result/bluemaxwith-shop1-group1-_-response.html",
"requestPath": "result/bluemaxwith-shop1-group1-_-request.txt",
"dynamicHiddenFieldName": "b5fc"
}
]

737
sheet/googlesheetapi.ts Normal file
View File

@@ -0,0 +1,737 @@
import { mkdir } from "node:fs/promises";
const GOOGLE_AUTH_SCOPE = "https://www.googleapis.com/auth/spreadsheets";
const TOKEN_PATH = ".tokens/google-oauth-token.json";
const DEFAULT_REDIRECT_PORT = 3487;
const CALLBACK_PATH = "/oauth2callback";
const DEFAULT_TARGET_SHEET_URL =
"https://docs.google.com/spreadsheets/d/1LGF93SGkrIqqniY-a3ReTPhizsEMtgm5QBC8tSALmVM/edit?gid=0#gid=0";
export const TARGET_HEADERS = [
"id",
"created_time",
"campaign_name",
"form_name",
"platform",
"full_name",
"phone_number",
"site",
"created_at",
"activation_channel",
"customer_group",
"sales_point",
"contact_number",
"primary_product",
"status",
"key",
] as const;
export type TargetHeader = (typeof TARGET_HEADERS)[number];
export type SheetRow = Record<
string,
string | number | boolean | null | undefined
>;
type TokenResponse = {
access_token: string;
expires_in: number;
refresh_token?: string;
scope: string;
token_type: string;
};
type SavedToken = {
access_token: string;
expiry_date: number;
refresh_token: string;
scope: string;
token_type: string;
};
type SpreadsheetMetadata = {
sheets?: Array<{
properties?: {
sheetId?: number;
title?: string;
};
}>;
};
type SheetValuesResponse = {
range?: string;
majorDimension?: string;
values?: string[][];
};
type ValueRangePayload = {
range: string;
majorDimension: "ROWS";
values: string[][];
};
type BatchUpdateSpreadsheetRequest = {
requests: Array<{
addSheet?: {
properties?: {
title?: string;
};
};
}>;
};
type AppendRowsOptions = {
rows: SheetRow[];
sheetName: string;
baseDate?: string;
targetSheetUrl?: string;
};
const TARGET_HEADER_ALIASES: Record<TargetHeader, string[]> = {
id: ["id"],
created_time: ["created_time"],
campaign_name: ["campaign_name", "campain_name"],
form_name: ["form_name"],
platform: ["platform"],
full_name: ["full_name", "고객명"],
phone_number: ["phone_number", "핸드폰연락처"],
site: ["site"],
created_at: ["created_at", "접수일자"],
activation_channel: ["activation_channel", "개통처"],
customer_group: ["customer_group", "고객그룹"],
sales_point: ["sales_point", "판매점"],
contact_number: ["contact_number", "phone_number", "핸드폰연락처"],
primary_product: ["primary_product", "1차 상품"],
status: ["status", "진행상태"],
key: ["key", "고객키"],
};
function readRequiredEnv(name: string): string {
const value = Bun.env[name]?.trim();
if (!value) {
throw new Error(`${name} 환경변수가 필요합니다.`);
}
return value;
}
function parseSpreadsheetUrl(input: string) {
const url = new URL(input);
const match = url.pathname.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
if (!match?.[1]) {
throw new Error("유효한 구글 시트 URL이 아닙니다.");
}
const spreadsheetId = match[1];
const gidValue =
url.searchParams.get("gid") ?? url.hash.match(/gid=(\d+)/)?.[1] ?? null;
const gid = gidValue ? Number(gidValue) : null;
if (gidValue && Number.isNaN(gid)) {
throw new Error("gid 값을 해석하지 못했습니다.");
}
return { spreadsheetId, gid };
}
function escapeSheetTitle(title: string): string {
return `'${title.replaceAll("'", "''")}'`;
}
function normalizeHeader(header: string): string {
return header.trim().toLowerCase().replaceAll(/\s+/g, "_");
}
function stringifyCellValue(value: SheetRow[string]): string {
if (value == null) {
return "";
}
return String(value).trim();
}
function formatKstDateTime(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
// Keep date-only values as-is so KST conversion does not introduce 09:00:00.
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
return trimmed;
}
const date = new Date(trimmed);
if (Number.isNaN(date.getTime())) {
return value;
}
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
.formatToParts(date)
.reduce<Record<string, string>>((acc, part) => {
if (part.type !== "literal") {
acc[part.type] = part.value;
}
return acc;
}, {});
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
}
function formatPhoneNumber(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const normalized = trimmed.replace(/^p:/i, "").replace(/[^\d+]/g, "");
let digits = normalized;
if (digits.startsWith("+82")) {
digits = `0${digits.slice(3)}`;
} else if (digits.startsWith("82")) {
digits = `0${digits.slice(2)}`;
}
const phoneDigits = digits.replace(/\D/g, "");
if (phoneDigits.length === 10 && phoneDigits.startsWith("10")) {
return `010-${phoneDigits.slice(2, 6)}-${phoneDigits.slice(6)}`;
}
if (phoneDigits.length === 11) {
return `${phoneDigits.slice(0, 3)}-${phoneDigits.slice(3, 7)}-${phoneDigits.slice(7)}`;
}
return value;
}
function normalizeCellValue(header: string, value: string): string {
const normalizedHeader = normalizeHeader(header);
if (
normalizedHeader === "created_time" ||
normalizedHeader === "created_at"
) {
return formatKstDateTime(value);
}
if (
normalizedHeader === "phone_number" ||
normalizedHeader === "contact_number"
) {
return formatPhoneNumber(value);
}
return value;
}
function buildHeaderLookup(headers: string[]): Map<string, string> {
const lookup = new Map<string, string>();
headers.forEach((header, index) => {
const trimmed = header.trim() || `column_${index + 1}`;
lookup.set(trimmed, trimmed);
lookup.set(normalizeHeader(trimmed), trimmed);
});
return lookup;
}
function getRowValueForTarget(
row: SheetRow,
targetHeader: TargetHeader
): string {
const directValue = stringifyCellValue(row[targetHeader]);
if (directValue) {
return normalizeCellValue(targetHeader, directValue);
}
const lookup = buildHeaderLookup(Object.keys(row));
const mappedSourceHeader =
lookup.get(targetHeader) ?? lookup.get(normalizeHeader(targetHeader));
if (mappedSourceHeader) {
const mappedValue = stringifyCellValue(row[mappedSourceHeader]);
if (mappedValue) {
return normalizeCellValue(targetHeader, mappedValue);
}
}
const aliases = TARGET_HEADER_ALIASES[targetHeader] ?? [];
for (const alias of aliases) {
const aliasValue = stringifyCellValue(row[alias]);
if (aliasValue) {
return normalizeCellValue(targetHeader, aliasValue);
}
const aliasHeader = lookup.get(alias) ?? lookup.get(normalizeHeader(alias));
if (aliasHeader) {
const headerValue = stringifyCellValue(row[aliasHeader]);
if (headerValue) {
return normalizeCellValue(targetHeader, headerValue);
}
}
}
return "";
}
export function mapRowsForTarget(rows: SheetRow[]): string[][] {
return rows.map((row) =>
TARGET_HEADERS.map((header) => getRowValueForTarget(row, header))
);
}
async function readSavedToken(): Promise<SavedToken | null> {
const tokenFile = Bun.file(TOKEN_PATH);
if (!(await tokenFile.exists())) {
return null;
}
return (await tokenFile.json()) as SavedToken;
}
async function saveToken(token: SavedToken) {
await mkdir(".tokens", { recursive: true });
await Bun.write(TOKEN_PATH, JSON.stringify(token, null, 2));
}
async function refreshAccessToken(
clientId: string,
clientSecret: string,
refreshToken: string
): Promise<SavedToken> {
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`토큰 갱신 실패: ${response.status} ${text}`);
}
const token = (await response.json()) as TokenResponse;
return {
access_token: token.access_token,
expiry_date: Date.now() + token.expires_in * 1000,
refresh_token: refreshToken,
scope: token.scope,
token_type: token.token_type,
};
}
async function requestNewToken(
clientId: string,
clientSecret: string
): Promise<SavedToken> {
const redirectPort = Number(
Bun.env.GOOGLE_REDIRECT_PORT ?? DEFAULT_REDIRECT_PORT
);
const redirectUri = `http://127.0.0.1:${redirectPort}${CALLBACK_PATH}`;
const state = crypto.randomUUID();
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", GOOGLE_AUTH_SCOPE);
authUrl.searchParams.set("access_type", "offline");
authUrl.searchParams.set("prompt", "consent");
authUrl.searchParams.set("state", state);
let resolveCode!: (value: string) => void;
let rejectCode!: (reason?: unknown) => void;
const codePromise = new Promise<string>((resolve, reject) => {
resolveCode = resolve;
rejectCode = reject;
});
const server = Bun.serve({
port: redirectPort,
fetch(request) {
const url = new URL(request.url);
if (url.pathname !== CALLBACK_PATH) {
return new Response("Not found", { status: 404 });
}
const returnedState = url.searchParams.get("state");
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
if (error) {
rejectCode(new Error(`OAuth 인증 실패: ${error}`));
return new Response(
"OAuth 인증이 취소되었습니다. 터미널을 확인해 주세요.",
{
status: 400,
}
);
}
if (returnedState !== state || !code) {
rejectCode(new Error("OAuth 콜백 검증에 실패했습니다."));
return new Response("잘못된 OAuth 콜백입니다.", { status: 400 });
}
resolveCode(code);
return new Response("인증이 완료되었습니다. 터미널로 돌아가 주세요.");
},
});
console.log("브라우저에서 Google OAuth 인증을 진행해 주세요.");
console.log(authUrl.toString());
try {
Bun.file(authUrl.toString());
} catch {
// URL은 이미 출력했으니 open 실패는 무시합니다.
}
let code: string;
try {
code = await Promise.race([
codePromise,
new Promise<string>((_, reject) => {
setTimeout(
() => reject(new Error("OAuth 인증 대기 시간이 초과되었습니다.")),
5 * 60 * 1000
);
}),
]);
} finally {
server.stop(true);
}
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`OAuth 토큰 발급 실패: ${response.status} ${text}`);
}
const token = (await response.json()) as TokenResponse;
if (!token.refresh_token) {
throw new Error(
"refresh_token 을 받지 못했습니다. OAuth 클라이언트 설정을 확인해 주세요."
);
}
return {
access_token: token.access_token,
expiry_date: Date.now() + token.expires_in * 1000,
refresh_token: token.refresh_token,
scope: token.scope,
token_type: token.token_type,
};
}
async function getAuthorizedToken(): Promise<SavedToken> {
const clientId = readRequiredEnv("GOOGLE_CLIENT_ID");
const clientSecret = readRequiredEnv("GOOGLE_CLIENT_SECRET");
const savedToken = await readSavedToken();
const hasWriteScope = savedToken?.scope?.includes(GOOGLE_AUTH_SCOPE) ?? false;
if (savedToken?.refresh_token && hasWriteScope) {
const needsRefresh = savedToken.expiry_date <= Date.now() + 60_000;
if (!needsRefresh) {
return savedToken;
}
const refreshed = await refreshAccessToken(
clientId,
clientSecret,
savedToken.refresh_token
);
await saveToken(refreshed);
return refreshed;
}
if (savedToken?.refresh_token && !hasWriteScope) {
console.log(
"기존 토큰이 읽기 전용 scope 입니다. 브라우저에서 다시 OAuth 인증을 진행합니다."
);
}
const newToken = await requestNewToken(clientId, clientSecret);
await saveToken(newToken);
return newToken;
}
async function googleApiFetch<T>(accessToken: string, url: string): Promise<T> {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Google API 호출 실패: ${response.status} ${text}`);
}
return (await response.json()) as T;
}
async function googleApiRequest<T>(
accessToken: string,
url: string,
init?: RequestInit
): Promise<T> {
const response = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${accessToken}`,
...(init?.headers ?? {}),
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Google API 호출 실패: ${response.status} ${text}`);
}
return (await response.json()) as T;
}
async function getSheetValues(
accessToken: string,
spreadsheetId: string,
range: string
): Promise<SheetValuesResponse> {
const encodedRange = encodeURIComponent(range);
return googleApiFetch<SheetValuesResponse>(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedRange}`
);
}
async function updateSheetValues(
accessToken: string,
spreadsheetId: string,
range: string,
values: string[][]
) {
const encodedRange = encodeURIComponent(range);
const body: ValueRangePayload = {
range,
majorDimension: "ROWS",
values,
};
return googleApiRequest(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedRange}?valueInputOption=USER_ENTERED`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
}
async function appendSheetValues(
accessToken: string,
spreadsheetId: string,
range: string,
values: string[][]
) {
const encodedRange = encodeURIComponent(range);
const body: ValueRangePayload = {
range,
majorDimension: "ROWS",
values,
};
return googleApiRequest(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedRange}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
}
async function hasSheetTitle(
accessToken: string,
spreadsheetId: string,
sheetTitle: string
): Promise<boolean> {
const metadata = await googleApiFetch<SpreadsheetMetadata>(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}?fields=sheets.properties(title)`
);
return (
metadata.sheets?.some((sheet) => sheet.properties?.title === sheetTitle) ??
false
);
}
async function createSheet(
accessToken: string,
spreadsheetId: string,
sheetTitle: string
) {
const body: BatchUpdateSpreadsheetRequest = {
requests: [
{
addSheet: {
properties: {
title: sheetTitle,
},
},
},
],
};
return googleApiRequest(
accessToken,
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}:batchUpdate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
}
async function ensureSheetExists(
accessToken: string,
spreadsheetId: string,
sheetTitle: string
) {
const exists = await hasSheetTitle(accessToken, spreadsheetId, sheetTitle);
if (!exists) {
await createSheet(accessToken, spreadsheetId, sheetTitle);
}
}
async function ensureTargetHeaderRow(
accessToken: string,
spreadsheetId: string,
sheetTitle: string
) {
const headerRange = `${escapeSheetTitle(sheetTitle)}!1:1`;
const current = await getSheetValues(accessToken, spreadsheetId, headerRange);
const existingHeaders = current.values?.[0] ?? [];
const hasSameHeaders =
existingHeaders.length === TARGET_HEADERS.length &&
TARGET_HEADERS.every((header, index) => existingHeaders[index] === header);
if (!hasSameHeaders) {
await updateSheetValues(accessToken, spreadsheetId, headerRange, [
Array.from(TARGET_HEADERS),
]);
}
}
function getTodayKstDateString(): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.formatToParts(new Date())
.reduce<Record<string, string>>((acc, part) => {
if (part.type !== "literal") {
acc[part.type] = part.value;
}
return acc;
}, {});
return `${parts.year}-${parts.month}-${parts.day}`;
}
function getMonthDayString(baseDate = getTodayKstDateString()): string {
return baseDate.slice(5).replace("-", "");
}
export async function appendRowsToDailySheet({
rows,
sheetName,
baseDate,
targetSheetUrl = Bun.env.GOOGLE_TARGET_SHEET_URL ?? DEFAULT_TARGET_SHEET_URL,
}: AppendRowsOptions) {
const targetSheetTitle = `${sheetName}${getMonthDayString(baseDate)}`;
if (rows.length === 0) {
return {
appendedRows: 0,
targetSheetTitle,
};
}
const token = await getAuthorizedToken();
const targetSheet = parseSpreadsheetUrl(targetSheetUrl);
const mappedRows = mapRowsForTarget(rows);
if (mappedRows.length === 0) {
return {
appendedRows: 0,
targetSheetTitle,
};
}
await ensureSheetExists(
token.access_token,
targetSheet.spreadsheetId,
targetSheetTitle
);
await ensureTargetHeaderRow(
token.access_token,
targetSheet.spreadsheetId,
targetSheetTitle
);
await appendSheetValues(
token.access_token,
targetSheet.spreadsheetId,
escapeSheetTitle(targetSheetTitle),
mappedRows
);
return {
appendedRows: mappedRows.length,
targetSheetTitle,
targetSpreadsheetId: targetSheet.spreadsheetId,
};
}

31
tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"types": ["bun"]
}
}