googlesheet
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal 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
35
.gitignore
vendored
Normal 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
77
README.md
Normal 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
144
bun.lock
Normal 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
6
cafe24/accounts.txt
Normal 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
710
cafe24/cafe24.ts
Normal 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("&", "&") ?? "";
|
||||
}
|
||||
|
||||
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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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
932
google/index.ts
Normal 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
2
google/sheetUrls.txt
Normal 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 룰루뷰구글
|
||||
67
hyundai/hyundaiCustomerParser.ts
Normal file
67
hyundai/hyundaiCustomerParser.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
377
hyundai/hyundaiCustomerVerifier.ts
Normal file
377
hyundai/hyundaiCustomerVerifier.ts
Normal 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
20
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
result/bluemaxwith-shop1-group1-_-request.txt
Normal file
1
result/bluemaxwith-shop1-group1-_-request.txt
Normal 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®ist_start_date=2026-05-11®ist_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=®ion=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
|
||||
30171
result/bluemaxwith-shop1-group1-_-response.html
Normal file
30171
result/bluemaxwith-shop1-group1-_-response.html
Normal file
File diff suppressed because it is too large
Load Diff
1
result/geonuk0901-shop1-group1-_-request.txt
Normal file
1
result/geonuk0901-shop1-group1-_-request.txt
Normal 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®ist_start_date=2026-05-11®ist_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=®ion=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
|
||||
7244
result/geonuk0901-shop1-group1-_-response.html
Normal file
7244
result/geonuk0901-shop1-group1-_-response.html
Normal file
File diff suppressed because it is too large
Load Diff
1
result/geonuk0901-shop2-group2-_-request.txt
Normal file
1
result/geonuk0901-shop2-group2-_-request.txt
Normal 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®ist_start_date=2026-05-11®ist_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=®ion=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
|
||||
18321
result/geonuk0901-shop2-group2-_-response.html
Normal file
18321
result/geonuk0901-shop2-group2-_-response.html
Normal file
File diff suppressed because it is too large
Load Diff
1
result/geonuk0901-shop2-group2-longman-request.txt
Normal file
1
result/geonuk0901-shop2-group2-longman-request.txt
Normal 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®ist_start_date=2026-04-24®ist_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=®ion=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
|
||||
22255
result/geonuk0901-shop2-group2-longman-response.html
Normal file
22255
result/geonuk0901-shop2-group2-longman-response.html
Normal file
File diff suppressed because it is too large
Load Diff
35
result/results.json
Normal file
35
result/results.json
Normal 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
737
sheet/googlesheetapi.ts
Normal 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
31
tsconfig.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user