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.
- package/README.md +45 -7
- package/dist/api/configStatus.js +19 -0
- package/dist/api/gs25Handlers.js +25 -14
- package/dist/api/handlers.js +32 -3
- package/dist/api/healthChecks.js +185 -13
- package/dist/api/lottemartHandlers.js +17 -1
- package/dist/api/response.js +2 -0
- package/dist/api/routes/healthRoutes.js +20 -3
- package/dist/bin.js +0 -0
- package/dist/cli/args.js +21 -0
- package/dist/cli/commandOptions.js +38 -0
- package/dist/cli/commands/convenience.js +53 -1
- package/dist/cli/commands/daiso.js +18 -2
- package/dist/cli/http.js +58 -0
- package/dist/cli/interactiveItemSearch.js +4 -2
- package/dist/cli/interactivePrompt.js +3 -2
- package/dist/cli.js +22 -0
- package/dist/cliHelp.js +6 -0
- package/dist/cliInteractive.js +75 -6
- package/dist/cliPicker.js +1 -0
- package/dist/cliRenderer.js +86 -3
- package/dist/core/errors.js +66 -0
- package/dist/core/registry.js +227 -3
- package/dist/index.js +2 -1
- package/dist/pages/openapiSpecActions.js +3 -3
- package/dist/pages/openapiSpecPathsCu.js +7 -0
- package/dist/pages/openapiSpecPathsLotteMart.js +14 -0
- package/dist/pages/openapiSpecPathsOliveyoung.js +35 -0
- package/dist/pages/promptReferenceText.js +13 -7
- package/dist/pages/promptRetailCinemaText.js +2 -1
- package/dist/services/daiso/client.js +8 -0
- package/dist/services/daiso/index.js +2 -0
- package/dist/services/daiso/tools/checkInventory.js +4 -2
- package/dist/services/daiso/tools/findInventoryByName.js +222 -0
- package/dist/services/daiso/tools/getDisplayLocation.js +2 -2
- package/dist/services/emart24/client.js +9 -0
- package/dist/services/emart24/tools/searchProducts.js +27 -17
- package/dist/services/gs25/client.js +10 -0
- package/dist/services/gs25/tools/searchProducts.js +28 -21
- package/dist/services/lottemart/client.js +10 -1
- package/dist/services/lottemart/tools/searchProducts.js +8 -1
- package/dist/services/lottemart/zetta.js +1 -1
- package/dist/services/oliveyoung/client.js +124 -53
- package/dist/services/oliveyoung/tools/checkInventory.js +7 -2
- package/dist/services/seveneleven/client.js +7 -0
- package/dist/services/seveneleven/tools/searchProducts.js +29 -18
- package/dist/utils/http.js +82 -10
- package/dist/utils/zyte.js +47 -26
- package/openapi.json +3 -3
- package/openapi.yaml +3 -3
- package/package.json +26 -8
package/README.md
CHANGED
|
@@ -5,9 +5,17 @@
|
|
|
5
5
|
<br>
|
|
6
6
|
<br>
|
|
7
7
|
|
|
8
|
-
# Daiso MCP
|
|
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-
|
|
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-
|
|
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-
|
|
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?
|
|
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
|
+
}
|
package/dist/api/gs25Handlers.js
CHANGED
|
@@ -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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
timeout: 20000,
|
|
146
|
+
const directGeocoded = await geocodeGs25Address(storeKeyword, {
|
|
147
|
+
timeout: 15000,
|
|
148
|
+
googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY,
|
|
150
149
|
});
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
}
|
package/dist/api/handlers.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
}, {
|
package/dist/api/healthChecks.js
CHANGED
|
@@ -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 =
|
|
6
|
-
const MAX_HEALTH_CHECK_TIMEOUT_MS =
|
|
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=%
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(),
|