daiso 1.0.7 → 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.
- package/README.md +106 -7
- package/dist/api/actionsProxy.js +10 -0
- package/dist/api/compareHandlers.js +26 -0
- package/dist/api/configStatus.js +8 -0
- package/dist/api/feedbackHandlers.js +60 -0
- package/dist/api/healthCheckDefinitions.js +170 -0
- package/dist/api/healthCheckShape.js +106 -0
- package/dist/api/healthCheckTypes.js +4 -0
- package/dist/api/healthChecks.js +3 -271
- package/dist/api/opinetHandlers.js +91 -0
- package/dist/api/routes/compareRoutes.js +9 -0
- package/dist/api/routes/feedbackRoutes.js +5 -0
- package/dist/api/routes/healthRoutes.js +17 -1
- package/dist/api/routes/opinetRoutes.js +24 -0
- package/dist/bin.js +0 -0
- package/dist/cli/commandOptions.js +24 -2
- package/dist/cli/commands/apiCommand.js +24 -0
- package/dist/cli/commands/cinema.js +65 -0
- package/dist/cli/commands/convenience.js +75 -100
- package/dist/cli/http.js +1 -1
- package/dist/cli.js +12 -1
- package/dist/cliHelp.js +55 -8
- package/dist/cliRenderer.js +15 -0
- package/dist/core/outputSchema.js +141 -0
- package/dist/core/registry.js +1 -126
- package/dist/index.js +96 -28
- package/dist/pages/openapiFullSpec.js +8 -0
- package/dist/pages/openapiSpecActions.js +70 -0
- package/dist/pages/openapiSpecPathsCompare.js +59 -0
- package/dist/pages/openapiSpecPathsFeedback.js +95 -0
- package/dist/pages/openapiSpecPathsOpinet.js +81 -0
- package/dist/pages/promptReferenceText.js +18 -1
- package/dist/pages/promptRetailCinemaText.js +21 -4
- package/dist/services/compare/client.js +133 -0
- package/dist/services/compare/index.js +17 -0
- package/dist/services/compare/tools/compareProducts.js +45 -0
- package/dist/services/compare/types.js +1 -0
- package/dist/services/feedback/client.js +109 -0
- package/dist/services/feedback/index.js +22 -0
- package/dist/services/feedback/tools/submitDeveloperRequest.js +54 -0
- package/dist/services/feedback/types.js +3 -0
- package/dist/services/gs25/client.js +1 -84
- package/dist/services/gs25/productSearch.js +97 -0
- package/dist/services/oliveyoung/client.js +1 -55
- package/dist/services/oliveyoung/normalize.js +58 -0
- package/dist/services/opinet/client.js +231 -0
- package/dist/services/opinet/index.js +26 -0
- package/dist/services/opinet/location.js +119 -0
- package/dist/services/opinet/tools.js +80 -0
- package/dist/services/opinet/types.js +1 -0
- package/openapi.json +92 -0
- package/openapi.yaml +62 -0
- 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
|
|
|
@@ -35,6 +35,16 @@
|
|
|
35
35
|
<td>네이버 지역 검색</td>
|
|
36
36
|
<td>음식점, 카페, 디저트, 주변 장소</td>
|
|
37
37
|
</tr>
|
|
38
|
+
<tr>
|
|
39
|
+
<td>비교</td>
|
|
40
|
+
<td>다이소, GS25, 세븐일레븐, 이마트24</td>
|
|
41
|
+
<td>새 외부 키 없는 상품 가격 후보 비교</td>
|
|
42
|
+
</tr>
|
|
43
|
+
<tr>
|
|
44
|
+
<td>운영</td>
|
|
45
|
+
<td>Supabase</td>
|
|
46
|
+
<td>MCP 오류, 개선 요청, 신규 기능 요청 저장</td>
|
|
47
|
+
</tr>
|
|
38
48
|
<tr>
|
|
39
49
|
<td>리테일</td>
|
|
40
50
|
<td>다이소, 올리브영, 롯데마트</td>
|
|
@@ -65,11 +75,11 @@
|
|
|
65
75
|
**[실시간 서비스 상태 보기](https://aka-page.betteruptime.com/)**
|
|
66
76
|
|
|
67
77
|
<!-- WORKERS_INVOCATIONS_CHART:START -->
|
|
68
|
-
<h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-
|
|
78
|
+
<h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-27, 88일)</h3>
|
|
69
79
|
|
|
70
|
-
<img src="./assets/analytics/workers-invocations.png?v=2026-05-
|
|
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%">
|
|
71
81
|
|
|
72
|
-
<sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-
|
|
82
|
+
<sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-28 07:46 KST</sub>
|
|
73
83
|
|
|
74
84
|
<!-- WORKERS_INVOCATIONS_CHART:END -->
|
|
75
85
|
|
|
@@ -105,6 +115,7 @@ ChatGPT, Claude, Grok 같은 AI 앱에서 바로 연결해 사용할 수 있습
|
|
|
105
115
|
|
|
106
116
|
```
|
|
107
117
|
다이소 mcp로 수납박스 검색해줘
|
|
118
|
+
콜라 어디가 싼지 비교해줘
|
|
108
119
|
올리브영 mcp로 명동 근처 매장 찾아줘
|
|
109
120
|
이마트24 mcp로 강남 근처 매장과 두바이 재고 알려줘
|
|
110
121
|
롯데마트 mcp로 잠실 근처 매장 찾아줘
|
|
@@ -122,6 +133,15 @@ GS25 mcp로 강남 근처 매장과 오감자 재고 알려줘
|
|
|
122
133
|
CGV mcp로 강남 상영 영화와 시간표 알려줘
|
|
123
134
|
```
|
|
124
135
|
|
|
136
|
+
에이전트가 고르면 좋은 대표 흐름:
|
|
137
|
+
|
|
138
|
+
- 가격 비교: `콜라 어디가 싸?` → `compare_products`
|
|
139
|
+
- 주변 장소: `강남역 근처 카페 찾아줘` → `places_search_nearby`
|
|
140
|
+
- 브랜드 명시 재고: `다이소 핫식스 재고 찾아줘` → 먼저 다이소에서 검색 후 결과 없을 때만 대안 제안
|
|
141
|
+
- 편의점 재고: `GS25 강남 오감자 재고` → 상품 후보 확인 후 재고 조회
|
|
142
|
+
- 영화 시간표: `오늘 강남 CGV 시간표` → KST 오늘 날짜로 극장 검색 후 시간표 조회
|
|
143
|
+
- 개발자 요청: `올리브영 재고 도구가 계속 실패한다고 개발자에게 알려줘` → `submit_developer_request`
|
|
144
|
+
|
|
125
145
|
<br>
|
|
126
146
|
|
|
127
147
|
### 
|
|
@@ -217,7 +237,7 @@ AI 앱 연동 없이 터미널에서 바로 확인할 수 있습니다.
|
|
|
217
237
|
npx daiso products 수납박스 --json
|
|
218
238
|
npx daiso places 강남역 --category cafe --limit 5 --json
|
|
219
239
|
npx daiso gs25-inventory 오감자 --storeKeyword 강남 --storeLimit 5 --json
|
|
220
|
-
npx daiso
|
|
240
|
+
npx daiso cgv-movies --playDate 20260520 --theaterCode 0056 --json
|
|
221
241
|
```
|
|
222
242
|
|
|
223
243
|
<br>
|
|
@@ -254,10 +274,16 @@ npx daiso product 1034604
|
|
|
254
274
|
npx daiso stores 강남역
|
|
255
275
|
npx daiso inventory 1034604 --keyword 강남역
|
|
256
276
|
npx daiso display-location 1034604 04515
|
|
277
|
+
npx daiso compare 콜라 --limit 3
|
|
257
278
|
npx daiso places 강남역 --category cafe --limit 5
|
|
258
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
|
|
259
282
|
npx daiso cu-stores 강남
|
|
260
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
|
|
261
287
|
npx daiso lottemart-stores 잠실 --area 서울 --limit 10
|
|
262
288
|
npx daiso lottemart-products 콜라 --storeName 강변점 --area 서울
|
|
263
289
|
npx daiso emart24-stores 강남 --service24h true
|
|
@@ -268,10 +294,9 @@ npx daiso gs25-products 오감자 --limit 20
|
|
|
268
294
|
npx daiso gs25-inventory 오감자 --storeKeyword 강남 --storeLimit 10
|
|
269
295
|
npx daiso seveneleven-products 삼각김밥 --size 20
|
|
270
296
|
npx daiso seveneleven-stores 안산 중앙역 --limit 10
|
|
271
|
-
npx daiso
|
|
297
|
+
npx daiso seveneleven-inventory 핫식스 --storeKeyword "안산 중앙역" --storeLimit 10
|
|
272
298
|
npx daiso seveneleven-popwords --label home
|
|
273
299
|
npx daiso seveneleven-catalog --includeIssues true --includeExhibition true --limit 10
|
|
274
|
-
npx daiso get /api/cgv/movies --playDate 20260307 --theaterCode 0056
|
|
275
300
|
|
|
276
301
|
# 원본 JSON 필요 시
|
|
277
302
|
npx daiso products 수납박스 --json
|
|
@@ -287,6 +312,7 @@ npx daiso products 수납박스 --json
|
|
|
287
312
|
- 명령 맵: `skills/daiso-cli/references/cli-command-map.md`
|
|
288
313
|
- 기본 원칙: 쉘 실행이 가능한 환경에서는 CLI를 우선 사용하고, AI 앱 연결이 필요할 때는 MCP 서버 URL `https://mcp.aka.page`를 사용합니다.
|
|
289
314
|
- 구조화 결과가 필요하면 스킬은 `npx daiso ... --json` 형태를 선택합니다.
|
|
315
|
+
- 상황별 레시피는 `콜라 어디가 싸?`, `강남역 근처 카페`, `다이소 핫식스 재고`, `오늘 강남 CGV 시간표`처럼 실제 사용자 문장 기준으로 정리되어 있습니다.
|
|
290
316
|
|
|
291
317
|
### OpenAPI 스펙
|
|
292
318
|
|
|
@@ -304,6 +330,79 @@ MCP 도구 응답은 각 서비스의 원본 필드를 유지하면서, 클라
|
|
|
304
330
|
- `standard.theaters`: 영화관 목록을 `code`, `name`, `address`, `distanceMeters`, `raw` 형태로 정규화합니다.
|
|
305
331
|
- `standard.places`: 음식점/카페 같은 장소 목록을 `name`, `address`, `raw` 중심으로 정규화합니다.
|
|
306
332
|
|
|
333
|
+
### 키 없는 통합 가격 후보 비교
|
|
334
|
+
|
|
335
|
+
새 외부 API 키 없이 기존 상품 검색을 묶어 가격 후보를 비교합니다.
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
npx daiso compare 콜라 --limit 3 --json
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
REST:
|
|
342
|
+
|
|
343
|
+
```text
|
|
344
|
+
GET /api/compare/products?keyword=콜라&limit=3
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
지원 서비스는 `daiso`, `gs25`, `seveneleven`, `emart24`입니다. GS25처럼 상품 검색 응답에 가격이 없는 서비스는 후보에는 포함되지만 최저가 계산에서는 제외됩니다.
|
|
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
|
+
|
|
307
406
|
### 운영 헬스 체크
|
|
308
407
|
|
|
309
408
|
공개 상태 페이지: **[Daiso MCP Status](https://aka-page.betteruptime.com/)**
|
package/dist/api/actionsProxy.js
CHANGED
|
@@ -86,11 +86,21 @@ export const ACTION_QUERY_DEFINITIONS = [
|
|
|
86
86
|
summary: '세븐일레븐 카탈로그 조회',
|
|
87
87
|
targetPath: '/api/seveneleven/catalog',
|
|
88
88
|
},
|
|
89
|
+
{
|
|
90
|
+
action: 'compareProducts',
|
|
91
|
+
summary: '키 없는 통합 상품 가격 후보 비교',
|
|
92
|
+
targetPath: '/api/compare/products',
|
|
93
|
+
},
|
|
89
94
|
{
|
|
90
95
|
action: 'placesSearchNearby',
|
|
91
96
|
summary: '음식점/카페 등 주변 장소 검색',
|
|
92
97
|
targetPath: '/api/places/search',
|
|
93
98
|
},
|
|
99
|
+
{
|
|
100
|
+
action: 'submitDeveloperRequest',
|
|
101
|
+
summary: '개발자 요청 제출',
|
|
102
|
+
targetPath: '/api/feedback/requests',
|
|
103
|
+
},
|
|
94
104
|
{
|
|
95
105
|
action: 'megaboxFindTheaters',
|
|
96
106
|
summary: '메가박스 지점 검색',
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { compareProducts, parseCompareServices } from '../services/compare/client.js';
|
|
2
|
+
import { errorResponse, successResponse } from './response.js';
|
|
3
|
+
export async function handleCompareProducts(c) {
|
|
4
|
+
const keyword = c.req.query('keyword') || c.req.query('q') || '';
|
|
5
|
+
const limit = parseInt(c.req.query('limit') || '5', 10);
|
|
6
|
+
const services = parseCompareServices(c.req.query('services'));
|
|
7
|
+
if (keyword.trim().length === 0) {
|
|
8
|
+
return errorResponse(c, 'MISSING_KEYWORD', '검색어(keyword)를 입력해주세요.');
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const result = await compareProducts({
|
|
12
|
+
keyword,
|
|
13
|
+
limit,
|
|
14
|
+
services,
|
|
15
|
+
});
|
|
16
|
+
return successResponse(c, result, {
|
|
17
|
+
total: result.resultCount,
|
|
18
|
+
page: 1,
|
|
19
|
+
pageSize: limit,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
|
24
|
+
return errorResponse(c, 'COMPARE_PRODUCTS_FAILED', message, 500);
|
|
25
|
+
}
|
|
26
|
+
}
|
package/dist/api/configStatus.js
CHANGED
|
@@ -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
|
+
}
|