daiso 1.0.5 → 1.0.6

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 (51) hide show
  1. package/README.md +45 -7
  2. package/dist/api/configStatus.js +19 -0
  3. package/dist/api/gs25Handlers.js +25 -14
  4. package/dist/api/handlers.js +32 -3
  5. package/dist/api/healthChecks.js +185 -13
  6. package/dist/api/lottemartHandlers.js +17 -1
  7. package/dist/api/response.js +2 -0
  8. package/dist/api/routes/healthRoutes.js +20 -3
  9. package/dist/bin.js +0 -0
  10. package/dist/cli/args.js +21 -0
  11. package/dist/cli/commandOptions.js +38 -0
  12. package/dist/cli/commands/convenience.js +53 -1
  13. package/dist/cli/commands/daiso.js +18 -2
  14. package/dist/cli/http.js +58 -0
  15. package/dist/cli/interactiveItemSearch.js +4 -2
  16. package/dist/cli/interactivePrompt.js +3 -2
  17. package/dist/cli.js +22 -0
  18. package/dist/cliHelp.js +6 -0
  19. package/dist/cliInteractive.js +75 -6
  20. package/dist/cliPicker.js +1 -0
  21. package/dist/cliRenderer.js +86 -3
  22. package/dist/core/errors.js +66 -0
  23. package/dist/core/registry.js +227 -3
  24. package/dist/index.js +2 -1
  25. package/dist/pages/openapiSpecActions.js +3 -3
  26. package/dist/pages/openapiSpecPathsCu.js +7 -0
  27. package/dist/pages/openapiSpecPathsLotteMart.js +14 -0
  28. package/dist/pages/openapiSpecPathsOliveyoung.js +35 -0
  29. package/dist/pages/promptReferenceText.js +13 -7
  30. package/dist/pages/promptRetailCinemaText.js +2 -1
  31. package/dist/services/daiso/client.js +8 -0
  32. package/dist/services/daiso/index.js +2 -0
  33. package/dist/services/daiso/tools/checkInventory.js +4 -2
  34. package/dist/services/daiso/tools/findInventoryByName.js +222 -0
  35. package/dist/services/daiso/tools/getDisplayLocation.js +2 -2
  36. package/dist/services/emart24/client.js +9 -0
  37. package/dist/services/emart24/tools/searchProducts.js +27 -17
  38. package/dist/services/gs25/client.js +10 -0
  39. package/dist/services/gs25/tools/searchProducts.js +28 -21
  40. package/dist/services/lottemart/client.js +10 -1
  41. package/dist/services/lottemart/tools/searchProducts.js +8 -1
  42. package/dist/services/lottemart/zetta.js +1 -1
  43. package/dist/services/oliveyoung/client.js +124 -53
  44. package/dist/services/oliveyoung/tools/checkInventory.js +7 -2
  45. package/dist/services/seveneleven/client.js +7 -0
  46. package/dist/services/seveneleven/tools/searchProducts.js +29 -18
  47. package/dist/utils/http.js +82 -10
  48. package/dist/utils/zyte.js +47 -26
  49. package/openapi.json +3 -3
  50. package/openapi.yaml +3 -3
  51. package/package.json +26 -8
package/README.md CHANGED
@@ -5,9 +5,17 @@
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로 연결하는 도구입니다.
11
19
 
12
20
  <br>
13
21
 
@@ -21,11 +29,11 @@
21
29
  **[실시간 서비스 상태 보기](https://aka-page.betteruptime.com/)**
22
30
 
23
31
  <!-- WORKERS_INVOCATIONS_CHART:START -->
24
- <h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-16, 77일)</h3>
32
+ <h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-19, 80일)</h3>
25
33
 
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%">
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%">
27
35
 
28
- <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-17 00:44 KST</sub>
36
+ <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-20 01:35 KST</sub>
29
37
 
30
38
  <!-- WORKERS_INVOCATIONS_CHART:END -->
31
39
 
@@ -216,6 +224,17 @@ npx daiso get /api/cgv/movies --playDate 20260307 --theaterCode 0056
216
224
  npx daiso products 수납박스 --json
217
225
  ```
218
226
 
227
+ ### Codex Skill
228
+
229
+ 이 저장소는 MCP 서버뿐 아니라 에이전트가 `npx daiso` CLI를 직접 고를 수 있게 하는 Codex Skill도 제공합니다.
230
+
231
+ - ClawHub: `clawhub install daiso-cli`
232
+ - 공개 페이지: `https://clawhub.ai/hmmhmmhm/daiso-cli`
233
+ - 스킬 파일: `skills/daiso-cli/SKILL.md`
234
+ - 명령 맵: `skills/daiso-cli/references/cli-command-map.md`
235
+ - 기본 원칙: 쉘 실행이 가능한 환경에서는 CLI를 우선 사용하고, AI 앱 연결이 필요할 때는 MCP 서버 URL `https://mcp.aka.page`를 사용합니다.
236
+ - 구조화 결과가 필요하면 스킬은 `npx daiso ... --json` 형태를 선택합니다.
237
+
219
238
  ### OpenAPI 스펙
220
239
 
221
240
  - OpenAI 챗봇 등록용 축약 스펙: `https://mcp.aka.page/openapi.json`
@@ -223,6 +242,14 @@ npx daiso products 수납박스 --json
223
242
  - 전체 개별 엔드포인트 스펙(JSON): `https://mcp.aka.page/openapi-full.json`
224
243
  - 전체 개별 엔드포인트 스펙(YAML): `https://mcp.aka.page/openapi-full.yaml`
225
244
 
245
+ ### MCP 표준 응답 모델
246
+
247
+ MCP 도구 응답은 각 서비스의 원본 필드를 유지하면서, 클라이언트가 공통 UI를 만들 수 있도록 `standard` 필드를 함께 제공합니다.
248
+
249
+ - `standard.products`: 상품/영화 목록을 `code`, `name`, `price`, `imageUrl`, `raw` 형태로 정규화합니다.
250
+ - `standard.stores`: 매장 목록을 `code`, `name`, `address`, `distanceMeters`, `raw` 형태로 정규화합니다.
251
+ - `standard.theaters`: 영화관 목록을 `code`, `name`, `address`, `distanceMeters`, `raw` 형태로 정규화합니다.
252
+
226
253
  ### 운영 헬스 체크
227
254
 
228
255
  공개 상태 페이지: **[Daiso MCP Status](https://aka-page.betteruptime.com/)**
@@ -231,16 +258,26 @@ npx daiso products 수납박스 --json
231
258
 
232
259
  ```bash
233
260
  curl -H "Authorization: Bearer $HEALTH_CHECK_SECRET" \
234
- "https://mcp.aka.page/api/health/checks?check=lottemart.products&fresh=true&includeSamples=true"
261
+ "https://mcp.aka.page/api/health/checks?mode=full&fresh=true&includeSamples=true&timeoutMs=20000&slowThresholdMs=9000"
235
262
  ```
236
263
 
237
264
  주요 쿼리:
238
265
 
239
266
  - `service=gs25`: 특정 서비스만 확인
240
267
  - `check=lottemart.products`: 특정 체크만 확인
241
- - `mode=quick|deep`: 체크 모드 선택
268
+ - `mode=quick|deep|full`: 체크 모드 선택
242
269
  - `fresh=true`: 60초 캐시 우회
243
270
  - `includeSamples=true`: 첫 결과 이름 샘플 포함
271
+ - `timeoutMs=20000`: 체크별 요청 제한 시간
272
+ - `slowThresholdMs=9000`: 지정 시간보다 느린 성공 응답을 degraded로 표시
273
+
274
+ 상태 기준:
275
+
276
+ - `ok`: 필수 응답 구조와 최소 결과가 정상입니다.
277
+ - `degraded`: 기능은 살아 있지만 빈 결과, 느린 응답, 응답 구조 변화 등 확인이 필요합니다.
278
+ - `fail`: 외부 API 오류, 타임아웃, 인증 문제처럼 실제 장애로 봐야 합니다.
279
+
280
+ Better Stack 같은 외부 모니터링에서는 `fail`을 장애 알림 기준으로 보고, `degraded`는 느린 외부 API나 응답 품질 저하를 추적하는 경고 신호로 봅니다.
244
281
 
245
282
  배포 전 로컬에서 CLI 모드까지 확인할 때는 아래 명령을 사용합니다.
246
283
 
@@ -315,6 +352,7 @@ daiso 인터랙티브 모드
315
352
 
316
353
  - [@thecats1105](https://github.com/thecats1105): 다이소 진열 위치 조회 도구(`daiso_get_display_location`) 구현 및 API/테스트 연동
317
354
  - [@betterthanhajin](https://github.com/betterthanhajin): CGV 서비스 프로바이더 구현(극장/영화/시간표 도구, 라우트·스펙·테스트 추가)
355
+ - [@LLagoon3](https://github.com/LLagoon3): 다이소 매장 재고 조회 신규 인증 흐름 전환 및 관련 테스트 보강
318
356
  - [제로초님](https://youtube.com/shorts/ZgIqA1NCEp0?si=UW0pKsSpqmEi7lXG): 프로젝트 홍보 도움
319
357
 
320
358
  <br>
@@ -0,0 +1,19 @@
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', 'lottemart', 'cgv'],
13
+ },
14
+ healthCheckSecret: {
15
+ configured: isConfigured(bindings?.HEALTH_CHECK_SECRET),
16
+ usedBy: ['health-checks'],
17
+ },
18
+ };
19
+ }
@@ -143,22 +143,33 @@ export async function handleGs25CheckInventory(c) {
143
143
  }
144
144
  try {
145
145
  if ((typeof latitude !== 'number' || typeof longitude !== 'number') && storeKeyword.trim().length > 0) {
146
- const baseStores = await fetchGs25Stores({
147
- serviceCode,
148
- }, {
149
- timeout: 20000,
146
+ const directGeocoded = await geocodeGs25Address(storeKeyword, {
147
+ timeout: 15000,
148
+ googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY,
150
149
  });
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,
150
+ if (directGeocoded) {
151
+ latitude = directGeocoded.latitude;
152
+ longitude = directGeocoded.longitude;
153
+ geocodeUsed = true;
154
+ }
155
+ else {
156
+ const baseStores = await fetchGs25Stores({
157
+ serviceCode,
158
+ }, {
159
+ timeout: 20000,
157
160
  });
158
- if (geocoded) {
159
- latitude = geocoded.latitude;
160
- longitude = geocoded.longitude;
161
- geocodeUsed = true;
161
+ const firstAddress = filterGs25StoresByKeyword(baseStores.stores, storeKeyword).find((store) => store.address.trim().length > 0)
162
+ ?.address || '';
163
+ if (firstAddress.length > 0) {
164
+ const geocoded = await geocodeGs25Address(firstAddress, {
165
+ timeout: 15000,
166
+ googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY,
167
+ });
168
+ if (geocoded) {
169
+ latitude = geocoded.latitude;
170
+ longitude = geocoded.longitude;
171
+ geocodeUsed = true;
172
+ }
162
173
  }
163
174
  }
164
175
  }
@@ -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
  }, {
@@ -2,8 +2,10 @@
2
2
  * 개별 서비스 헬스 체크 실행기
3
3
  */
4
4
  const HEALTH_CHECK_CACHE_TTL_MS = 60_000;
5
- const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 3000;
6
- const MAX_HEALTH_CHECK_TIMEOUT_MS = 10_000;
5
+ const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 7000;
6
+ const MAX_HEALTH_CHECK_TIMEOUT_MS = 20_000;
7
+ const DEFAULT_HEALTH_CHECK_CONCURRENCY = 6;
8
+ const DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS = 0;
7
9
  const HEALTH_CHECKS = [
8
10
  {
9
11
  id: 'cli.contract',
@@ -19,6 +21,8 @@ const HEALTH_CHECKS = [
19
21
  target: 'products',
20
22
  mode: 'quick',
21
23
  path: '/api/daiso/products?q=%ED%85%8C%EC%9D%B4%ED%94%84&pageSize=1',
24
+ collectionKey: 'products',
25
+ requiredFields: ['id', 'name', 'productName', 'itemName', 'goodsName'],
22
26
  },
23
27
  {
24
28
  id: 'cu.stores',
@@ -26,6 +30,8 @@ const HEALTH_CHECKS = [
26
30
  target: 'stores',
27
31
  mode: 'quick',
28
32
  path: '/api/cu/stores?keyword=%EA%B0%95%EB%82%A8&limit=1',
33
+ collectionKey: 'stores',
34
+ requiredFields: ['storeCode', 'storeName', 'name'],
29
35
  },
30
36
  {
31
37
  id: 'emart24.products',
@@ -33,6 +39,8 @@ const HEALTH_CHECKS = [
33
39
  target: 'products',
34
40
  mode: 'quick',
35
41
  path: '/api/emart24/products?keyword=%EC%BB%A4%ED%94%BC&pageSize=1',
42
+ collectionKey: 'products',
43
+ requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
36
44
  },
37
45
  {
38
46
  id: 'gs25.products',
@@ -40,6 +48,8 @@ const HEALTH_CHECKS = [
40
48
  target: 'products',
41
49
  mode: 'quick',
42
50
  path: '/api/gs25/products?keyword=%EC%BD%9C%EB%9D%BC&limit=1',
51
+ collectionKey: 'products',
52
+ requiredFields: ['itemCode', 'itemName', 'name'],
43
53
  },
44
54
  {
45
55
  id: 'gs25.stores',
@@ -47,6 +57,8 @@ const HEALTH_CHECKS = [
47
57
  target: 'stores',
48
58
  mode: 'quick',
49
59
  path: '/api/gs25/stores?keyword=%EA%B0%95%EB%82%A8&limit=1',
60
+ collectionKey: 'stores',
61
+ requiredFields: ['storeCode', 'storeName', 'name'],
50
62
  },
51
63
  {
52
64
  id: 'seveneleven.products',
@@ -54,20 +66,26 @@ const HEALTH_CHECKS = [
54
66
  target: 'products',
55
67
  mode: 'quick',
56
68
  path: '/api/seveneleven/products?query=%EC%BB%A4%ED%94%BC&size=1',
69
+ collectionKey: 'products',
70
+ requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
57
71
  },
58
72
  {
59
73
  id: 'lottemart.products',
60
74
  service: 'lottemart',
61
75
  target: 'products',
62
76
  mode: 'quick',
63
- path: '/api/lottemart/products?keyword=%EC%BD%9C%EB%9D%BC&storeCode=2301&area=%EC%84%9C%EC%9A%B8&pageLimit=1',
77
+ path: '/api/lottemart/products?keyword=%EC%BD%9C%EB%9D%BC&storeCode=2301&area=%EC%84%9C%EC%9A%B8&pageLimit=1&source=zetta',
78
+ collectionKey: 'products',
79
+ requiredFields: ['productCode', 'name', 'productName'],
64
80
  },
65
81
  {
66
82
  id: 'oliveyoung.products',
67
83
  service: 'oliveyoung',
68
84
  target: 'products',
69
85
  mode: 'quick',
70
- path: '/api/oliveyoung/products?keyword=%EC%84%A0%ED%81%AC%EB%A6%BC&size=1',
86
+ path: '/api/oliveyoung/products?keyword=%EB%A6%BD%EB%B0%A4&size=1',
87
+ collectionKey: 'products',
88
+ requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
71
89
  },
72
90
  {
73
91
  id: 'megabox.theaters',
@@ -75,6 +93,8 @@ const HEALTH_CHECKS = [
75
93
  target: 'theaters',
76
94
  mode: 'quick',
77
95
  path: '/api/megabox/theaters?keyword=%EA%B0%95%EB%82%A8&limit=1',
96
+ collectionKey: 'theaters',
97
+ requiredFields: ['theaterCode', 'theaterName', 'name'],
78
98
  },
79
99
  {
80
100
  id: 'lottecinema.theaters',
@@ -82,6 +102,8 @@ const HEALTH_CHECKS = [
82
102
  target: 'theaters',
83
103
  mode: 'quick',
84
104
  path: '/api/lottecinema/theaters?keyword=%EC%9E%A0%EC%8B%A4&limit=1',
105
+ collectionKey: 'theaters',
106
+ requiredFields: ['theaterCode', 'theaterName', 'name'],
85
107
  },
86
108
  {
87
109
  id: 'cgv.theaters',
@@ -89,6 +111,53 @@ const HEALTH_CHECKS = [
89
111
  target: 'theaters',
90
112
  mode: 'quick',
91
113
  path: '/api/cgv/theaters?keyword=%EA%B0%95%EB%82%A8&limit=1',
114
+ collectionKey: 'theaters',
115
+ requiredFields: ['theaterCode', 'theaterName', 'name'],
116
+ },
117
+ {
118
+ id: 'cu.inventory',
119
+ service: 'cu',
120
+ target: 'inventory',
121
+ mode: 'deep',
122
+ path: '/api/cu/inventory?keyword=%EC%BB%A4%ED%94%BC&size=1&storeLimit=0&storeCheck=false',
123
+ collectionKey: 'inventoryItems',
124
+ requiredFields: ['itemCode', 'itemName', 'name'],
125
+ },
126
+ {
127
+ id: 'emart24.inventory',
128
+ service: 'emart24',
129
+ target: 'inventory',
130
+ mode: 'deep',
131
+ path: '/api/emart24/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
132
+ collectionKey: 'inventoryItems',
133
+ requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
134
+ },
135
+ {
136
+ id: 'gs25.inventory',
137
+ service: 'gs25',
138
+ target: 'inventory',
139
+ mode: 'deep',
140
+ path: '/api/gs25/inventory?keyword=%EC%BD%9C%EB%9D%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
141
+ collectionKey: 'inventoryItems',
142
+ requiredFields: ['itemCode', 'itemName', 'name'],
143
+ },
144
+ {
145
+ id: 'seveneleven.inventory',
146
+ service: 'seveneleven',
147
+ target: 'inventory',
148
+ mode: 'deep',
149
+ path: '/api/seveneleven/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&size=1',
150
+ collectionKey: 'inventoryItems',
151
+ requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
152
+ },
153
+ {
154
+ id: 'oliveyoung.inventory',
155
+ service: 'oliveyoung',
156
+ target: 'inventory',
157
+ mode: 'deep',
158
+ 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
+ collectionKey: 'inventoryProducts',
160
+ requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
92
161
  },
93
162
  ];
94
163
  const healthCheckCache = new Map();
@@ -105,13 +174,15 @@ function createCacheKey(params) {
105
174
  params.check || '',
106
175
  params.mode,
107
176
  params.timeoutMs,
177
+ params.slowThresholdMs || 0,
178
+ params.cacheBust ? 'cache-bust' : 'cache',
108
179
  params.includeSamples ? 'samples' : 'no-samples',
109
180
  ].join('|');
110
181
  }
111
182
  function selectChecks(params) {
112
183
  const mode = params.mode || 'quick';
113
184
  return HEALTH_CHECKS.filter((check) => {
114
- if (check.mode !== mode) {
185
+ if (mode !== 'full' && check.mode !== mode) {
115
186
  return false;
116
187
  }
117
188
  if (params.service && check.service !== params.service) {
@@ -123,6 +194,19 @@ function selectChecks(params) {
123
194
  return true;
124
195
  });
125
196
  }
197
+ async function mapWithConcurrency(items, concurrency, mapper) {
198
+ const results = new Array(items.length);
199
+ let nextIndex = 0;
200
+ const workerCount = Math.min(Math.max(1, concurrency), items.length);
201
+ await Promise.all(Array.from({ length: workerCount }, async () => {
202
+ while (nextIndex < items.length) {
203
+ const currentIndex = nextIndex;
204
+ nextIndex += 1;
205
+ results[currentIndex] = await mapper(items[currentIndex]);
206
+ }
207
+ }));
208
+ return results;
209
+ }
126
210
  function toCount(data) {
127
211
  if (!data || typeof data !== 'object') {
128
212
  return null;
@@ -137,6 +221,15 @@ function toCount(data) {
137
221
  return value.length;
138
222
  }
139
223
  }
224
+ if (record.inventory && typeof record.inventory === 'object') {
225
+ const inventory = record.inventory;
226
+ for (const key of ['products', 'items']) {
227
+ const value = inventory[key];
228
+ if (Array.isArray(value)) {
229
+ return value.length;
230
+ }
231
+ }
232
+ }
140
233
  return null;
141
234
  }
142
235
  function toFirstName(data) {
@@ -156,8 +249,67 @@ function toFirstName(data) {
156
249
  }
157
250
  }
158
251
  }
252
+ if (record.inventory && typeof record.inventory === 'object') {
253
+ const inventory = record.inventory;
254
+ for (const key of ['products', 'items']) {
255
+ const value = inventory[key];
256
+ if (!Array.isArray(value) || !value[0] || typeof value[0] !== 'object') {
257
+ continue;
258
+ }
259
+ const item = value[0];
260
+ for (const nameKey of ['productName', 'itemName', 'goodsName', 'name']) {
261
+ if (typeof item[nameKey] === 'string' && item[nameKey].trim().length > 0) {
262
+ return item[nameKey].trim();
263
+ }
264
+ }
265
+ }
266
+ }
159
267
  return undefined;
160
268
  }
269
+ function getCollectionItems(data, collectionKey) {
270
+ if (!data || typeof data !== 'object') {
271
+ return [];
272
+ }
273
+ const record = data;
274
+ if (collectionKey === 'inventoryProducts' || collectionKey === 'inventoryItems') {
275
+ const inventory = record.inventory;
276
+ if (!inventory || typeof inventory !== 'object') {
277
+ return [];
278
+ }
279
+ const key = collectionKey === 'inventoryProducts' ? 'products' : 'items';
280
+ const value = inventory[key];
281
+ return Array.isArray(value) ? value : [];
282
+ }
283
+ if (collectionKey && Array.isArray(record[collectionKey])) {
284
+ return record[collectionKey];
285
+ }
286
+ for (const key of ['products', 'stores', 'theaters', 'movies', 'showtimes']) {
287
+ /* c8 ignore next -- 현재 정의된 체크는 collectionKey를 명시한다. */
288
+ if (Array.isArray(record[key])) {
289
+ return record[key];
290
+ }
291
+ }
292
+ return [];
293
+ }
294
+ function hasRequiredRepresentativeFields(data, collectionKey, requiredFields = []) {
295
+ /* c8 ignore next -- 현재 정의된 API 체크는 requiredFields를 명시한다. */
296
+ if (requiredFields.length === 0) {
297
+ return true;
298
+ }
299
+ const items = getCollectionItems(data, collectionKey);
300
+ if (items.length === 0) {
301
+ return true;
302
+ }
303
+ const first = items[0];
304
+ if (!first || typeof first !== 'object' || Array.isArray(first)) {
305
+ return false;
306
+ }
307
+ const record = first;
308
+ return requiredFields.some((field) => {
309
+ const value = record[field];
310
+ return typeof value === 'string' ? value.trim().length > 0 : value !== undefined && value !== null;
311
+ });
312
+ }
161
313
  function aggregateStatus(checks) {
162
314
  if (checks.length === 0) {
163
315
  return 'skipped';
@@ -170,9 +322,12 @@ function aggregateStatus(checks) {
170
322
  }
171
323
  return 'ok';
172
324
  }
173
- function buildCheckUrl(baseUrl, check, timeoutMs) {
325
+ function buildCheckUrl(baseUrl, check, timeoutMs, cacheBustValue) {
174
326
  const url = new URL(check.path, baseUrl);
175
327
  url.searchParams.set('timeoutMs', String(timeoutMs));
328
+ if (typeof cacheBustValue === 'number') {
329
+ url.searchParams.set('_healthCheck', String(cacheBustValue));
330
+ }
176
331
  return url.toString();
177
332
  }
178
333
  const CLI_CONTRACT_PATHS = [
@@ -201,10 +356,11 @@ function isCliCompatibleEnvelope(path, body) {
201
356
  }
202
357
  async function runCliContractCheck(check, params) {
203
358
  const startedAt = params.now();
359
+ const cacheBustValue = params.cacheBust ? startedAt : undefined;
204
360
  for (const path of CLI_CONTRACT_PATHS) {
205
361
  const syntheticCheck = { ...check, path };
206
362
  try {
207
- const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, syntheticCheck, params.timeoutMs), {
363
+ const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, syntheticCheck, params.timeoutMs, cacheBustValue), {
208
364
  signal: AbortSignal.timeout(params.timeoutMs),
209
365
  });
210
366
  const body = (await response.json().catch(() => ({})));
@@ -246,8 +402,9 @@ async function runSingleCheck(check, params) {
246
402
  }
247
403
  const timeoutMs = params.timeoutMs;
248
404
  const startedAt = params.now();
405
+ const cacheBustValue = params.cacheBust ? startedAt : undefined;
249
406
  try {
250
- const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, check, timeoutMs), {
407
+ const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, check, timeoutMs, cacheBustValue), {
251
408
  signal: AbortSignal.timeout(timeoutMs),
252
409
  });
253
410
  const body = (await response.json().catch(() => ({})));
@@ -264,8 +421,18 @@ async function runSingleCheck(check, params) {
264
421
  };
265
422
  }
266
423
  const count = typeof body.meta?.total === 'number' ? body.meta.total : toCount(body.data);
267
- const status = count === 0 ? 'degraded' : 'ok';
424
+ const shapeOk = hasRequiredRepresentativeFields(body.data, check.collectionKey, check.requiredFields);
425
+ const slowThresholdMs = params.slowThresholdMs || DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
426
+ const slow = slowThresholdMs > 0 && durationMs > slowThresholdMs;
427
+ const status = count === 0 || !shapeOk || slow ? 'degraded' : 'ok';
268
428
  const first = params.includeSamples ? toFirstName(body.data) : undefined;
429
+ const message = slow
430
+ ? `slow response: ${durationMs}ms > ${slowThresholdMs}ms`
431
+ : !shapeOk && count !== 0
432
+ ? `response missing required fields: ${check.requiredFields.join(', ')}`
433
+ : count === null
434
+ ? 'response ok'
435
+ : `${count} item(s) returned`;
269
436
  return {
270
437
  id: check.id,
271
438
  service: check.service,
@@ -273,7 +440,7 @@ async function runSingleCheck(check, params) {
273
440
  status,
274
441
  durationMs,
275
442
  httpStatus: response.status,
276
- message: count === null ? 'response ok' : `${count} item(s) returned`,
443
+ message,
277
444
  ...(first ? { sample: { first } } : {}),
278
445
  };
279
446
  }
@@ -293,7 +460,10 @@ export async function runHealthChecks(params) {
293
460
  const fetchImpl = params.fetchImpl || ((input, init) => globalThis.fetch(input, init));
294
461
  const mode = params.mode || 'quick';
295
462
  const timeoutMs = clampTimeout(params.timeoutMs ?? DEFAULT_HEALTH_CHECK_TIMEOUT_MS);
296
- const cacheKey = createCacheKey({ ...params, mode, timeoutMs, baseUrl: params.baseUrl });
463
+ const slowThresholdMs = Number.isFinite(params.slowThresholdMs) && params.slowThresholdMs && params.slowThresholdMs > 0
464
+ ? Math.trunc(params.slowThresholdMs)
465
+ : DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
466
+ const cacheKey = createCacheKey({ ...params, mode, timeoutMs, slowThresholdMs, baseUrl: params.baseUrl });
297
467
  const cached = healthCheckCache.get(cacheKey);
298
468
  const startedAt = now();
299
469
  if (!params.fresh && cached && cached.expiresAt > startedAt) {
@@ -302,13 +472,15 @@ export async function runHealthChecks(params) {
302
472
  cached: true,
303
473
  };
304
474
  }
305
- const checks = await Promise.all(selectChecks(params).map((check) => runSingleCheck(check, {
475
+ const checks = await mapWithConcurrency(selectChecks(params), DEFAULT_HEALTH_CHECK_CONCURRENCY, (check) => runSingleCheck(check, {
306
476
  baseUrl: params.baseUrl,
307
477
  fetchImpl,
308
478
  now,
309
479
  timeoutMs,
480
+ slowThresholdMs,
310
481
  includeSamples: params.includeSamples,
311
- })));
482
+ cacheBust: params.cacheBust,
483
+ }));
312
484
  const summary = {
313
485
  status: aggregateStatus(checks),
314
486
  checkedAt: new Date(startedAt).toISOString(),