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.
Files changed (42) hide show
  1. package/README.md +83 -4
  2. package/dist/api/actionsProxy.js +116 -21
  3. package/dist/api/compareHandlers.js +26 -0
  4. package/dist/api/configStatus.js +5 -1
  5. package/dist/api/gs25Handlers.js +45 -10
  6. package/dist/api/healthChecks.js +50 -9
  7. package/dist/api/placesHandlers.js +44 -0
  8. package/dist/api/routes/compareRoutes.js +9 -0
  9. package/dist/api/routes/healthRoutes.js +1 -1
  10. package/dist/api/routes/placesRoutes.js +9 -0
  11. package/dist/cli/commandOptions.js +65 -6
  12. package/dist/cli/commands/convenience.js +38 -0
  13. package/dist/cli.js +5 -1
  14. package/dist/cliHelp.js +25 -6
  15. package/dist/cliRenderer.js +24 -2
  16. package/dist/core/registry.js +33 -2
  17. package/dist/index.js +22 -3
  18. package/dist/pages/openapiFullSpec.js +6 -0
  19. package/dist/pages/openapiSpecActions.js +315 -42
  20. package/dist/pages/openapiSpecPathsCompare.js +59 -0
  21. package/dist/pages/openapiSpecPathsPlaces.js +85 -0
  22. package/dist/pages/promptReferenceText.js +5 -0
  23. package/dist/pages/promptRetailCinemaText.js +41 -3
  24. package/dist/services/compare/client.js +133 -0
  25. package/dist/services/compare/index.js +17 -0
  26. package/dist/services/compare/tools/compareProducts.js +45 -0
  27. package/dist/services/compare/types.js +1 -0
  28. package/dist/services/gs25/api.js +3 -0
  29. package/dist/services/gs25/client.js +211 -35
  30. package/dist/services/gs25/index.js +11 -3
  31. package/dist/services/gs25/tools/checkInventory.js +39 -12
  32. package/dist/services/gs25/tools/findNearbyStores.js +19 -5
  33. package/dist/services/gs25/tools/searchProducts.js +11 -4
  34. package/dist/services/oliveyoung/client.js +90 -41
  35. package/dist/services/places/client.js +116 -0
  36. package/dist/services/places/index.js +23 -0
  37. package/dist/services/places/tools/searchNearby.js +56 -0
  38. package/dist/services/places/types.js +1 -0
  39. package/dist/utils/zyte.js +45 -18
  40. package/openapi.json +29 -0
  41. package/openapi.yaml +20 -0
  42. 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
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
23
64
  [![Cloudflare Workers](https://img.shields.io/badge/Cloudflare-Workers-F38020.svg)](https://workers.cloudflare.com/)
24
65
  [![MCP](https://img.shields.io/badge/MCP-Protocol-8B5CF6.svg)](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-19, 80일)</h3>
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-19T16:35:34.414Z" alt="Cloudflare Workers 호출량 그래프 (2026-03-01 ~ 2026-05-19)" width="100%">
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-20 01:35 KST</sub>
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
@@ -3,34 +3,129 @@
3
3
  */
4
4
  export const ACTION_QUERY_DEFINITIONS = [
5
5
  { action: 'daisoSearchProducts', summary: '다이소 제품 검색', targetPath: '/api/daiso/products' },
6
- { action: 'daisoGetProduct', summary: '다이소 제품 상세 조회', targetPath: '/api/daiso/products/:productId', pathParam: 'productId' },
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
- { action: 'daisoCheckInventory', summary: '다이소 재고 조회', targetPath: '/api/daiso/inventory' },
9
- { action: 'daisoGetDisplayLocation', summary: '다이소 진열 위치 조회', targetPath: '/api/daiso/display-location' },
10
- { action: 'oliveyoungSearchProducts', summary: '올리브영 상품 검색', targetPath: '/api/oliveyoung/products' },
11
- { action: 'oliveyoungFindStores', summary: '올리브영 매장 검색', targetPath: '/api/oliveyoung/stores' },
12
- { action: 'oliveyoungCheckInventory', summary: '올리브영 재고 조회', targetPath: '/api/oliveyoung/inventory' },
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
- { action: 'emart24SearchProducts', summary: '이마트24 상품 검색', targetPath: '/api/emart24/products' },
17
- { action: 'emart24CheckInventory', summary: '이마트24 재고 조회', targetPath: '/api/emart24/inventory' },
18
- { action: 'lottemartFindStores', summary: '롯데마트 매장 검색', targetPath: '/api/lottemart/stores' },
19
- { action: 'lottemartSearchProducts', summary: '롯데마트 상품 검색', targetPath: '/api/lottemart/products' },
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
- { action: 'sevenelevenSearchProducts', summary: '세븐일레븐 상품 검색', targetPath: '/api/seveneleven/products' },
24
- { action: 'sevenelevenSearchStores', summary: '세븐일레븐 매장 검색', targetPath: '/api/seveneleven/stores' },
25
- { action: 'sevenelevenCheckInventory', summary: '세븐일레븐 재고 조회', targetPath: '/api/seveneleven/inventory' },
26
- { action: 'sevenelevenGetPopwords', summary: '세븐일레븐 인기 검색어 조회', targetPath: '/api/seveneleven/popwords' },
27
- { action: 'sevenelevenGetCatalog', summary: '세븐일레븐 카탈로그 조회', targetPath: '/api/seveneleven/catalog' },
28
- { action: 'megaboxFindTheaters', summary: '메가박스 지점 검색', targetPath: '/api/megabox/theaters' },
29
- { action: 'megaboxListMovies', summary: '메가박스 영화 목록 조회', targetPath: '/api/megabox/movies' },
30
- { action: 'megaboxGetSeats', summary: '메가박스 잔여 좌석 조회', targetPath: '/api/megabox/seats' },
31
- { action: 'lottecinemaFindTheaters', summary: '롯데시네마 지점 검색', targetPath: '/api/lottecinema/theaters' },
32
- { action: 'lottecinemaListMovies', summary: '롯데시네마 영화 목록 조회', targetPath: '/api/lottecinema/movies' },
33
- { action: 'lottecinemaGetSeats', summary: '롯데시네마 잔여 좌석 조회', targetPath: '/api/lottecinema/seats' },
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
+ }
@@ -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),
@@ -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') && keyword.trim().length > 0) {
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 && typeof latitude === 'number' && typeof longitude === 'number') {
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, { timeout: 20000 });
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') && storeKeyword.trim().length > 0) {
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, { timeout: 20000 });
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 || firstProduct?.shortItemName ||
231
- filtered.find((item) => item.searchItemName.length > 0)?.searchItemName || null;
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);
@@ -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 = 6;
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, params.timeoutMs, cacheBustValue), {
364
- signal: AbortSignal.timeout(params.timeoutMs),
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: body.error?.message || `${path} CLI 계약 응답이 올바르지 않습니다.`,
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: `${CLI_CONTRACT_PATHS.length} CLI contract endpoint(s) passed`,
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: body.error?.message || `HTTP ${response.status}`,
461
+ message,
421
462
  };
422
463
  }
423
464
  const count = typeof body.meta?.total === 'number' ? body.meta.total : toCount(body.data);