daiso 1.0.5 → 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 (64) hide show
  1. package/README.md +101 -7
  2. package/dist/api/actionsProxy.js +111 -21
  3. package/dist/api/configStatus.js +23 -0
  4. package/dist/api/gs25Handlers.js +68 -22
  5. package/dist/api/handlers.js +32 -3
  6. package/dist/api/healthChecks.js +233 -20
  7. package/dist/api/lottemartHandlers.js +17 -1
  8. package/dist/api/placesHandlers.js +44 -0
  9. package/dist/api/response.js +2 -0
  10. package/dist/api/routes/healthRoutes.js +20 -3
  11. package/dist/api/routes/placesRoutes.js +9 -0
  12. package/dist/bin.js +0 -0
  13. package/dist/cli/args.js +21 -0
  14. package/dist/cli/commandOptions.js +96 -0
  15. package/dist/cli/commands/convenience.js +73 -1
  16. package/dist/cli/commands/daiso.js +18 -2
  17. package/dist/cli/http.js +58 -0
  18. package/dist/cli/interactiveItemSearch.js +4 -2
  19. package/dist/cli/interactivePrompt.js +3 -2
  20. package/dist/cli.js +25 -1
  21. package/dist/cliHelp.js +20 -6
  22. package/dist/cliInteractive.js +75 -6
  23. package/dist/cliPicker.js +1 -0
  24. package/dist/cliRenderer.js +93 -3
  25. package/dist/core/errors.js +66 -0
  26. package/dist/core/registry.js +246 -3
  27. package/dist/index.js +19 -4
  28. package/dist/pages/openapiFullSpec.js +3 -0
  29. package/dist/pages/openapiSpecActions.js +308 -42
  30. package/dist/pages/openapiSpecPathsCu.js +7 -0
  31. package/dist/pages/openapiSpecPathsLotteMart.js +14 -0
  32. package/dist/pages/openapiSpecPathsOliveyoung.js +35 -0
  33. package/dist/pages/openapiSpecPathsPlaces.js +85 -0
  34. package/dist/pages/promptReferenceText.js +16 -7
  35. package/dist/pages/promptRetailCinemaText.js +24 -2
  36. package/dist/services/daiso/client.js +8 -0
  37. package/dist/services/daiso/index.js +2 -0
  38. package/dist/services/daiso/tools/checkInventory.js +4 -2
  39. package/dist/services/daiso/tools/findInventoryByName.js +222 -0
  40. package/dist/services/daiso/tools/getDisplayLocation.js +2 -2
  41. package/dist/services/emart24/client.js +9 -0
  42. package/dist/services/emart24/tools/searchProducts.js +27 -17
  43. package/dist/services/gs25/api.js +3 -0
  44. package/dist/services/gs25/client.js +216 -30
  45. package/dist/services/gs25/index.js +11 -3
  46. package/dist/services/gs25/tools/checkInventory.js +39 -12
  47. package/dist/services/gs25/tools/findNearbyStores.js +19 -5
  48. package/dist/services/gs25/tools/searchProducts.js +39 -25
  49. package/dist/services/lottemart/client.js +10 -1
  50. package/dist/services/lottemart/tools/searchProducts.js +8 -1
  51. package/dist/services/lottemart/zetta.js +1 -1
  52. package/dist/services/oliveyoung/client.js +206 -86
  53. package/dist/services/oliveyoung/tools/checkInventory.js +7 -2
  54. package/dist/services/places/client.js +116 -0
  55. package/dist/services/places/index.js +23 -0
  56. package/dist/services/places/tools/searchNearby.js +56 -0
  57. package/dist/services/places/types.js +1 -0
  58. package/dist/services/seveneleven/client.js +7 -0
  59. package/dist/services/seveneleven/tools/searchProducts.js +29 -18
  60. package/dist/utils/http.js +82 -10
  61. package/dist/utils/zyte.js +74 -26
  62. package/openapi.json +22 -3
  63. package/openapi.yaml +16 -3
  64. package/package.json +28 -9
package/README.md CHANGED
@@ -5,9 +5,53 @@
5
5
  <br>
6
6
  <br>
7
7
 
8
- # Daiso MCP Server
8
+ # Daiso MCP 및 Skill
9
9
 
10
- 다이소(제품/매장/재고), 롯데마트(매장/상품), GS25(매장/상품/재고), 세븐일레븐(상품/매장/재고/인기검색어/카탈로그), CU(매장/재고), 이마트24(매장/상품/재고), 올리브영(매장/재고), 메가박스(지점/영화/시간표/좌석), 롯데시네마(지점/영화/좌석), CGV(극장/영화/시간표) 조회 기능을 AI에 연결합니다.
10
+ 다이소(제품/매장/재고), 주변 음식점/카페, 롯데마트(매장/상품), GS25(매장/상품/재고), 세븐일레븐(상품/매장/재고/인기검색어/카탈로그), CU(매장/재고), 이마트24(매장/상품/재고), 올리브영(매장/재고), 메가박스(지점/영화/시간표/좌석), 롯데시네마(지점/영화/좌석), CGV(극장/영화/시간표) 조회 기능을 MCP, CLI, Codex Skill로 AI에 연결합니다.
11
+
12
+ <br>
13
+
14
+ **MCP:** `https://mcp.aka.page` · **CLI:** `npx daiso` · **Skill:** `clawhub install daiso-cli`
15
+
16
+ **ClawHub:** [clawhub.ai/hmmhmmhm/daiso-cli](https://clawhub.ai/hmmhmmhm/daiso-cli)
17
+
18
+ 한국 로컬 리테일과 영화관 조회를 MCP, CLI, Codex Skill로 연결하는 도구입니다.
19
+
20
+ <br>
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>
11
55
 
12
56
  <br>
13
57
 
@@ -21,11 +65,11 @@
21
65
  **[실시간 서비스 상태 보기](https://aka-page.betteruptime.com/)**
22
66
 
23
67
  <!-- WORKERS_INVOCATIONS_CHART:START -->
24
- <h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-16, 77일)</h3>
68
+ <h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-21, 82일)</h3>
25
69
 
26
- <img src="./assets/analytics/workers-invocations.png?v=2026-05-16T15:44:19.119Z" alt="Cloudflare Workers 호출량 그래프 (2026-03-01 ~ 2026-05-16)" 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%">
27
71
 
28
- <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-17 00:44 KST</sub>
72
+ <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-23 13:50 KST</sub>
29
73
 
30
74
  <!-- WORKERS_INVOCATIONS_CHART:END -->
31
75
 
@@ -70,6 +114,8 @@ GS25 mcp로 강남 근처 매장과 오감자 재고 알려줘
70
114
  세븐일레븐 mcp로 안산 중앙역 근처 매장 찾아줘
71
115
  세븐일레븐 mcp로 안산 중앙역 근처 세븐일레븐에서 핫식스 재고 알려줘
72
116
  세븐일레븐 mcp로 인기 검색어와 카탈로그 요약 알려줘
117
+ 강남역 근처 카페 찾아줘
118
+ 성수동 근처 브런치 음식점 찾아줘
73
119
  메가박스 mcp로 강남점 영화와 잔여 좌석 알려줘
74
120
  롯데시네마 mcp로 월드타워 근처 지점과 상영 영화 알려줘
75
121
  롯데시네마 mcp로 월드타워 잔여 좌석 알려줘
@@ -163,6 +209,19 @@ AI: (https://mcp.aka.page/api/cgv/timetable?playDate=20260315&keyword=안산%20
163
209
 
164
210
  <br>
165
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
+
166
225
  ### MCP 서버 URL / CLI (고급)
167
226
 
168
227
  AI 앱 대신 직접 연결하거나 스크립트에서 사용할 때만 참고하세요.
@@ -195,6 +254,8 @@ npx daiso product 1034604
195
254
  npx daiso stores 강남역
196
255
  npx daiso inventory 1034604 --keyword 강남역
197
256
  npx daiso display-location 1034604 04515
257
+ npx daiso places 강남역 --category cafe --limit 5
258
+ npx daiso places 성수동 --keyword 브런치 --limit 5
198
259
  npx daiso cu-stores 강남
199
260
  npx daiso cu-inventory 과자 --storeKeyword 강남
200
261
  npx daiso lottemart-stores 잠실 --area 서울 --limit 10
@@ -216,6 +277,17 @@ npx daiso get /api/cgv/movies --playDate 20260307 --theaterCode 0056
216
277
  npx daiso products 수납박스 --json
217
278
  ```
218
279
 
280
+ ### Codex Skill
281
+
282
+ 이 저장소는 MCP 서버뿐 아니라 에이전트가 `npx daiso` CLI를 직접 고를 수 있게 하는 Codex Skill도 제공합니다.
283
+
284
+ - ClawHub: `clawhub install daiso-cli`
285
+ - 공개 페이지: `https://clawhub.ai/hmmhmmhm/daiso-cli`
286
+ - 스킬 파일: `skills/daiso-cli/SKILL.md`
287
+ - 명령 맵: `skills/daiso-cli/references/cli-command-map.md`
288
+ - 기본 원칙: 쉘 실행이 가능한 환경에서는 CLI를 우선 사용하고, AI 앱 연결이 필요할 때는 MCP 서버 URL `https://mcp.aka.page`를 사용합니다.
289
+ - 구조화 결과가 필요하면 스킬은 `npx daiso ... --json` 형태를 선택합니다.
290
+
219
291
  ### OpenAPI 스펙
220
292
 
221
293
  - OpenAI 챗봇 등록용 축약 스펙: `https://mcp.aka.page/openapi.json`
@@ -223,24 +295,45 @@ npx daiso products 수납박스 --json
223
295
  - 전체 개별 엔드포인트 스펙(JSON): `https://mcp.aka.page/openapi-full.json`
224
296
  - 전체 개별 엔드포인트 스펙(YAML): `https://mcp.aka.page/openapi-full.yaml`
225
297
 
298
+ ### MCP 표준 응답 모델
299
+
300
+ MCP 도구 응답은 각 서비스의 원본 필드를 유지하면서, 클라이언트가 공통 UI를 만들 수 있도록 `standard` 필드를 함께 제공합니다.
301
+
302
+ - `standard.products`: 상품/영화 목록을 `code`, `name`, `price`, `imageUrl`, `raw` 형태로 정규화합니다.
303
+ - `standard.stores`: 매장 목록을 `code`, `name`, `address`, `distanceMeters`, `raw` 형태로 정규화합니다.
304
+ - `standard.theaters`: 영화관 목록을 `code`, `name`, `address`, `distanceMeters`, `raw` 형태로 정규화합니다.
305
+ - `standard.places`: 음식점/카페 같은 장소 목록을 `name`, `address`, `raw` 중심으로 정규화합니다.
306
+
226
307
  ### 운영 헬스 체크
227
308
 
228
309
  공개 상태 페이지: **[Daiso MCP Status](https://aka-page.betteruptime.com/)**
229
310
 
311
+ 주변 음식점/카페 검색은 네이버 지역 검색 API를 사용합니다. 로컬 실행이나 배포 환경에는 `NAVER_CLIENT_ID`, `NAVER_CLIENT_SECRET`을 설정하세요.
312
+
230
313
  서비스별 API 상태를 즉시 확인할 때는 `GET /api/health/checks`를 사용합니다. 이 엔드포인트는 `HEALTH_CHECK_SECRET` 환경 변수가 설정되어 있어야 하며, 요청에는 `Authorization: Bearer <secret>` 또는 `x-health-check-key: <secret>` 헤더가 필요합니다. 내부 체크 요청의 기준 URL은 `HEALTH_CHECK_BASE_URL`로 지정할 수 있습니다.
231
314
 
232
315
  ```bash
233
316
  curl -H "Authorization: Bearer $HEALTH_CHECK_SECRET" \
234
- "https://mcp.aka.page/api/health/checks?check=lottemart.products&fresh=true&includeSamples=true"
317
+ "https://mcp.aka.page/api/health/checks?mode=full&fresh=true&includeSamples=true&timeoutMs=20000&slowThresholdMs=9000"
235
318
  ```
236
319
 
237
320
  주요 쿼리:
238
321
 
239
322
  - `service=gs25`: 특정 서비스만 확인
240
323
  - `check=lottemart.products`: 특정 체크만 확인
241
- - `mode=quick|deep`: 체크 모드 선택
324
+ - `mode=quick|deep|full`: 체크 모드 선택
242
325
  - `fresh=true`: 60초 캐시 우회
243
326
  - `includeSamples=true`: 첫 결과 이름 샘플 포함
327
+ - `timeoutMs=20000`: 체크별 요청 제한 시간
328
+ - `slowThresholdMs=9000`: 지정 시간보다 느린 성공 응답을 degraded로 표시
329
+
330
+ 상태 기준:
331
+
332
+ - `ok`: 필수 응답 구조와 최소 결과가 정상입니다.
333
+ - `degraded`: 기능은 살아 있지만 빈 결과, 느린 응답, 응답 구조 변화 등 확인이 필요합니다.
334
+ - `fail`: 외부 API 오류, 타임아웃, 인증 문제처럼 실제 장애로 봐야 합니다.
335
+
336
+ Better Stack 같은 외부 모니터링에서는 `fail`을 장애 알림 기준으로 보고, `degraded`는 느린 외부 API나 응답 품질 저하를 추적하는 경고 신호로 봅니다.
244
337
 
245
338
  배포 전 로컬에서 CLI 모드까지 확인할 때는 아래 명령을 사용합니다.
246
339
 
@@ -315,6 +408,7 @@ daiso 인터랙티브 모드
315
408
 
316
409
  - [@thecats1105](https://github.com/thecats1105): 다이소 진열 위치 조회 도구(`daiso_get_display_location`) 구현 및 API/테스트 연동
317
410
  - [@betterthanhajin](https://github.com/betterthanhajin): CGV 서비스 프로바이더 구현(극장/영화/시간표 도구, 라우트·스펙·테스트 추가)
411
+ - [@LLagoon3](https://github.com/LLagoon3): 다이소 매장 재고 조회 신규 인증 흐름 전환 및 관련 테스트 보강
318
412
  - [제로초님](https://youtube.com/shorts/ZgIqA1NCEp0?si=UW0pKsSpqmEi7lXG): 프로젝트 홍보 도움
319
413
 
320
414
  <br>
@@ -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' },
@@ -0,0 +1,23 @@
1
+ function isConfigured(value) {
2
+ return typeof value === 'string' && value.trim().length > 0;
3
+ }
4
+ export function buildConfigStatus(bindings) {
5
+ return {
6
+ googleMapsApiKey: {
7
+ configured: isConfigured(bindings?.GOOGLE_MAPS_API_KEY),
8
+ usedBy: ['gs25', 'cu', 'lottemart', 'megabox', 'lottecinema', 'cgv'],
9
+ },
10
+ zyteApiKey: {
11
+ configured: isConfigured(bindings?.ZYTE_API_KEY),
12
+ usedBy: ['oliveyoung', 'gs25', 'lottemart', 'cgv'],
13
+ },
14
+ naverLocalSearch: {
15
+ configured: isConfigured(bindings?.NAVER_CLIENT_ID) && isConfigured(bindings?.NAVER_CLIENT_SECRET),
16
+ usedBy: ['places'],
17
+ },
18
+ healthCheckSecret: {
19
+ configured: isConfigured(bindings?.HEALTH_CHECK_SECRET),
20
+ usedBy: ['health-checks'],
21
+ },
22
+ };
23
+ }
@@ -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,23 +168,35 @@ 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) {
146
- const baseStores = await fetchGs25Stores({
147
- serviceCode,
148
- }, {
149
- timeout: 20000,
171
+ if ((typeof latitude !== 'number' || typeof longitude !== 'number') &&
172
+ storeKeyword.trim().length > 0) {
173
+ const directGeocoded = await geocodeGs25Address(storeKeyword, {
174
+ timeout: 15000,
175
+ googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY,
150
176
  });
151
- const firstAddress = filterGs25StoresByKeyword(baseStores.stores, storeKeyword).find((store) => store.address.trim().length > 0)
152
- ?.address || '';
153
- if (firstAddress.length > 0) {
154
- const geocoded = await geocodeGs25Address(firstAddress, {
155
- timeout: 15000,
156
- googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY,
177
+ if (directGeocoded) {
178
+ latitude = directGeocoded.latitude;
179
+ longitude = directGeocoded.longitude;
180
+ geocodeUsed = true;
181
+ }
182
+ else {
183
+ const baseStores = await fetchGs25Stores({
184
+ serviceCode,
185
+ }, {
186
+ timeout: 20000,
187
+ zyteApiKey: c.env?.ZYTE_API_KEY,
157
188
  });
158
- if (geocoded) {
159
- latitude = geocoded.latitude;
160
- longitude = geocoded.longitude;
161
- geocodeUsed = true;
189
+ const firstAddress = filterGs25StoresByKeyword(baseStores.stores, storeKeyword).find((store) => store.address.trim().length > 0)?.address || '';
190
+ if (firstAddress.length > 0) {
191
+ const geocoded = await geocodeGs25Address(firstAddress, {
192
+ timeout: 15000,
193
+ googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY,
194
+ });
195
+ if (geocoded) {
196
+ latitude = geocoded.latitude;
197
+ longitude = geocoded.longitude;
198
+ geocodeUsed = true;
199
+ }
162
200
  }
163
201
  }
164
202
  }
@@ -177,10 +215,14 @@ export async function handleGs25CheckInventory(c) {
177
215
  useCache: false,
178
216
  }, {
179
217
  timeout: 20000,
218
+ zyteApiKey: c.env?.ZYTE_API_KEY,
180
219
  });
181
220
  }
182
221
  else {
183
- const searchProducts = await fetchGs25SearchProducts(keyword, { timeout: 20000 });
222
+ const searchProducts = await fetchGs25SearchProducts(keyword, {
223
+ timeout: 20000,
224
+ zyteApiKey: c.env?.ZYTE_API_KEY,
225
+ });
184
226
  firstProduct = searchProducts.find((p) => p.itemCode.length > 0);
185
227
  if (firstProduct) {
186
228
  resolvedItemCode = firstProduct.itemCode;
@@ -194,6 +236,7 @@ export async function handleGs25CheckInventory(c) {
194
236
  useCache: false,
195
237
  }, {
196
238
  timeout: 20000,
239
+ zyteApiKey: c.env?.ZYTE_API_KEY,
197
240
  });
198
241
  }
199
242
  else {
@@ -206,6 +249,7 @@ export async function handleGs25CheckInventory(c) {
206
249
  useCache: false,
207
250
  }, {
208
251
  timeout: 20000,
252
+ zyteApiKey: c.env?.ZYTE_API_KEY,
209
253
  });
210
254
  }
211
255
  }
@@ -216,8 +260,10 @@ export async function handleGs25CheckInventory(c) {
216
260
  const withDistance = attachDistanceToGs25Stores(filtered, latitude, longitude);
217
261
  const stores = sortGs25Stores(withDistance).slice(0, storeLimit);
218
262
  // 상품 정보: searchProducts에서 먼저 가져오고, 없으면 stores에서
219
- const productName = firstProduct?.itemName || firstProduct?.shortItemName ||
220
- 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;
221
267
  const productPrice = filtered.find((item) => item.searchItemSellPrice !== null)?.searchItemSellPrice ?? null;
222
268
  const inStockStoreCount = filtered.filter((item) => item.realStockQuantity > 0).length;
223
269
  const totalStockQuantity = filtered.reduce((sum, item) => sum + Math.max(item.realStockQuantity, 0), 0);
@@ -8,6 +8,19 @@ import { enrichOliveyoungProductsWithNearbyStoreInventory, fetchOliveyoungProduc
8
8
  import { fetchCuStock, fetchCuStores, geocodeCuAddress } from '../services/cu/client.js';
9
9
  import { errorResponse, successResponse } from './response.js';
10
10
  export { handleCheckInventory, handleFindStores, handleGetDisplayLocation, handleGetProduct, handleSearchProducts, } from './daisoHandlers.js';
11
+ function parseBooleanQuery(value, defaultValue = false) {
12
+ if (!value) {
13
+ return defaultValue;
14
+ }
15
+ const normalized = value.trim().toLowerCase();
16
+ if (normalized === 'true' || normalized === '1' || normalized === 'y') {
17
+ return true;
18
+ }
19
+ if (normalized === 'false' || normalized === '0' || normalized === 'n') {
20
+ return false;
21
+ }
22
+ return defaultValue;
23
+ }
11
24
  /**
12
25
  * 올리브영 상품 검색 API 핸들러
13
26
  * GET /api/oliveyoung/products?keyword={검색어}
@@ -18,6 +31,8 @@ export async function handleOliveyoungSearchProducts(c) {
18
31
  const size = parseInt(c.req.query('size') || '20');
19
32
  const sort = c.req.query('sort') || '01';
20
33
  const includeSoldOut = c.req.query('includeSoldOut') === 'true';
34
+ const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
35
+ const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000;
21
36
  if (keyword.trim().length === 0) {
22
37
  return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.');
23
38
  }
@@ -30,6 +45,7 @@ export async function handleOliveyoungSearchProducts(c) {
30
45
  includeSoldOut,
31
46
  }, {
32
47
  apiKey: c.env?.ZYTE_API_KEY,
48
+ timeout: timeoutMs,
33
49
  });
34
50
  return successResponse(c, {
35
51
  keyword,
@@ -54,6 +70,8 @@ export async function handleOliveyoungFindStores(c) {
54
70
  const lng = parseFloat(c.req.query('lng') || '126.978');
55
71
  const pageIdx = parseInt(c.req.query('pageIdx') || '1');
56
72
  const limit = parseInt(c.req.query('limit') || '20');
73
+ const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
74
+ const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000;
57
75
  try {
58
76
  const result = await fetchOliveyoungStores({
59
77
  latitude: lat,
@@ -62,6 +80,7 @@ export async function handleOliveyoungFindStores(c) {
62
80
  searchWords: keyword,
63
81
  }, {
64
82
  apiKey: c.env?.ZYTE_API_KEY,
83
+ timeout: timeoutMs,
65
84
  });
66
85
  return successResponse(c, {
67
86
  stores: result.stores.slice(0, limit),
@@ -86,6 +105,10 @@ export async function handleOliveyoungCheckInventory(c) {
86
105
  const sort = c.req.query('sort') || '01';
87
106
  const includeSoldOut = c.req.query('includeSoldOut') === 'true';
88
107
  const storeLimit = parseInt(c.req.query('storeLimit') || '10');
108
+ const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
109
+ const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000;
110
+ const parsedStockCheckLimit = parseInt(c.req.query('stockCheckLimit') || '5', 10);
111
+ const stockCheckLimit = Number.isFinite(parsedStockCheckLimit) && parsedStockCheckLimit >= 0 ? parsedStockCheckLimit : 5;
89
112
  if (!keyword || keyword.trim().length === 0) {
90
113
  return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.');
91
114
  }
@@ -98,6 +121,7 @@ export async function handleOliveyoungCheckInventory(c) {
98
121
  searchWords: storeKeyword,
99
122
  }, {
100
123
  apiKey: c.env.ZYTE_API_KEY,
124
+ timeout: timeoutMs,
101
125
  }),
102
126
  fetchOliveyoungProducts({
103
127
  keyword,
@@ -107,15 +131,17 @@ export async function handleOliveyoungCheckInventory(c) {
107
131
  includeSoldOut,
108
132
  }, {
109
133
  apiKey: c.env.ZYTE_API_KEY,
134
+ timeout: timeoutMs,
110
135
  }),
111
136
  ]);
112
137
  const enrichedInventory = await enrichOliveyoungProductsWithNearbyStoreInventory(productResult.products, {
113
138
  latitude: lat,
114
139
  longitude: lng,
115
140
  storeKeyword,
116
- maxProducts: Math.min(productResult.products.length, 5),
141
+ maxProducts: Math.min(productResult.products.length, stockCheckLimit),
117
142
  }, {
118
143
  apiKey: c.env.ZYTE_API_KEY,
144
+ timeout: timeoutMs,
119
145
  });
120
146
  const inStockCount = enrichedInventory.products.filter((product) => product.inStock).length;
121
147
  return successResponse(c, {
@@ -191,6 +217,7 @@ export async function handleCuCheckInventory(c) {
191
217
  const offset = parseInt(c.req.query('offset') || '0');
192
218
  const searchSort = c.req.query('searchSort') || 'recom';
193
219
  const storeLimit = parseInt(c.req.query('storeLimit') || '10');
220
+ const storeCheck = parseBooleanQuery(c.req.query('storeCheck'), true);
194
221
  if (!keyword || keyword.trim().length === 0) {
195
222
  return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.');
196
223
  }
@@ -207,9 +234,11 @@ export async function handleCuCheckInventory(c) {
207
234
  const hasInputLocation = typeof lat === 'number' && typeof lng === 'number';
208
235
  const resolvedLat = hasInputLocation ? lat : undefined;
209
236
  const resolvedLng = hasInputLocation ? lng : undefined;
210
- let storeResult = null;
237
+ let storeResult = storeCheck
238
+ ? null
239
+ : { totalCount: 0, stores: [] };
211
240
  // 좌표 미입력 + 매장 키워드 입력 시, 키워드 기반 매장 검색 결과를 우선 사용합니다.
212
- if (!hasInputLocation && storeKeyword.trim().length > 0) {
241
+ if (storeCheck && !hasInputLocation && storeKeyword.trim().length > 0) {
213
242
  const keywordStoreResult = await fetchCuStores({
214
243
  searchWord: storeKeyword,
215
244
  }, {