daiso 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -4
- package/dist/api/actionsProxy.js +116 -21
- package/dist/api/compareHandlers.js +26 -0
- package/dist/api/configStatus.js +5 -1
- package/dist/api/gs25Handlers.js +45 -10
- package/dist/api/healthChecks.js +50 -9
- package/dist/api/placesHandlers.js +44 -0
- package/dist/api/routes/compareRoutes.js +9 -0
- package/dist/api/routes/healthRoutes.js +1 -1
- package/dist/api/routes/placesRoutes.js +9 -0
- package/dist/cli/commandOptions.js +65 -6
- package/dist/cli/commands/convenience.js +38 -0
- package/dist/cli.js +5 -1
- package/dist/cliHelp.js +25 -6
- package/dist/cliRenderer.js +24 -2
- package/dist/core/registry.js +33 -2
- package/dist/index.js +22 -3
- package/dist/pages/openapiFullSpec.js +6 -0
- package/dist/pages/openapiSpecActions.js +315 -42
- package/dist/pages/openapiSpecPathsCompare.js +59 -0
- package/dist/pages/openapiSpecPathsPlaces.js +85 -0
- package/dist/pages/promptReferenceText.js +5 -0
- package/dist/pages/promptRetailCinemaText.js +41 -3
- package/dist/services/compare/client.js +133 -0
- package/dist/services/compare/index.js +17 -0
- package/dist/services/compare/tools/compareProducts.js +45 -0
- package/dist/services/compare/types.js +1 -0
- package/dist/services/gs25/api.js +3 -0
- package/dist/services/gs25/client.js +211 -35
- package/dist/services/gs25/index.js +11 -3
- package/dist/services/gs25/tools/checkInventory.js +39 -12
- package/dist/services/gs25/tools/findNearbyStores.js +19 -5
- package/dist/services/gs25/tools/searchProducts.js +11 -4
- package/dist/services/oliveyoung/client.js +90 -41
- package/dist/services/places/client.js +116 -0
- package/dist/services/places/index.js +23 -0
- package/dist/services/places/tools/searchNearby.js +56 -0
- package/dist/services/places/types.js +1 -0
- package/dist/utils/zyte.js +45 -18
- package/openapi.json +29 -0
- package/openapi.yaml +20 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
# Daiso MCP 및 Skill
|
|
9
9
|
|
|
10
|
-
다이소(제품/매장/재고), 롯데마트(매장/상품), GS25(매장/상품/재고), 세븐일레븐(상품/매장/재고/인기검색어/카탈로그), CU(매장/재고), 이마트24(매장/상품/재고), 올리브영(매장/재고), 메가박스(지점/영화/시간표/좌석), 롯데시네마(지점/영화/좌석), CGV(극장/영화/시간표) 조회 기능을 MCP, CLI, Codex Skill로 AI에 연결합니다.
|
|
10
|
+
다이소(제품/매장/재고), 키 없는 통합 가격 후보 비교, 주변 음식점/카페, 롯데마트(매장/상품), GS25(매장/상품/재고), 세븐일레븐(상품/매장/재고/인기검색어/카탈로그), CU(매장/재고), 이마트24(매장/상품/재고), 올리브영(매장/재고), 메가박스(지점/영화/시간표/좌석), 롯데시네마(지점/영화/좌석), CGV(극장/영화/시간표) 조회 기능을 MCP, CLI, Codex Skill로 AI에 연결합니다.
|
|
11
11
|
|
|
12
12
|
<br>
|
|
13
13
|
|
|
@@ -19,6 +19,47 @@
|
|
|
19
19
|
|
|
20
20
|
<br>
|
|
21
21
|
|
|
22
|
+
<h3>지원 서비스</h3>
|
|
23
|
+
|
|
24
|
+
<table>
|
|
25
|
+
<thead>
|
|
26
|
+
<tr>
|
|
27
|
+
<th>분류</th>
|
|
28
|
+
<th>서비스</th>
|
|
29
|
+
<th>조회 기능</th>
|
|
30
|
+
</tr>
|
|
31
|
+
</thead>
|
|
32
|
+
<tbody>
|
|
33
|
+
<tr>
|
|
34
|
+
<td>장소</td>
|
|
35
|
+
<td>네이버 지역 검색</td>
|
|
36
|
+
<td>음식점, 카페, 디저트, 주변 장소</td>
|
|
37
|
+
</tr>
|
|
38
|
+
<tr>
|
|
39
|
+
<td>비교</td>
|
|
40
|
+
<td>다이소, GS25, 세븐일레븐, 이마트24</td>
|
|
41
|
+
<td>새 외부 키 없는 상품 가격 후보 비교</td>
|
|
42
|
+
</tr>
|
|
43
|
+
<tr>
|
|
44
|
+
<td>리테일</td>
|
|
45
|
+
<td>다이소, 올리브영, 롯데마트</td>
|
|
46
|
+
<td>상품, 매장, 재고</td>
|
|
47
|
+
</tr>
|
|
48
|
+
<tr>
|
|
49
|
+
<td>편의점</td>
|
|
50
|
+
<td>GS25, 세븐일레븐, CU, 이마트24</td>
|
|
51
|
+
<td>상품, 매장, 재고, 인기검색어, 카탈로그</td>
|
|
52
|
+
</tr>
|
|
53
|
+
<tr>
|
|
54
|
+
<td>영화관</td>
|
|
55
|
+
<td>CGV, 메가박스, 롯데시네마</td>
|
|
56
|
+
<td>극장, 영화, 시간표, 잔여 좌석</td>
|
|
57
|
+
</tr>
|
|
58
|
+
</tbody>
|
|
59
|
+
</table>
|
|
60
|
+
|
|
61
|
+
<br>
|
|
62
|
+
|
|
22
63
|
[](https://opensource.org/licenses/MIT)
|
|
23
64
|
[](https://workers.cloudflare.com/)
|
|
24
65
|
[](https://modelcontextprotocol.io/)
|
|
@@ -29,11 +70,11 @@
|
|
|
29
70
|
**[실시간 서비스 상태 보기](https://aka-page.betteruptime.com/)**
|
|
30
71
|
|
|
31
72
|
<!-- WORKERS_INVOCATIONS_CHART:START -->
|
|
32
|
-
<h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-
|
|
73
|
+
<h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-23, 84일)</h3>
|
|
33
74
|
|
|
34
|
-
<img src="./assets/analytics/workers-invocations.png?v=2026-05-
|
|
75
|
+
<img src="./assets/analytics/workers-invocations.png?v=2026-05-23T15:44:29.609Z" alt="Cloudflare Workers 호출량 그래프 (2026-03-01 ~ 2026-05-23)" width="100%">
|
|
35
76
|
|
|
36
|
-
<sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-
|
|
77
|
+
<sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-24 00:44 KST</sub>
|
|
37
78
|
|
|
38
79
|
<!-- WORKERS_INVOCATIONS_CHART:END -->
|
|
39
80
|
|
|
@@ -69,6 +110,7 @@ ChatGPT, Claude, Grok 같은 AI 앱에서 바로 연결해 사용할 수 있습
|
|
|
69
110
|
|
|
70
111
|
```
|
|
71
112
|
다이소 mcp로 수납박스 검색해줘
|
|
113
|
+
콜라 어디가 싼지 비교해줘
|
|
72
114
|
올리브영 mcp로 명동 근처 매장 찾아줘
|
|
73
115
|
이마트24 mcp로 강남 근처 매장과 두바이 재고 알려줘
|
|
74
116
|
롯데마트 mcp로 잠실 근처 매장 찾아줘
|
|
@@ -78,6 +120,8 @@ GS25 mcp로 강남 근처 매장과 오감자 재고 알려줘
|
|
|
78
120
|
세븐일레븐 mcp로 안산 중앙역 근처 매장 찾아줘
|
|
79
121
|
세븐일레븐 mcp로 안산 중앙역 근처 세븐일레븐에서 핫식스 재고 알려줘
|
|
80
122
|
세븐일레븐 mcp로 인기 검색어와 카탈로그 요약 알려줘
|
|
123
|
+
강남역 근처 카페 찾아줘
|
|
124
|
+
성수동 근처 브런치 음식점 찾아줘
|
|
81
125
|
메가박스 mcp로 강남점 영화와 잔여 좌석 알려줘
|
|
82
126
|
롯데시네마 mcp로 월드타워 근처 지점과 상영 영화 알려줘
|
|
83
127
|
롯데시네마 mcp로 월드타워 잔여 좌석 알려줘
|
|
@@ -171,6 +215,19 @@ AI: (https://mcp.aka.page/api/cgv/timetable?playDate=20260315&keyword=안산%20
|
|
|
171
215
|
|
|
172
216
|
<br>
|
|
173
217
|
|
|
218
|
+
### 바로 실행해보기
|
|
219
|
+
|
|
220
|
+
AI 앱 연동 없이 터미널에서 바로 확인할 수 있습니다.
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
npx daiso products 수납박스 --json
|
|
224
|
+
npx daiso places 강남역 --category cafe --limit 5 --json
|
|
225
|
+
npx daiso gs25-inventory 오감자 --storeKeyword 강남 --storeLimit 5 --json
|
|
226
|
+
npx daiso get /api/cgv/movies --playDate 20260520 --theaterCode 0056 --json
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
<br>
|
|
230
|
+
|
|
174
231
|
### MCP 서버 URL / CLI (고급)
|
|
175
232
|
|
|
176
233
|
AI 앱 대신 직접 연결하거나 스크립트에서 사용할 때만 참고하세요.
|
|
@@ -203,6 +260,9 @@ npx daiso product 1034604
|
|
|
203
260
|
npx daiso stores 강남역
|
|
204
261
|
npx daiso inventory 1034604 --keyword 강남역
|
|
205
262
|
npx daiso display-location 1034604 04515
|
|
263
|
+
npx daiso compare 콜라 --limit 3
|
|
264
|
+
npx daiso places 강남역 --category cafe --limit 5
|
|
265
|
+
npx daiso places 성수동 --keyword 브런치 --limit 5
|
|
206
266
|
npx daiso cu-stores 강남
|
|
207
267
|
npx daiso cu-inventory 과자 --storeKeyword 강남
|
|
208
268
|
npx daiso lottemart-stores 잠실 --area 서울 --limit 10
|
|
@@ -249,11 +309,30 @@ MCP 도구 응답은 각 서비스의 원본 필드를 유지하면서, 클라
|
|
|
249
309
|
- `standard.products`: 상품/영화 목록을 `code`, `name`, `price`, `imageUrl`, `raw` 형태로 정규화합니다.
|
|
250
310
|
- `standard.stores`: 매장 목록을 `code`, `name`, `address`, `distanceMeters`, `raw` 형태로 정규화합니다.
|
|
251
311
|
- `standard.theaters`: 영화관 목록을 `code`, `name`, `address`, `distanceMeters`, `raw` 형태로 정규화합니다.
|
|
312
|
+
- `standard.places`: 음식점/카페 같은 장소 목록을 `name`, `address`, `raw` 중심으로 정규화합니다.
|
|
313
|
+
|
|
314
|
+
### 키 없는 통합 가격 후보 비교
|
|
315
|
+
|
|
316
|
+
새 외부 API 키 없이 기존 상품 검색을 묶어 가격 후보를 비교합니다.
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
npx daiso compare 콜라 --limit 3 --json
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
REST:
|
|
323
|
+
|
|
324
|
+
```text
|
|
325
|
+
GET /api/compare/products?keyword=콜라&limit=3
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
지원 서비스는 `daiso`, `gs25`, `seveneleven`, `emart24`입니다. GS25처럼 상품 검색 응답에 가격이 없는 서비스는 후보에는 포함되지만 최저가 계산에서는 제외됩니다.
|
|
252
329
|
|
|
253
330
|
### 운영 헬스 체크
|
|
254
331
|
|
|
255
332
|
공개 상태 페이지: **[Daiso MCP Status](https://aka-page.betteruptime.com/)**
|
|
256
333
|
|
|
334
|
+
주변 음식점/카페 검색은 네이버 지역 검색 API를 사용합니다. 로컬 실행이나 배포 환경에는 `NAVER_CLIENT_ID`, `NAVER_CLIENT_SECRET`을 설정하세요.
|
|
335
|
+
|
|
257
336
|
서비스별 API 상태를 즉시 확인할 때는 `GET /api/health/checks`를 사용합니다. 이 엔드포인트는 `HEALTH_CHECK_SECRET` 환경 변수가 설정되어 있어야 하며, 요청에는 `Authorization: Bearer <secret>` 또는 `x-health-check-key: <secret>` 헤더가 필요합니다. 내부 체크 요청의 기준 URL은 `HEALTH_CHECK_BASE_URL`로 지정할 수 있습니다.
|
|
258
337
|
|
|
259
338
|
```bash
|
package/dist/api/actionsProxy.js
CHANGED
|
@@ -3,34 +3,129 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const ACTION_QUERY_DEFINITIONS = [
|
|
5
5
|
{ action: 'daisoSearchProducts', summary: '다이소 제품 검색', targetPath: '/api/daiso/products' },
|
|
6
|
-
{
|
|
6
|
+
{
|
|
7
|
+
action: 'daisoGetProduct',
|
|
8
|
+
summary: '다이소 제품 상세 조회',
|
|
9
|
+
targetPath: '/api/daiso/products/:productId',
|
|
10
|
+
pathParam: 'productId',
|
|
11
|
+
},
|
|
7
12
|
{ action: 'daisoFindStores', summary: '다이소 매장 검색', targetPath: '/api/daiso/stores' },
|
|
8
|
-
{
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
{
|
|
14
|
+
action: 'daisoCheckInventory',
|
|
15
|
+
summary: '다이소 재고 조회',
|
|
16
|
+
targetPath: '/api/daiso/inventory',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
action: 'daisoGetDisplayLocation',
|
|
20
|
+
summary: '다이소 진열 위치 조회',
|
|
21
|
+
targetPath: '/api/daiso/display-location',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
action: 'oliveyoungSearchProducts',
|
|
25
|
+
summary: '올리브영 상품 검색',
|
|
26
|
+
targetPath: '/api/oliveyoung/products',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
action: 'oliveyoungFindStores',
|
|
30
|
+
summary: '올리브영 매장 검색',
|
|
31
|
+
targetPath: '/api/oliveyoung/stores',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
action: 'oliveyoungCheckInventory',
|
|
35
|
+
summary: '올리브영 재고 조회',
|
|
36
|
+
targetPath: '/api/oliveyoung/inventory',
|
|
37
|
+
},
|
|
13
38
|
{ action: 'cuFindStores', summary: 'CU 매장 검색', targetPath: '/api/cu/stores' },
|
|
14
39
|
{ action: 'cuCheckInventory', summary: 'CU 재고 조회', targetPath: '/api/cu/inventory' },
|
|
15
40
|
{ action: 'emart24FindStores', summary: '이마트24 매장 검색', targetPath: '/api/emart24/stores' },
|
|
16
|
-
{
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
41
|
+
{
|
|
42
|
+
action: 'emart24SearchProducts',
|
|
43
|
+
summary: '이마트24 상품 검색',
|
|
44
|
+
targetPath: '/api/emart24/products',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
action: 'emart24CheckInventory',
|
|
48
|
+
summary: '이마트24 재고 조회',
|
|
49
|
+
targetPath: '/api/emart24/inventory',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
action: 'lottemartFindStores',
|
|
53
|
+
summary: '롯데마트 매장 검색',
|
|
54
|
+
targetPath: '/api/lottemart/stores',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
action: 'lottemartSearchProducts',
|
|
58
|
+
summary: '롯데마트 상품 검색',
|
|
59
|
+
targetPath: '/api/lottemart/products',
|
|
60
|
+
},
|
|
20
61
|
{ action: 'gs25FindStores', summary: 'GS25 매장 검색', targetPath: '/api/gs25/stores' },
|
|
21
62
|
{ action: 'gs25SearchProducts', summary: 'GS25 상품 검색', targetPath: '/api/gs25/products' },
|
|
22
63
|
{ action: 'gs25CheckInventory', summary: 'GS25 재고 조회', targetPath: '/api/gs25/inventory' },
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
{
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
{
|
|
64
|
+
{
|
|
65
|
+
action: 'sevenelevenSearchProducts',
|
|
66
|
+
summary: '세븐일레븐 상품 검색',
|
|
67
|
+
targetPath: '/api/seveneleven/products',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
action: 'sevenelevenSearchStores',
|
|
71
|
+
summary: '세븐일레븐 매장 검색',
|
|
72
|
+
targetPath: '/api/seveneleven/stores',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
action: 'sevenelevenCheckInventory',
|
|
76
|
+
summary: '세븐일레븐 재고 조회',
|
|
77
|
+
targetPath: '/api/seveneleven/inventory',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
action: 'sevenelevenGetPopwords',
|
|
81
|
+
summary: '세븐일레븐 인기 검색어 조회',
|
|
82
|
+
targetPath: '/api/seveneleven/popwords',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
action: 'sevenelevenGetCatalog',
|
|
86
|
+
summary: '세븐일레븐 카탈로그 조회',
|
|
87
|
+
targetPath: '/api/seveneleven/catalog',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
action: 'compareProducts',
|
|
91
|
+
summary: '키 없는 통합 상품 가격 후보 비교',
|
|
92
|
+
targetPath: '/api/compare/products',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
action: 'placesSearchNearby',
|
|
96
|
+
summary: '음식점/카페 등 주변 장소 검색',
|
|
97
|
+
targetPath: '/api/places/search',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
action: 'megaboxFindTheaters',
|
|
101
|
+
summary: '메가박스 지점 검색',
|
|
102
|
+
targetPath: '/api/megabox/theaters',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
action: 'megaboxListMovies',
|
|
106
|
+
summary: '메가박스 영화 목록 조회',
|
|
107
|
+
targetPath: '/api/megabox/movies',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
action: 'megaboxGetSeats',
|
|
111
|
+
summary: '메가박스 잔여 좌석 조회',
|
|
112
|
+
targetPath: '/api/megabox/seats',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
action: 'lottecinemaFindTheaters',
|
|
116
|
+
summary: '롯데시네마 지점 검색',
|
|
117
|
+
targetPath: '/api/lottecinema/theaters',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
action: 'lottecinemaListMovies',
|
|
121
|
+
summary: '롯데시네마 영화 목록 조회',
|
|
122
|
+
targetPath: '/api/lottecinema/movies',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
action: 'lottecinemaGetSeats',
|
|
126
|
+
summary: '롯데시네마 잔여 좌석 조회',
|
|
127
|
+
targetPath: '/api/lottecinema/seats',
|
|
128
|
+
},
|
|
34
129
|
{ action: 'cgvFindTheaters', summary: 'CGV 극장 검색', targetPath: '/api/cgv/theaters' },
|
|
35
130
|
{ action: 'cgvSearchMovies', summary: 'CGV 영화 목록 조회', targetPath: '/api/cgv/movies' },
|
|
36
131
|
{ action: 'cgvGetTimetable', summary: 'CGV 시간표 조회', targetPath: '/api/cgv/timetable' },
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { compareProducts, parseCompareServices } from '../services/compare/client.js';
|
|
2
|
+
import { errorResponse, successResponse } from './response.js';
|
|
3
|
+
export async function handleCompareProducts(c) {
|
|
4
|
+
const keyword = c.req.query('keyword') || c.req.query('q') || '';
|
|
5
|
+
const limit = parseInt(c.req.query('limit') || '5', 10);
|
|
6
|
+
const services = parseCompareServices(c.req.query('services'));
|
|
7
|
+
if (keyword.trim().length === 0) {
|
|
8
|
+
return errorResponse(c, 'MISSING_KEYWORD', '검색어(keyword)를 입력해주세요.');
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const result = await compareProducts({
|
|
12
|
+
keyword,
|
|
13
|
+
limit,
|
|
14
|
+
services,
|
|
15
|
+
});
|
|
16
|
+
return successResponse(c, result, {
|
|
17
|
+
total: result.resultCount,
|
|
18
|
+
page: 1,
|
|
19
|
+
pageSize: limit,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
|
24
|
+
return errorResponse(c, 'COMPARE_PRODUCTS_FAILED', message, 500);
|
|
25
|
+
}
|
|
26
|
+
}
|
package/dist/api/configStatus.js
CHANGED
|
@@ -9,7 +9,11 @@ export function buildConfigStatus(bindings) {
|
|
|
9
9
|
},
|
|
10
10
|
zyteApiKey: {
|
|
11
11
|
configured: isConfigured(bindings?.ZYTE_API_KEY),
|
|
12
|
-
usedBy: ['oliveyoung', 'lottemart', 'cgv'],
|
|
12
|
+
usedBy: ['oliveyoung', 'gs25', 'lottemart', 'cgv'],
|
|
13
|
+
},
|
|
14
|
+
naverLocalSearch: {
|
|
15
|
+
configured: isConfigured(bindings?.NAVER_CLIENT_ID) && isConfigured(bindings?.NAVER_CLIENT_SECRET),
|
|
16
|
+
usedBy: ['places'],
|
|
13
17
|
},
|
|
14
18
|
healthCheckSecret: {
|
|
15
19
|
configured: isConfigured(bindings?.HEALTH_CHECK_SECRET),
|
package/dist/api/gs25Handlers.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
/* c8 ignore start */
|
|
5
5
|
import { errorResponse, successResponse } from './response.js';
|
|
6
|
-
import { attachDistanceToGs25Stores, fetchGs25SearchProducts, fetchGs25Stores, filterGs25StoresByKeyword, geocodeGs25Address, selectGs25StoresForKeyword, sortGs25Stores, } from '../services/gs25/client.js';
|
|
6
|
+
import { attachDistanceToGs25Stores, fetchGs25SearchProducts, fetchGs25Stores, fetchGs25WebStores, filterGs25StoresByKeyword, geocodeGs25Address, selectGs25StoresForKeyword, sortGs25Stores, } from '../services/gs25/client.js';
|
|
7
7
|
const GS25_FALLBACK_STORE_LOOKUP_ITEM_CODE = '8801117752804';
|
|
8
8
|
/**
|
|
9
9
|
* GS25 매장 검색 API 핸들러
|
|
@@ -21,7 +21,8 @@ export async function handleGs25FindStores(c) {
|
|
|
21
21
|
let longitude = typeof parsedLng === 'number' && Number.isFinite(parsedLng) ? parsedLng : undefined;
|
|
22
22
|
let geocodeUsed = false;
|
|
23
23
|
try {
|
|
24
|
-
if ((typeof latitude !== 'number' || typeof longitude !== 'number') &&
|
|
24
|
+
if ((typeof latitude !== 'number' || typeof longitude !== 'number') &&
|
|
25
|
+
keyword.trim().length > 0) {
|
|
25
26
|
const geocoded = await geocodeGs25Address(keyword, {
|
|
26
27
|
timeout: 15000,
|
|
27
28
|
googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY,
|
|
@@ -38,9 +39,12 @@ export async function handleGs25FindStores(c) {
|
|
|
38
39
|
longitude,
|
|
39
40
|
}, {
|
|
40
41
|
timeout: 20000,
|
|
42
|
+
zyteApiKey: c.env?.ZYTE_API_KEY,
|
|
41
43
|
});
|
|
42
44
|
let fallbackUsed = false;
|
|
43
|
-
if (storeResult.stores.length === 0 &&
|
|
45
|
+
if (storeResult.stores.length === 0 &&
|
|
46
|
+
typeof latitude === 'number' &&
|
|
47
|
+
typeof longitude === 'number') {
|
|
44
48
|
try {
|
|
45
49
|
const fallbackResult = await fetchGs25Stores({
|
|
46
50
|
serviceCode,
|
|
@@ -51,6 +55,7 @@ export async function handleGs25FindStores(c) {
|
|
|
51
55
|
useCache: false,
|
|
52
56
|
}, {
|
|
53
57
|
timeout: 20000,
|
|
58
|
+
zyteApiKey: c.env?.ZYTE_API_KEY,
|
|
54
59
|
});
|
|
55
60
|
if (fallbackResult.stores.length > 0) {
|
|
56
61
|
storeResult = fallbackResult;
|
|
@@ -61,6 +66,24 @@ export async function handleGs25FindStores(c) {
|
|
|
61
66
|
fallbackUsed = false;
|
|
62
67
|
}
|
|
63
68
|
}
|
|
69
|
+
if (storeResult.stores.length === 0 && keyword.trim().length > 0) {
|
|
70
|
+
try {
|
|
71
|
+
const webStoreResult = await fetchGs25WebStores(keyword, {
|
|
72
|
+
timeout: 20000,
|
|
73
|
+
});
|
|
74
|
+
if (webStoreResult.stores.length > 0) {
|
|
75
|
+
storeResult = {
|
|
76
|
+
totalCount: webStoreResult.totalCount,
|
|
77
|
+
stores: webStoreResult.stores,
|
|
78
|
+
cacheHit: false,
|
|
79
|
+
};
|
|
80
|
+
fallbackUsed = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
fallbackUsed = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
64
87
|
const selected = selectGs25StoresForKeyword(storeResult.stores, keyword, {
|
|
65
88
|
relaxWhenEmpty: typeof latitude === 'number' && typeof longitude === 'number',
|
|
66
89
|
});
|
|
@@ -98,7 +121,10 @@ export async function handleGs25SearchProducts(c) {
|
|
|
98
121
|
return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.');
|
|
99
122
|
}
|
|
100
123
|
try {
|
|
101
|
-
const products = await fetchGs25SearchProducts(keyword, {
|
|
124
|
+
const products = await fetchGs25SearchProducts(keyword, {
|
|
125
|
+
timeout: 20000,
|
|
126
|
+
zyteApiKey: c.env?.ZYTE_API_KEY,
|
|
127
|
+
});
|
|
102
128
|
const limitedProducts = products.slice(0, limit);
|
|
103
129
|
return successResponse(c, {
|
|
104
130
|
keyword,
|
|
@@ -142,7 +168,8 @@ export async function handleGs25CheckInventory(c) {
|
|
|
142
168
|
return errorResponse(c, 'MISSING_QUERY', '검색어(keyword) 또는 상품 코드(itemCode)를 입력해주세요.');
|
|
143
169
|
}
|
|
144
170
|
try {
|
|
145
|
-
if ((typeof latitude !== 'number' || typeof longitude !== 'number') &&
|
|
171
|
+
if ((typeof latitude !== 'number' || typeof longitude !== 'number') &&
|
|
172
|
+
storeKeyword.trim().length > 0) {
|
|
146
173
|
const directGeocoded = await geocodeGs25Address(storeKeyword, {
|
|
147
174
|
timeout: 15000,
|
|
148
175
|
googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY,
|
|
@@ -157,9 +184,9 @@ export async function handleGs25CheckInventory(c) {
|
|
|
157
184
|
serviceCode,
|
|
158
185
|
}, {
|
|
159
186
|
timeout: 20000,
|
|
187
|
+
zyteApiKey: c.env?.ZYTE_API_KEY,
|
|
160
188
|
});
|
|
161
|
-
const firstAddress = filterGs25StoresByKeyword(baseStores.stores, storeKeyword).find((store) => store.address.trim().length > 0)
|
|
162
|
-
?.address || '';
|
|
189
|
+
const firstAddress = filterGs25StoresByKeyword(baseStores.stores, storeKeyword).find((store) => store.address.trim().length > 0)?.address || '';
|
|
163
190
|
if (firstAddress.length > 0) {
|
|
164
191
|
const geocoded = await geocodeGs25Address(firstAddress, {
|
|
165
192
|
timeout: 15000,
|
|
@@ -188,10 +215,14 @@ export async function handleGs25CheckInventory(c) {
|
|
|
188
215
|
useCache: false,
|
|
189
216
|
}, {
|
|
190
217
|
timeout: 20000,
|
|
218
|
+
zyteApiKey: c.env?.ZYTE_API_KEY,
|
|
191
219
|
});
|
|
192
220
|
}
|
|
193
221
|
else {
|
|
194
|
-
const searchProducts = await fetchGs25SearchProducts(keyword, {
|
|
222
|
+
const searchProducts = await fetchGs25SearchProducts(keyword, {
|
|
223
|
+
timeout: 20000,
|
|
224
|
+
zyteApiKey: c.env?.ZYTE_API_KEY,
|
|
225
|
+
});
|
|
195
226
|
firstProduct = searchProducts.find((p) => p.itemCode.length > 0);
|
|
196
227
|
if (firstProduct) {
|
|
197
228
|
resolvedItemCode = firstProduct.itemCode;
|
|
@@ -205,6 +236,7 @@ export async function handleGs25CheckInventory(c) {
|
|
|
205
236
|
useCache: false,
|
|
206
237
|
}, {
|
|
207
238
|
timeout: 20000,
|
|
239
|
+
zyteApiKey: c.env?.ZYTE_API_KEY,
|
|
208
240
|
});
|
|
209
241
|
}
|
|
210
242
|
else {
|
|
@@ -217,6 +249,7 @@ export async function handleGs25CheckInventory(c) {
|
|
|
217
249
|
useCache: false,
|
|
218
250
|
}, {
|
|
219
251
|
timeout: 20000,
|
|
252
|
+
zyteApiKey: c.env?.ZYTE_API_KEY,
|
|
220
253
|
});
|
|
221
254
|
}
|
|
222
255
|
}
|
|
@@ -227,8 +260,10 @@ export async function handleGs25CheckInventory(c) {
|
|
|
227
260
|
const withDistance = attachDistanceToGs25Stores(filtered, latitude, longitude);
|
|
228
261
|
const stores = sortGs25Stores(withDistance).slice(0, storeLimit);
|
|
229
262
|
// 상품 정보: searchProducts에서 먼저 가져오고, 없으면 stores에서
|
|
230
|
-
const productName = firstProduct?.itemName ||
|
|
231
|
-
|
|
263
|
+
const productName = firstProduct?.itemName ||
|
|
264
|
+
firstProduct?.shortItemName ||
|
|
265
|
+
filtered.find((item) => item.searchItemName.length > 0)?.searchItemName ||
|
|
266
|
+
null;
|
|
232
267
|
const productPrice = filtered.find((item) => item.searchItemSellPrice !== null)?.searchItemSellPrice ?? null;
|
|
233
268
|
const inStockStoreCount = filtered.filter((item) => item.realStockQuantity > 0).length;
|
|
234
269
|
const totalStockQuantity = filtered.reduce((sum, item) => sum + Math.max(item.realStockQuantity, 0), 0);
|
package/dist/api/healthChecks.js
CHANGED
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
const HEALTH_CHECK_CACHE_TTL_MS = 60_000;
|
|
5
5
|
const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 7000;
|
|
6
6
|
const MAX_HEALTH_CHECK_TIMEOUT_MS = 20_000;
|
|
7
|
-
const DEFAULT_HEALTH_CHECK_CONCURRENCY =
|
|
7
|
+
const DEFAULT_HEALTH_CHECK_CONCURRENCY = 1;
|
|
8
8
|
const DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS = 0;
|
|
9
|
+
const GS25_CLOUDFRONT_403_PATTERNS = [
|
|
10
|
+
'403 Forbidden',
|
|
11
|
+
'The request could not be satisfied',
|
|
12
|
+
'403 ERROR',
|
|
13
|
+
];
|
|
9
14
|
const HEALTH_CHECKS = [
|
|
10
15
|
{
|
|
11
16
|
id: 'cli.contract',
|
|
@@ -50,6 +55,7 @@ const HEALTH_CHECKS = [
|
|
|
50
55
|
path: '/api/gs25/products?keyword=%EC%BD%9C%EB%9D%BC&limit=1',
|
|
51
56
|
collectionKey: 'products',
|
|
52
57
|
requiredFields: ['itemCode', 'itemName', 'name'],
|
|
58
|
+
degradedFailurePatterns: GS25_CLOUDFRONT_403_PATTERNS,
|
|
53
59
|
},
|
|
54
60
|
{
|
|
55
61
|
id: 'gs25.stores',
|
|
@@ -86,6 +92,7 @@ const HEALTH_CHECKS = [
|
|
|
86
92
|
path: '/api/oliveyoung/products?keyword=%EB%A6%BD%EB%B0%A4&size=1',
|
|
87
93
|
collectionKey: 'products',
|
|
88
94
|
requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
|
|
95
|
+
timeoutMs: 5000,
|
|
89
96
|
},
|
|
90
97
|
{
|
|
91
98
|
id: 'megabox.theaters',
|
|
@@ -140,6 +147,11 @@ const HEALTH_CHECKS = [
|
|
|
140
147
|
path: '/api/gs25/inventory?keyword=%EC%BD%9C%EB%9D%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
|
|
141
148
|
collectionKey: 'inventoryItems',
|
|
142
149
|
requiredFields: ['itemCode', 'itemName', 'name'],
|
|
150
|
+
degradedFailurePatterns: [
|
|
151
|
+
'401 Unauthorized',
|
|
152
|
+
'인증키가 제공되지 않음',
|
|
153
|
+
...GS25_CLOUDFRONT_403_PATTERNS,
|
|
154
|
+
],
|
|
143
155
|
},
|
|
144
156
|
{
|
|
145
157
|
id: 'seveneleven.inventory',
|
|
@@ -158,6 +170,7 @@ const HEALTH_CHECKS = [
|
|
|
158
170
|
path: '/api/oliveyoung/inventory?keyword=%EC%84%A0%ED%81%AC%EB%A6%BC&storeKeyword=%EB%AA%85%EB%8F%99&size=1&storeLimit=1&stockCheckLimit=0',
|
|
159
171
|
collectionKey: 'inventoryProducts',
|
|
160
172
|
requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
|
|
173
|
+
timeoutMs: 5000,
|
|
161
174
|
},
|
|
162
175
|
];
|
|
163
176
|
const healthCheckCache = new Map();
|
|
@@ -322,6 +335,24 @@ function aggregateStatus(checks) {
|
|
|
322
335
|
}
|
|
323
336
|
return 'ok';
|
|
324
337
|
}
|
|
338
|
+
function shouldDegradeFailedResponse(check, message) {
|
|
339
|
+
return check.degradedFailurePatterns?.some((pattern) => message.includes(pattern)) ?? false;
|
|
340
|
+
}
|
|
341
|
+
function shouldDegradeCliContractPath(path, message) {
|
|
342
|
+
return path.startsWith('/api/gs25/') && GS25_CLOUDFRONT_403_PATTERNS.some((pattern) => message.includes(pattern));
|
|
343
|
+
}
|
|
344
|
+
function resolveCheckTimeoutMs(check, timeoutMs) {
|
|
345
|
+
if (check.timeoutMs === undefined) {
|
|
346
|
+
return timeoutMs;
|
|
347
|
+
}
|
|
348
|
+
return Math.min(timeoutMs, Math.trunc(check.timeoutMs));
|
|
349
|
+
}
|
|
350
|
+
function resolveCliContractTimeoutMs(path, timeoutMs) {
|
|
351
|
+
if (path.startsWith('/api/oliveyoung/')) {
|
|
352
|
+
return Math.min(timeoutMs, 5000);
|
|
353
|
+
}
|
|
354
|
+
return timeoutMs;
|
|
355
|
+
}
|
|
325
356
|
function buildCheckUrl(baseUrl, check, timeoutMs, cacheBustValue) {
|
|
326
357
|
const url = new URL(check.path, baseUrl);
|
|
327
358
|
url.searchParams.set('timeoutMs', String(timeoutMs));
|
|
@@ -357,14 +388,21 @@ function isCliCompatibleEnvelope(path, body) {
|
|
|
357
388
|
async function runCliContractCheck(check, params) {
|
|
358
389
|
const startedAt = params.now();
|
|
359
390
|
const cacheBustValue = params.cacheBust ? startedAt : undefined;
|
|
391
|
+
const degradedMessages = [];
|
|
360
392
|
for (const path of CLI_CONTRACT_PATHS) {
|
|
393
|
+
const checkTimeoutMs = resolveCliContractTimeoutMs(path, params.timeoutMs);
|
|
361
394
|
const syntheticCheck = { ...check, path };
|
|
362
395
|
try {
|
|
363
|
-
const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, syntheticCheck,
|
|
364
|
-
signal: AbortSignal.timeout(
|
|
396
|
+
const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, syntheticCheck, checkTimeoutMs, cacheBustValue), {
|
|
397
|
+
signal: AbortSignal.timeout(checkTimeoutMs),
|
|
365
398
|
});
|
|
366
399
|
const body = (await response.json().catch(() => ({})));
|
|
367
400
|
if (!response.ok || !isCliCompatibleEnvelope(path, body)) {
|
|
401
|
+
const message = body.error?.message || `${path} CLI 계약 응답이 올바르지 않습니다.`;
|
|
402
|
+
if (shouldDegradeCliContractPath(path, message)) {
|
|
403
|
+
degradedMessages.push(`${path}: ${message}`);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
368
406
|
return {
|
|
369
407
|
id: check.id,
|
|
370
408
|
service: check.service,
|
|
@@ -372,7 +410,7 @@ async function runCliContractCheck(check, params) {
|
|
|
372
410
|
status: 'fail',
|
|
373
411
|
durationMs: params.now() - startedAt,
|
|
374
412
|
httpStatus: response.status,
|
|
375
|
-
message
|
|
413
|
+
message,
|
|
376
414
|
};
|
|
377
415
|
}
|
|
378
416
|
}
|
|
@@ -391,16 +429,18 @@ async function runCliContractCheck(check, params) {
|
|
|
391
429
|
id: check.id,
|
|
392
430
|
service: check.service,
|
|
393
431
|
target: check.target,
|
|
394
|
-
status: 'ok',
|
|
432
|
+
status: degradedMessages.length > 0 ? 'degraded' : 'ok',
|
|
395
433
|
durationMs: params.now() - startedAt,
|
|
396
|
-
message:
|
|
434
|
+
message: degradedMessages.length > 0
|
|
435
|
+
? `known upstream issue: ${degradedMessages[0]}`
|
|
436
|
+
: `${CLI_CONTRACT_PATHS.length} CLI contract endpoint(s) passed`,
|
|
397
437
|
};
|
|
398
438
|
}
|
|
399
439
|
async function runSingleCheck(check, params) {
|
|
400
440
|
if (check.kind === 'cli-contract') {
|
|
401
441
|
return runCliContractCheck(check, params);
|
|
402
442
|
}
|
|
403
|
-
const timeoutMs = params.timeoutMs;
|
|
443
|
+
const timeoutMs = resolveCheckTimeoutMs(check, params.timeoutMs);
|
|
404
444
|
const startedAt = params.now();
|
|
405
445
|
const cacheBustValue = params.cacheBust ? startedAt : undefined;
|
|
406
446
|
try {
|
|
@@ -410,14 +450,15 @@ async function runSingleCheck(check, params) {
|
|
|
410
450
|
const body = (await response.json().catch(() => ({})));
|
|
411
451
|
const durationMs = params.now() - startedAt;
|
|
412
452
|
if (!response.ok || body.success === false) {
|
|
453
|
+
const message = body.error?.message || `HTTP ${response.status}`;
|
|
413
454
|
return {
|
|
414
455
|
id: check.id,
|
|
415
456
|
service: check.service,
|
|
416
457
|
target: check.target,
|
|
417
|
-
status: 'fail',
|
|
458
|
+
status: shouldDegradeFailedResponse(check, message) ? 'degraded' : 'fail',
|
|
418
459
|
durationMs,
|
|
419
460
|
httpStatus: response.status,
|
|
420
|
-
message
|
|
461
|
+
message,
|
|
421
462
|
};
|
|
422
463
|
}
|
|
423
464
|
const count = typeof body.meta?.total === 'number' ? body.meta.total : toCount(body.data);
|