daiso 1.0.6 → 1.0.7

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 (35) hide show
  1. package/README.md +60 -4
  2. package/dist/api/actionsProxy.js +111 -21
  3. package/dist/api/configStatus.js +5 -1
  4. package/dist/api/gs25Handlers.js +45 -10
  5. package/dist/api/healthChecks.js +50 -9
  6. package/dist/api/placesHandlers.js +44 -0
  7. package/dist/api/routes/healthRoutes.js +1 -1
  8. package/dist/api/routes/placesRoutes.js +9 -0
  9. package/dist/cli/commandOptions.js +64 -6
  10. package/dist/cli/commands/convenience.js +20 -0
  11. package/dist/cli.js +3 -1
  12. package/dist/cliHelp.js +14 -6
  13. package/dist/cliRenderer.js +9 -2
  14. package/dist/core/registry.js +21 -2
  15. package/dist/index.js +17 -3
  16. package/dist/pages/openapiFullSpec.js +3 -0
  17. package/dist/pages/openapiSpecActions.js +308 -42
  18. package/dist/pages/openapiSpecPathsPlaces.js +85 -0
  19. package/dist/pages/promptReferenceText.js +3 -0
  20. package/dist/pages/promptRetailCinemaText.js +22 -1
  21. package/dist/services/gs25/api.js +3 -0
  22. package/dist/services/gs25/client.js +211 -35
  23. package/dist/services/gs25/index.js +11 -3
  24. package/dist/services/gs25/tools/checkInventory.js +39 -12
  25. package/dist/services/gs25/tools/findNearbyStores.js +19 -5
  26. package/dist/services/gs25/tools/searchProducts.js +11 -4
  27. package/dist/services/oliveyoung/client.js +90 -41
  28. package/dist/services/places/client.js +116 -0
  29. package/dist/services/places/index.js +23 -0
  30. package/dist/services/places/tools/searchNearby.js +56 -0
  31. package/dist/services/places/types.js +1 -0
  32. package/dist/utils/zyte.js +45 -18
  33. package/openapi.json +19 -0
  34. package/openapi.yaml +13 -0
  35. 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,42 @@
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>다이소, 올리브영, 롯데마트</td>
41
+ <td>상품, 매장, 재고</td>
42
+ </tr>
43
+ <tr>
44
+ <td>편의점</td>
45
+ <td>GS25, 세븐일레븐, CU, 이마트24</td>
46
+ <td>상품, 매장, 재고, 인기검색어, 카탈로그</td>
47
+ </tr>
48
+ <tr>
49
+ <td>영화관</td>
50
+ <td>CGV, 메가박스, 롯데시네마</td>
51
+ <td>극장, 영화, 시간표, 잔여 좌석</td>
52
+ </tr>
53
+ </tbody>
54
+ </table>
55
+
56
+ <br>
57
+
22
58
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
23
59
  [![Cloudflare Workers](https://img.shields.io/badge/Cloudflare-Workers-F38020.svg)](https://workers.cloudflare.com/)
24
60
  [![MCP](https://img.shields.io/badge/MCP-Protocol-8B5CF6.svg)](https://modelcontextprotocol.io/)
@@ -29,11 +65,11 @@
29
65
  **[실시간 서비스 상태 보기](https://aka-page.betteruptime.com/)**
30
66
 
31
67
  <!-- WORKERS_INVOCATIONS_CHART:START -->
32
- <h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-19, 80일)</h3>
68
+ <h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-21, 82일)</h3>
33
69
 
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%">
70
+ <img src="./assets/analytics/workers-invocations.png?v=2026-05-23T04:50:41.937Z" alt="Cloudflare Workers 호출량 그래프 (2026-03-01 ~ 2026-05-21)" width="100%">
35
71
 
36
- <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-20 01:35 KST</sub>
72
+ <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-23 13:50 KST</sub>
37
73
 
38
74
  <!-- WORKERS_INVOCATIONS_CHART:END -->
39
75
 
@@ -78,6 +114,8 @@ GS25 mcp로 강남 근처 매장과 오감자 재고 알려줘
78
114
  세븐일레븐 mcp로 안산 중앙역 근처 매장 찾아줘
79
115
  세븐일레븐 mcp로 안산 중앙역 근처 세븐일레븐에서 핫식스 재고 알려줘
80
116
  세븐일레븐 mcp로 인기 검색어와 카탈로그 요약 알려줘
117
+ 강남역 근처 카페 찾아줘
118
+ 성수동 근처 브런치 음식점 찾아줘
81
119
  메가박스 mcp로 강남점 영화와 잔여 좌석 알려줘
82
120
  롯데시네마 mcp로 월드타워 근처 지점과 상영 영화 알려줘
83
121
  롯데시네마 mcp로 월드타워 잔여 좌석 알려줘
@@ -171,6 +209,19 @@ AI: (https://mcp.aka.page/api/cgv/timetable?playDate=20260315&keyword=안산%20
171
209
 
172
210
  <br>
173
211
 
212
+ ### 바로 실행해보기
213
+
214
+ AI 앱 연동 없이 터미널에서 바로 확인할 수 있습니다.
215
+
216
+ ```bash
217
+ npx daiso products 수납박스 --json
218
+ npx daiso places 강남역 --category cafe --limit 5 --json
219
+ npx daiso gs25-inventory 오감자 --storeKeyword 강남 --storeLimit 5 --json
220
+ npx daiso get /api/cgv/movies --playDate 20260520 --theaterCode 0056 --json
221
+ ```
222
+
223
+ <br>
224
+
174
225
  ### MCP 서버 URL / CLI (고급)
175
226
 
176
227
  AI 앱 대신 직접 연결하거나 스크립트에서 사용할 때만 참고하세요.
@@ -203,6 +254,8 @@ npx daiso product 1034604
203
254
  npx daiso stores 강남역
204
255
  npx daiso inventory 1034604 --keyword 강남역
205
256
  npx daiso display-location 1034604 04515
257
+ npx daiso places 강남역 --category cafe --limit 5
258
+ npx daiso places 성수동 --keyword 브런치 --limit 5
206
259
  npx daiso cu-stores 강남
207
260
  npx daiso cu-inventory 과자 --storeKeyword 강남
208
261
  npx daiso lottemart-stores 잠실 --area 서울 --limit 10
@@ -249,11 +302,14 @@ MCP 도구 응답은 각 서비스의 원본 필드를 유지하면서, 클라
249
302
  - `standard.products`: 상품/영화 목록을 `code`, `name`, `price`, `imageUrl`, `raw` 형태로 정규화합니다.
250
303
  - `standard.stores`: 매장 목록을 `code`, `name`, `address`, `distanceMeters`, `raw` 형태로 정규화합니다.
251
304
  - `standard.theaters`: 영화관 목록을 `code`, `name`, `address`, `distanceMeters`, `raw` 형태로 정규화합니다.
305
+ - `standard.places`: 음식점/카페 같은 장소 목록을 `name`, `address`, `raw` 중심으로 정규화합니다.
252
306
 
253
307
  ### 운영 헬스 체크
254
308
 
255
309
  공개 상태 페이지: **[Daiso MCP Status](https://aka-page.betteruptime.com/)**
256
310
 
311
+ 주변 음식점/카페 검색은 네이버 지역 검색 API를 사용합니다. 로컬 실행이나 배포 환경에는 `NAVER_CLIENT_ID`, `NAVER_CLIENT_SECRET`을 설정하세요.
312
+
257
313
  서비스별 API 상태를 즉시 확인할 때는 `GET /api/health/checks`를 사용합니다. 이 엔드포인트는 `HEALTH_CHECK_SECRET` 환경 변수가 설정되어 있어야 하며, 요청에는 `Authorization: Bearer <secret>` 또는 `x-health-check-key: <secret>` 헤더가 필요합니다. 내부 체크 요청의 기준 URL은 `HEALTH_CHECK_BASE_URL`로 지정할 수 있습니다.
258
314
 
259
315
  ```bash
@@ -3,34 +3,124 @@
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: 'placesSearchNearby',
91
+ summary: '음식점/카페 등 주변 장소 검색',
92
+ targetPath: '/api/places/search',
93
+ },
94
+ {
95
+ action: 'megaboxFindTheaters',
96
+ summary: '메가박스 지점 검색',
97
+ targetPath: '/api/megabox/theaters',
98
+ },
99
+ {
100
+ action: 'megaboxListMovies',
101
+ summary: '메가박스 영화 목록 조회',
102
+ targetPath: '/api/megabox/movies',
103
+ },
104
+ {
105
+ action: 'megaboxGetSeats',
106
+ summary: '메가박스 잔여 좌석 조회',
107
+ targetPath: '/api/megabox/seats',
108
+ },
109
+ {
110
+ action: 'lottecinemaFindTheaters',
111
+ summary: '롯데시네마 지점 검색',
112
+ targetPath: '/api/lottecinema/theaters',
113
+ },
114
+ {
115
+ action: 'lottecinemaListMovies',
116
+ summary: '롯데시네마 영화 목록 조회',
117
+ targetPath: '/api/lottecinema/movies',
118
+ },
119
+ {
120
+ action: 'lottecinemaGetSeats',
121
+ summary: '롯데시네마 잔여 좌석 조회',
122
+ targetPath: '/api/lottecinema/seats',
123
+ },
34
124
  { action: 'cgvFindTheaters', summary: 'CGV 극장 검색', targetPath: '/api/cgv/theaters' },
35
125
  { action: 'cgvSearchMovies', summary: 'CGV 영화 목록 조회', targetPath: '/api/cgv/movies' },
36
126
  { action: 'cgvGetTimetable', summary: 'CGV 시간표 조회', targetPath: '/api/cgv/timetable' },
@@ -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);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 장소 검색 GET API 핸들러
3
+ */
4
+ import { errorResponse, successResponse } from './response.js';
5
+ import { searchNaverLocalPlaces } from '../services/places/client.js';
6
+ import { getErrorMessage } from '../core/errors.js';
7
+ const PLACE_CATEGORIES = new Set(['restaurant', 'cafe', 'food', 'dessert', 'all']);
8
+ function parseCategory(value) {
9
+ if (value && PLACE_CATEGORIES.has(value)) {
10
+ return value;
11
+ }
12
+ return 'all';
13
+ }
14
+ export async function handlePlacesSearch(c) {
15
+ const location = c.req.query('location') || '';
16
+ const keyword = c.req.query('keyword') || '';
17
+ const limit = Number.parseInt(c.req.query('limit') || '5', 10);
18
+ const start = Number.parseInt(c.req.query('start') || '1', 10);
19
+ const sort = c.req.query('sort') === 'comment' ? 'comment' : 'random';
20
+ const category = parseCategory(c.req.query('category'));
21
+ if (location.trim().length === 0 && keyword.trim().length === 0) {
22
+ return errorResponse(c, 'MISSING_QUERY', '위치(location) 또는 검색어(keyword)를 입력해주세요.');
23
+ }
24
+ try {
25
+ const result = await searchNaverLocalPlaces({
26
+ naverClientId: c.env?.NAVER_CLIENT_ID,
27
+ naverClientSecret: c.env?.NAVER_CLIENT_SECRET,
28
+ location,
29
+ keyword,
30
+ category,
31
+ limit,
32
+ start,
33
+ sort,
34
+ });
35
+ return successResponse(c, result, {
36
+ total: result.totalCount,
37
+ page: start,
38
+ pageSize: result.count,
39
+ });
40
+ }
41
+ catch (error) {
42
+ return errorResponse(c, 'PLACES_SEARCH_FAILED', getErrorMessage(error), 500);
43
+ }
44
+ }
@@ -41,7 +41,7 @@ export function registerHealthRoutes(app) {
41
41
  const baseUrl = c.req.query('baseUrl')?.trim() || c.env?.HEALTH_CHECK_BASE_URL?.trim() || new URL(c.req.url).origin;
42
42
  const transport = c.req.query('transport') || c.env?.HEALTH_CHECK_TRANSPORT || 'internal';
43
43
  const cacheBust = parseBoolean(c.req.query('cacheBust')) || transport === 'network';
44
- const fetchImpl = c.env?.HEALTH_CHECK_BASE_URL && transport !== 'network'
44
+ const fetchImpl = transport !== 'network'
45
45
  ? async (input, init) => app.fetch(new Request(input, init), c.env)
46
46
  : undefined;
47
47
  const result = await runHealthChecks({
@@ -0,0 +1,9 @@
1
+ import { withEdgeCache } from '../../utils/cache.js';
2
+ import { handlePlacesSearch } from '../placesHandlers.js';
3
+ export function registerPlacesRoutes(app) {
4
+ app.get('/api/places/search', async (c) => withEdgeCache(c.req.url, {
5
+ ttlSeconds: 60 * 60,
6
+ staleWhileRevalidateSeconds: 60 * 10,
7
+ keyPrefix: 'places-search-v1',
8
+ }, () => handlePlacesSearch(c)));
9
+ }