daiso 1.0.8 → 1.0.9

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 (44) hide show
  1. package/README.md +83 -7
  2. package/dist/api/actionsProxy.js +5 -0
  3. package/dist/api/configStatus.js +8 -0
  4. package/dist/api/feedbackHandlers.js +60 -0
  5. package/dist/api/healthCheckDefinitions.js +170 -0
  6. package/dist/api/healthCheckShape.js +106 -0
  7. package/dist/api/healthCheckTypes.js +4 -0
  8. package/dist/api/healthChecks.js +3 -271
  9. package/dist/api/opinetHandlers.js +91 -0
  10. package/dist/api/routes/feedbackRoutes.js +5 -0
  11. package/dist/api/routes/healthRoutes.js +17 -1
  12. package/dist/api/routes/opinetRoutes.js +24 -0
  13. package/dist/bin.js +0 -0
  14. package/dist/cli/commandOptions.js +23 -2
  15. package/dist/cli/commands/apiCommand.js +24 -0
  16. package/dist/cli/commands/cinema.js +65 -0
  17. package/dist/cli/commands/convenience.js +57 -100
  18. package/dist/cli/http.js +1 -1
  19. package/dist/cli.js +10 -1
  20. package/dist/cliHelp.js +44 -8
  21. package/dist/core/outputSchema.js +141 -0
  22. package/dist/core/registry.js +1 -138
  23. package/dist/index.js +92 -29
  24. package/dist/pages/openapiFullSpec.js +5 -0
  25. package/dist/pages/openapiSpecActions.js +63 -0
  26. package/dist/pages/openapiSpecPathsFeedback.js +95 -0
  27. package/dist/pages/openapiSpecPathsOpinet.js +81 -0
  28. package/dist/pages/promptReferenceText.js +15 -0
  29. package/dist/services/feedback/client.js +109 -0
  30. package/dist/services/feedback/index.js +22 -0
  31. package/dist/services/feedback/tools/submitDeveloperRequest.js +54 -0
  32. package/dist/services/feedback/types.js +3 -0
  33. package/dist/services/gs25/client.js +1 -84
  34. package/dist/services/gs25/productSearch.js +97 -0
  35. package/dist/services/oliveyoung/client.js +1 -55
  36. package/dist/services/oliveyoung/normalize.js +58 -0
  37. package/dist/services/opinet/client.js +231 -0
  38. package/dist/services/opinet/index.js +26 -0
  39. package/dist/services/opinet/location.js +119 -0
  40. package/dist/services/opinet/tools.js +80 -0
  41. package/dist/services/opinet/types.js +1 -0
  42. package/openapi.json +82 -0
  43. package/openapi.yaml +55 -0
  44. package/package.json +18 -17
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  # Daiso MCP 및 Skill
9
9
 
10
- 다이소(제품/매장/재고), 키 없는 통합 가격 후보 비교, 주변 음식점/카페, 롯데마트(매장/상품), GS25(매장/상품/재고), 세븐일레븐(상품/매장/재고/인기검색어/카탈로그), CU(매장/재고), 이마트24(매장/상품/재고), 올리브영(매장/재고), 메가박스(지점/영화/시간표/좌석), 롯데시네마(지점/영화/좌석), CGV(극장/영화/시간표) 조회 기능을 MCP, CLI, Codex Skill로 AI에 연결합니다.
10
+ 다이소(제품/매장/재고), 키 없는 통합 가격 후보 비교, 주변 음식점/카페, 개발자 요청 제출, 롯데마트(매장/상품), GS25(매장/상품/재고), 세븐일레븐(상품/매장/재고/인기검색어/카탈로그), CU(매장/재고), 이마트24(매장/상품/재고), 올리브영(매장/재고), 메가박스(지점/영화/시간표/좌석), 롯데시네마(지점/영화/좌석), CGV(극장/영화/시간표) 조회 기능을 MCP, CLI, Codex Skill로 AI에 연결합니다.
11
11
 
12
12
  <br>
13
13
 
@@ -40,6 +40,11 @@
40
40
  <td>다이소, GS25, 세븐일레븐, 이마트24</td>
41
41
  <td>새 외부 키 없는 상품 가격 후보 비교</td>
42
42
  </tr>
43
+ <tr>
44
+ <td>운영</td>
45
+ <td>Supabase</td>
46
+ <td>MCP 오류, 개선 요청, 신규 기능 요청 저장</td>
47
+ </tr>
43
48
  <tr>
44
49
  <td>리테일</td>
45
50
  <td>다이소, 올리브영, 롯데마트</td>
@@ -70,11 +75,11 @@
70
75
  **[실시간 서비스 상태 보기](https://aka-page.betteruptime.com/)**
71
76
 
72
77
  <!-- WORKERS_INVOCATIONS_CHART:START -->
73
- <h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-23, 84일)</h3>
78
+ <h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-27, 88일)</h3>
74
79
 
75
- <img src="./assets/analytics/workers-invocations.png?v=2026-05-23T15:44:29.609Z" alt="Cloudflare Workers 호출량 그래프 (2026-03-01 ~ 2026-05-23)" width="100%">
80
+ <img src="./assets/analytics/workers-invocations.png?v=2026-05-27T22:46:35.568Z" alt="Cloudflare Workers 호출량 그래프 (2026-03-01 ~ 2026-05-27)" width="100%">
76
81
 
77
- <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-24 00:44 KST</sub>
82
+ <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-28 07:46 KST</sub>
78
83
 
79
84
  <!-- WORKERS_INVOCATIONS_CHART:END -->
80
85
 
@@ -128,6 +133,15 @@ GS25 mcp로 강남 근처 매장과 오감자 재고 알려줘
128
133
  CGV mcp로 강남 상영 영화와 시간표 알려줘
129
134
  ```
130
135
 
136
+ 에이전트가 고르면 좋은 대표 흐름:
137
+
138
+ - 가격 비교: `콜라 어디가 싸?` → `compare_products`
139
+ - 주변 장소: `강남역 근처 카페 찾아줘` → `places_search_nearby`
140
+ - 브랜드 명시 재고: `다이소 핫식스 재고 찾아줘` → 먼저 다이소에서 검색 후 결과 없을 때만 대안 제안
141
+ - 편의점 재고: `GS25 강남 오감자 재고` → 상품 후보 확인 후 재고 조회
142
+ - 영화 시간표: `오늘 강남 CGV 시간표` → KST 오늘 날짜로 극장 검색 후 시간표 조회
143
+ - 개발자 요청: `올리브영 재고 도구가 계속 실패한다고 개발자에게 알려줘` → `submit_developer_request`
144
+
131
145
  <br>
132
146
 
133
147
  ### ![Claude](https://img.shields.io/badge/Claude-D4A27F?logo=anthropic&logoColor=white)
@@ -223,7 +237,7 @@ AI 앱 연동 없이 터미널에서 바로 확인할 수 있습니다.
223
237
  npx daiso products 수납박스 --json
224
238
  npx daiso places 강남역 --category cafe --limit 5 --json
225
239
  npx daiso gs25-inventory 오감자 --storeKeyword 강남 --storeLimit 5 --json
226
- npx daiso get /api/cgv/movies --playDate 20260520 --theaterCode 0056 --json
240
+ npx daiso cgv-movies --playDate 20260520 --theaterCode 0056 --json
227
241
  ```
228
242
 
229
243
  <br>
@@ -263,8 +277,13 @@ npx daiso display-location 1034604 04515
263
277
  npx daiso compare 콜라 --limit 3
264
278
  npx daiso places 강남역 --category cafe --limit 5
265
279
  npx daiso places 성수동 --keyword 브런치 --limit 5
280
+ npx daiso get /api/opinet/lowest --fuelCode B027 --areaCode 0113 --count 5 --json
281
+ npx daiso get /api/opinet/average --json
266
282
  npx daiso cu-stores 강남
267
283
  npx daiso cu-inventory 과자 --storeKeyword 강남
284
+ npx daiso cgv-theaters 강남 --limit 10
285
+ npx daiso cgv-movies --playDate 20260307 --theaterCode 0056
286
+ npx daiso cgv-timetable --playDate 20260307 --theaterCode 0056
268
287
  npx daiso lottemart-stores 잠실 --area 서울 --limit 10
269
288
  npx daiso lottemart-products 콜라 --storeName 강변점 --area 서울
270
289
  npx daiso emart24-stores 강남 --service24h true
@@ -275,10 +294,9 @@ npx daiso gs25-products 오감자 --limit 20
275
294
  npx daiso gs25-inventory 오감자 --storeKeyword 강남 --storeLimit 10
276
295
  npx daiso seveneleven-products 삼각김밥 --size 20
277
296
  npx daiso seveneleven-stores 안산 중앙역 --limit 10
278
- npx daiso get /api/seveneleven/inventory --keyword 핫식스 --storeKeyword 안산%20중앙역 --storeLimit 10
297
+ npx daiso seveneleven-inventory 핫식스 --storeKeyword "안산 중앙역" --storeLimit 10
279
298
  npx daiso seveneleven-popwords --label home
280
299
  npx daiso seveneleven-catalog --includeIssues true --includeExhibition true --limit 10
281
- npx daiso get /api/cgv/movies --playDate 20260307 --theaterCode 0056
282
300
 
283
301
  # 원본 JSON 필요 시
284
302
  npx daiso products 수납박스 --json
@@ -294,6 +312,7 @@ npx daiso products 수납박스 --json
294
312
  - 명령 맵: `skills/daiso-cli/references/cli-command-map.md`
295
313
  - 기본 원칙: 쉘 실행이 가능한 환경에서는 CLI를 우선 사용하고, AI 앱 연결이 필요할 때는 MCP 서버 URL `https://mcp.aka.page`를 사용합니다.
296
314
  - 구조화 결과가 필요하면 스킬은 `npx daiso ... --json` 형태를 선택합니다.
315
+ - 상황별 레시피는 `콜라 어디가 싸?`, `강남역 근처 카페`, `다이소 핫식스 재고`, `오늘 강남 CGV 시간표`처럼 실제 사용자 문장 기준으로 정리되어 있습니다.
297
316
 
298
317
  ### OpenAPI 스펙
299
318
 
@@ -327,6 +346,63 @@ GET /api/compare/products?keyword=콜라&limit=3
327
346
 
328
347
  지원 서비스는 `daiso`, `gs25`, `seveneleven`, `emart24`입니다. GS25처럼 상품 검색 응답에 가격이 없는 서비스는 후보에는 포함되지만 최저가 계산에서는 제외됩니다.
329
348
 
349
+ ### 오피넷 유가 정보
350
+
351
+ 한국석유공사 오피넷 유가정보 API로 전국 평균 유가, 지역별 최저가 주유소, 반경 내 주유소, 주유소 상세정보를 조회합니다.
352
+
353
+ MCP 도구:
354
+
355
+ ```text
356
+ opinet_get_average_prices
357
+ opinet_get_lowest_price_stations
358
+ opinet_search_stations_around
359
+ opinet_get_station_detail
360
+ ```
361
+
362
+ REST:
363
+
364
+ ```text
365
+ GET /api/opinet/average
366
+ GET /api/opinet/lowest?fuelCode=B027&areaCode=0113&count=5
367
+ GET /api/opinet/stations/around?lat=37.4979&lng=127.0276&radiusMeters=3000&fuelCode=B027&sort=price
368
+ GET /api/opinet/stations/around?location=강남역&radiusMeters=3000&fuelCode=B027&sort=price
369
+ GET /api/opinet/stations/around?x=314681.8&y=544837&radiusMeters=3000&fuelCode=B027&sort=price
370
+ GET /api/opinet/station?id=A0010207
371
+ ```
372
+
373
+ 오피넷 API 키는 `OPINET_API_KEY` 환경 변수 또는 Cloudflare Worker Secret으로 설정합니다. 키 발급은 오피넷 웹사이트의 `유가관련정보 > 유가정보 API > 인증키 발급`에서 진행합니다. 반경 검색은 `lat/lng`, `location`, KATEC `x/y`를 모두 지원하며, `location` 검색에는 `GOOGLE_MAPS_API_KEY`가 필요합니다.
374
+
375
+ 운영 제약 및 캐싱 정책:
376
+
377
+ - 오피넷 무료 API는 한국석유공사 공공데이터 활용 가이드 기준 **1일 1,500 call** 한도를 기준으로 운영합니다.
378
+ - 모든 오피넷 응답에는 `source: "한국석유공사 오피넷"`과 `fetchedAt`을 포함합니다.
379
+ - Cloudflare Edge Cache로 동일 GET 요청의 원본 오피넷 호출을 줄입니다.
380
+ - `GET /api/opinet/average`: 60분 캐시, 30분 stale-while-revalidate
381
+ - `GET /api/opinet/lowest`: 30분 캐시, 10분 stale-while-revalidate
382
+ - `GET /api/opinet/stations/around`: 20분 캐시, 5분 stale-while-revalidate
383
+ - `GET /api/opinet/station`: 60분 캐시, 10분 stale-while-revalidate
384
+ - `location` 키워드의 Google Geocoding 결과는 Worker 인스턴스 메모리에서 24시간 캐시합니다.
385
+ - 호출 한도에 자주 도달하면 운영자가 담당 기관인 한국석유공사/오피넷에 문의해 추가 할당량 또는 별도 이용 조건을 협의할 예정입니다.
386
+
387
+ ### 개발자 요청 제출
388
+
389
+ AI 에이전트가 MCP 기능 오류, 개선 요청, 신규 기능 요청, 문서 문제를 바로 개발자에게 전달할 수 있습니다. 요청은 Supabase `agent_requests` 테이블에 저장됩니다.
390
+
391
+ MCP 도구:
392
+
393
+ ```text
394
+ submit_developer_request
395
+ ```
396
+
397
+ REST:
398
+
399
+ ```text
400
+ POST /api/feedback/requests
401
+ GET /api/feedback/requests?type=bug&title=제목&description=설명
402
+ ```
403
+
404
+ 로컬 실행이나 배포 환경에는 `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`를 설정하세요. 테이블 스키마는 [supabase-agent-requests.sql](./docs/supabase-agent-requests.sql)에 있습니다.
405
+
330
406
  ### 운영 헬스 체크
331
407
 
332
408
  공개 상태 페이지: **[Daiso MCP Status](https://aka-page.betteruptime.com/)**
@@ -96,6 +96,11 @@ export const ACTION_QUERY_DEFINITIONS = [
96
96
  summary: '음식점/카페 등 주변 장소 검색',
97
97
  targetPath: '/api/places/search',
98
98
  },
99
+ {
100
+ action: 'submitDeveloperRequest',
101
+ summary: '개발자 요청 제출',
102
+ targetPath: '/api/feedback/requests',
103
+ },
99
104
  {
100
105
  action: 'megaboxFindTheaters',
101
106
  summary: '메가박스 지점 검색',
@@ -15,6 +15,14 @@ export function buildConfigStatus(bindings) {
15
15
  configured: isConfigured(bindings?.NAVER_CLIENT_ID) && isConfigured(bindings?.NAVER_CLIENT_SECRET),
16
16
  usedBy: ['places'],
17
17
  },
18
+ opinetApiKey: {
19
+ configured: isConfigured(bindings?.OPINET_API_KEY),
20
+ usedBy: ['opinet'],
21
+ },
22
+ supabaseFeedback: {
23
+ configured: isConfigured(bindings?.SUPABASE_URL) && isConfigured(bindings?.SUPABASE_SERVICE_ROLE_KEY),
24
+ usedBy: ['feedback'],
25
+ },
18
26
  healthCheckSecret: {
19
27
  configured: isConfigured(bindings?.HEALTH_CHECK_SECRET),
20
28
  usedBy: ['health-checks'],
@@ -0,0 +1,60 @@
1
+ import { errorResponse, successResponse } from './response.js';
2
+ import { submitDeveloperRequest } from '../services/feedback/index.js';
3
+ function getErrorMessage(error) {
4
+ return error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
5
+ }
6
+ async function readJsonBody(c) {
7
+ if (c.req.method !== 'POST') {
8
+ return {};
9
+ }
10
+ return c.req.json().catch(() => ({}));
11
+ }
12
+ function queryInput(c) {
13
+ return {
14
+ type: c.req.query('type'),
15
+ severity: c.req.query('severity'),
16
+ title: c.req.query('title') || '',
17
+ description: c.req.query('description') || '',
18
+ service: c.req.query('service'),
19
+ toolName: c.req.query('toolName'),
20
+ reproduction: c.req.query('reproduction'),
21
+ expected: c.req.query('expected'),
22
+ actual: c.req.query('actual'),
23
+ source: c.req.query('source') || 'api',
24
+ };
25
+ }
26
+ function normalizeInput(raw) {
27
+ return {
28
+ type: typeof raw.type === 'string' ? raw.type : undefined,
29
+ severity: typeof raw.severity === 'string' ? raw.severity : undefined,
30
+ title: typeof raw.title === 'string' ? raw.title : '',
31
+ description: typeof raw.description === 'string' ? raw.description : '',
32
+ service: typeof raw.service === 'string' ? raw.service : undefined,
33
+ toolName: typeof raw.toolName === 'string' ? raw.toolName : undefined,
34
+ reproduction: typeof raw.reproduction === 'string' ? raw.reproduction : undefined,
35
+ expected: typeof raw.expected === 'string' ? raw.expected : undefined,
36
+ actual: typeof raw.actual === 'string' ? raw.actual : undefined,
37
+ source: typeof raw.source === 'string' ? raw.source : undefined,
38
+ userContext: raw.userContext,
39
+ };
40
+ }
41
+ export async function handleSubmitDeveloperRequest(c) {
42
+ try {
43
+ const body = await readJsonBody(c);
44
+ const input = normalizeInput(c.req.method === 'POST' ? body : queryInput(c));
45
+ if (!input.title.trim()) {
46
+ return errorResponse(c, 'MISSING_FEEDBACK_TITLE', 'title을 입력해주세요.', 400);
47
+ }
48
+ if (!input.description.trim()) {
49
+ return errorResponse(c, 'MISSING_FEEDBACK_DESCRIPTION', 'description을 입력해주세요.', 400);
50
+ }
51
+ const result = await submitDeveloperRequest(input, {
52
+ supabaseUrl: c.env?.SUPABASE_URL,
53
+ supabaseServiceRoleKey: c.env?.SUPABASE_SERVICE_ROLE_KEY,
54
+ });
55
+ return successResponse(c, result);
56
+ }
57
+ catch (error) {
58
+ return errorResponse(c, 'DEVELOPER_REQUEST_SUBMIT_FAILED', getErrorMessage(error), 500);
59
+ }
60
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * 서비스별 헬스 체크 정의
3
+ */
4
+ export const GS25_CLOUDFRONT_403_PATTERNS = [
5
+ '403 Forbidden',
6
+ 'The request could not be satisfied',
7
+ '403 ERROR',
8
+ ];
9
+ export const HEALTH_CHECKS = [
10
+ {
11
+ id: 'cli.contract',
12
+ service: 'cli',
13
+ target: 'api-contract',
14
+ mode: 'deep',
15
+ kind: 'cli-contract',
16
+ path: '/health',
17
+ },
18
+ {
19
+ id: 'daiso.products',
20
+ service: 'daiso',
21
+ target: 'products',
22
+ mode: 'quick',
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'],
26
+ },
27
+ {
28
+ id: 'cu.stores',
29
+ service: 'cu',
30
+ target: 'stores',
31
+ mode: 'quick',
32
+ path: '/api/cu/stores?keyword=%EA%B0%95%EB%82%A8&limit=1',
33
+ collectionKey: 'stores',
34
+ requiredFields: ['storeCode', 'storeName', 'name'],
35
+ },
36
+ {
37
+ id: 'emart24.products',
38
+ service: 'emart24',
39
+ target: 'products',
40
+ mode: 'quick',
41
+ path: '/api/emart24/products?keyword=%EC%BB%A4%ED%94%BC&pageSize=1',
42
+ collectionKey: 'products',
43
+ requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
44
+ },
45
+ {
46
+ id: 'gs25.products',
47
+ service: 'gs25',
48
+ target: 'products',
49
+ mode: 'quick',
50
+ path: '/api/gs25/products?keyword=%EC%BD%9C%EB%9D%BC&limit=1',
51
+ collectionKey: 'products',
52
+ requiredFields: ['itemCode', 'itemName', 'name'],
53
+ degradedFailurePatterns: GS25_CLOUDFRONT_403_PATTERNS,
54
+ },
55
+ {
56
+ id: 'gs25.stores',
57
+ service: 'gs25',
58
+ target: 'stores',
59
+ mode: 'quick',
60
+ path: '/api/gs25/stores?keyword=%EA%B0%95%EB%82%A8&limit=1',
61
+ collectionKey: 'stores',
62
+ requiredFields: ['storeCode', 'storeName', 'name'],
63
+ },
64
+ {
65
+ id: 'seveneleven.products',
66
+ service: 'seveneleven',
67
+ target: 'products',
68
+ mode: 'quick',
69
+ path: '/api/seveneleven/products?query=%EC%BB%A4%ED%94%BC&size=1',
70
+ collectionKey: 'products',
71
+ requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
72
+ },
73
+ {
74
+ id: 'lottemart.products',
75
+ service: 'lottemart',
76
+ target: 'products',
77
+ mode: 'quick',
78
+ path: '/api/lottemart/products?keyword=%EC%BD%9C%EB%9D%BC&storeCode=2301&area=%EC%84%9C%EC%9A%B8&pageLimit=1&source=zetta',
79
+ collectionKey: 'products',
80
+ requiredFields: ['productCode', 'name', 'productName'],
81
+ },
82
+ {
83
+ id: 'oliveyoung.products',
84
+ service: 'oliveyoung',
85
+ target: 'products',
86
+ mode: 'quick',
87
+ path: '/api/oliveyoung/products?keyword=%EB%A6%BD%EB%B0%A4&size=1',
88
+ collectionKey: 'products',
89
+ requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
90
+ timeoutMs: 5000,
91
+ },
92
+ {
93
+ id: 'megabox.theaters',
94
+ service: 'megabox',
95
+ target: 'theaters',
96
+ mode: 'quick',
97
+ path: '/api/megabox/theaters?keyword=%EA%B0%95%EB%82%A8&limit=1',
98
+ collectionKey: 'theaters',
99
+ requiredFields: ['theaterCode', 'theaterName', 'name'],
100
+ },
101
+ {
102
+ id: 'lottecinema.theaters',
103
+ service: 'lottecinema',
104
+ target: 'theaters',
105
+ mode: 'quick',
106
+ path: '/api/lottecinema/theaters?keyword=%EC%9E%A0%EC%8B%A4&limit=1',
107
+ collectionKey: 'theaters',
108
+ requiredFields: ['theaterCode', 'theaterName', 'name'],
109
+ },
110
+ {
111
+ id: 'cgv.theaters',
112
+ service: 'cgv',
113
+ target: 'theaters',
114
+ mode: 'quick',
115
+ path: '/api/cgv/theaters?keyword=%EA%B0%95%EB%82%A8&limit=1',
116
+ collectionKey: 'theaters',
117
+ requiredFields: ['theaterCode', 'theaterName', 'name'],
118
+ },
119
+ {
120
+ id: 'cu.inventory',
121
+ service: 'cu',
122
+ target: 'inventory',
123
+ mode: 'deep',
124
+ path: '/api/cu/inventory?keyword=%EC%BB%A4%ED%94%BC&size=1&storeLimit=0&storeCheck=false',
125
+ collectionKey: 'inventoryItems',
126
+ requiredFields: ['itemCode', 'itemName', 'name'],
127
+ },
128
+ {
129
+ id: 'emart24.inventory',
130
+ service: 'emart24',
131
+ target: 'inventory',
132
+ mode: 'deep',
133
+ path: '/api/emart24/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
134
+ collectionKey: 'inventoryItems',
135
+ requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
136
+ },
137
+ {
138
+ id: 'gs25.inventory',
139
+ service: 'gs25',
140
+ target: 'inventory',
141
+ mode: 'deep',
142
+ path: '/api/gs25/inventory?keyword=%EC%BD%9C%EB%9D%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
143
+ collectionKey: 'inventoryItems',
144
+ requiredFields: ['itemCode', 'itemName', 'name'],
145
+ degradedFailurePatterns: [
146
+ '401 Unauthorized',
147
+ '인증키가 제공되지 않음',
148
+ ...GS25_CLOUDFRONT_403_PATTERNS,
149
+ ],
150
+ },
151
+ {
152
+ id: 'seveneleven.inventory',
153
+ service: 'seveneleven',
154
+ target: 'inventory',
155
+ mode: 'deep',
156
+ path: '/api/seveneleven/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&size=1',
157
+ collectionKey: 'inventoryItems',
158
+ requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
159
+ },
160
+ {
161
+ id: 'oliveyoung.inventory',
162
+ service: 'oliveyoung',
163
+ target: 'inventory',
164
+ mode: 'deep',
165
+ 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',
166
+ collectionKey: 'inventoryProducts',
167
+ requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
168
+ timeoutMs: 5000,
169
+ },
170
+ ];
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 헬스 체크 응답 shape 검사 유틸리티
3
+ */
4
+ export function toCount(data) {
5
+ if (!data || typeof data !== 'object') {
6
+ return null;
7
+ }
8
+ const record = data;
9
+ if (typeof record.count === 'number') {
10
+ return record.count;
11
+ }
12
+ for (const key of ['products', 'stores', 'theaters', 'movies', 'showtimes']) {
13
+ const value = record[key];
14
+ if (Array.isArray(value)) {
15
+ return value.length;
16
+ }
17
+ }
18
+ if (record.inventory && typeof record.inventory === 'object') {
19
+ const inventory = record.inventory;
20
+ for (const key of ['products', 'items']) {
21
+ const value = inventory[key];
22
+ if (Array.isArray(value)) {
23
+ return value.length;
24
+ }
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+ export function toFirstName(data) {
30
+ if (!data || typeof data !== 'object') {
31
+ return undefined;
32
+ }
33
+ const record = data;
34
+ for (const key of ['products', 'stores', 'theaters', 'movies', 'showtimes']) {
35
+ const value = record[key];
36
+ if (!Array.isArray(value) || !value[0] || typeof value[0] !== 'object') {
37
+ continue;
38
+ }
39
+ const item = value[0];
40
+ for (const nameKey of ['productName', 'itemName', 'goodsName', 'name', 'storeName', 'theaterName', 'movieName']) {
41
+ if (typeof item[nameKey] === 'string' && item[nameKey].trim().length > 0) {
42
+ return item[nameKey].trim();
43
+ }
44
+ }
45
+ }
46
+ if (record.inventory && typeof record.inventory === 'object') {
47
+ const inventory = record.inventory;
48
+ for (const key of ['products', 'items']) {
49
+ const value = inventory[key];
50
+ if (!Array.isArray(value) || !value[0] || typeof value[0] !== 'object') {
51
+ continue;
52
+ }
53
+ const item = value[0];
54
+ for (const nameKey of ['productName', 'itemName', 'goodsName', 'name']) {
55
+ if (typeof item[nameKey] === 'string' && item[nameKey].trim().length > 0) {
56
+ return item[nameKey].trim();
57
+ }
58
+ }
59
+ }
60
+ }
61
+ return undefined;
62
+ }
63
+ function getCollectionItems(data, collectionKey) {
64
+ if (!data || typeof data !== 'object') {
65
+ return [];
66
+ }
67
+ const record = data;
68
+ if (collectionKey === 'inventoryProducts' || collectionKey === 'inventoryItems') {
69
+ const inventory = record.inventory;
70
+ if (!inventory || typeof inventory !== 'object') {
71
+ return [];
72
+ }
73
+ const key = collectionKey === 'inventoryProducts' ? 'products' : 'items';
74
+ const value = inventory[key];
75
+ return Array.isArray(value) ? value : [];
76
+ }
77
+ if (collectionKey && Array.isArray(record[collectionKey])) {
78
+ return record[collectionKey];
79
+ }
80
+ for (const key of ['products', 'stores', 'theaters', 'movies', 'showtimes']) {
81
+ /* c8 ignore next -- 현재 정의된 체크는 collectionKey를 명시한다. */
82
+ if (Array.isArray(record[key])) {
83
+ return record[key];
84
+ }
85
+ }
86
+ return [];
87
+ }
88
+ export function hasRequiredRepresentativeFields(data, collectionKey, requiredFields = []) {
89
+ /* c8 ignore next -- 현재 정의된 API 체크는 requiredFields를 명시한다. */
90
+ if (requiredFields.length === 0) {
91
+ return true;
92
+ }
93
+ const items = getCollectionItems(data, collectionKey);
94
+ if (items.length === 0) {
95
+ return true;
96
+ }
97
+ const first = items[0];
98
+ if (!first || typeof first !== 'object' || Array.isArray(first)) {
99
+ return false;
100
+ }
101
+ const record = first;
102
+ return requiredFields.some((field) => {
103
+ const value = record[field];
104
+ return typeof value === 'string' ? value.trim().length > 0 : value !== undefined && value !== null;
105
+ });
106
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * 헬스 체크 공통 타입
3
+ */
4
+ export {};