daiso 1.0.7 → 1.0.8

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 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,11 @@
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>
38
43
  <tr>
39
44
  <td>리테일</td>
40
45
  <td>다이소, 올리브영, 롯데마트</td>
@@ -65,11 +70,11 @@
65
70
  **[실시간 서비스 상태 보기](https://aka-page.betteruptime.com/)**
66
71
 
67
72
  <!-- WORKERS_INVOCATIONS_CHART:START -->
68
- <h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-21, 82일)</h3>
73
+ <h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-23, 84일)</h3>
69
74
 
70
- <img src="./assets/analytics/workers-invocations.png?v=2026-05-23T04:50:41.937Z" alt="Cloudflare Workers 호출량 그래프 (2026-03-01 ~ 2026-05-21)" width="100%">
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%">
71
76
 
72
- <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-23 13:50 KST</sub>
77
+ <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-24 00:44 KST</sub>
73
78
 
74
79
  <!-- WORKERS_INVOCATIONS_CHART:END -->
75
80
 
@@ -105,6 +110,7 @@ ChatGPT, Claude, Grok 같은 AI 앱에서 바로 연결해 사용할 수 있습
105
110
 
106
111
  ```
107
112
  다이소 mcp로 수납박스 검색해줘
113
+ 콜라 어디가 싼지 비교해줘
108
114
  올리브영 mcp로 명동 근처 매장 찾아줘
109
115
  이마트24 mcp로 강남 근처 매장과 두바이 재고 알려줘
110
116
  롯데마트 mcp로 잠실 근처 매장 찾아줘
@@ -254,6 +260,7 @@ npx daiso product 1034604
254
260
  npx daiso stores 강남역
255
261
  npx daiso inventory 1034604 --keyword 강남역
256
262
  npx daiso display-location 1034604 04515
263
+ npx daiso compare 콜라 --limit 3
257
264
  npx daiso places 강남역 --category cafe --limit 5
258
265
  npx daiso places 성수동 --keyword 브런치 --limit 5
259
266
  npx daiso cu-stores 강남
@@ -304,6 +311,22 @@ MCP 도구 응답은 각 서비스의 원본 필드를 유지하면서, 클라
304
311
  - `standard.theaters`: 영화관 목록을 `code`, `name`, `address`, `distanceMeters`, `raw` 형태로 정규화합니다.
305
312
  - `standard.places`: 음식점/카페 같은 장소 목록을 `name`, `address`, `raw` 중심으로 정규화합니다.
306
313
 
314
+ ### 키 없는 통합 가격 후보 비교
315
+
316
+ 새 외부 API 키 없이 기존 상품 검색을 묶어 가격 후보를 비교합니다.
317
+
318
+ ```bash
319
+ npx daiso compare 콜라 --limit 3 --json
320
+ ```
321
+
322
+ REST:
323
+
324
+ ```text
325
+ GET /api/compare/products?keyword=콜라&limit=3
326
+ ```
327
+
328
+ 지원 서비스는 `daiso`, `gs25`, `seveneleven`, `emart24`입니다. GS25처럼 상품 검색 응답에 가격이 없는 서비스는 후보에는 포함되지만 최저가 계산에서는 제외됩니다.
329
+
307
330
  ### 운영 헬스 체크
308
331
 
309
332
  공개 상태 페이지: **[Daiso MCP Status](https://aka-page.betteruptime.com/)**
@@ -86,6 +86,11 @@ 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: '음식점/카페 등 주변 장소 검색',
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ import { withEdgeCache } from '../../utils/cache.js';
2
+ import { handleCompareProducts } from '../compareHandlers.js';
3
+ export function registerCompareRoutes(app) {
4
+ app.get('/api/compare/products', async (c) => withEdgeCache(c.req.url, {
5
+ ttlSeconds: 60 * 10,
6
+ staleWhileRevalidateSeconds: 60,
7
+ keyPrefix: 'compare-products-v1',
8
+ }, () => handleCompareProducts(c)));
9
+ }
@@ -12,6 +12,7 @@ export const COMMAND_OPTION_ALLOWLISTS = {
12
12
  stores: ['help', 'json', 'keyword', 'sido', 'gugun', 'dong', 'limit'],
13
13
  inventory: ['help', 'json', 'keyword', 'lat', 'lng', 'page', 'pageSize'],
14
14
  'display-location': ['help', 'json'],
15
+ compare: ['help', 'json', 'keyword', 'q', 'services', 'limit'],
15
16
  places: ['help', 'json', 'location', 'category', 'keyword', 'limit', 'start', 'sort'],
16
17
  'cu-stores': ['help', 'json', 'keyword', 'lat', 'lng', 'limit'],
17
18
  'cu-inventory': [
@@ -6,6 +6,24 @@ import { printCommandHelp } from '../../cliHelp.js';
6
6
  import { parseCliArgs, toUrl, applyOptionsToQuery, toQueryOptions } from '../args.js';
7
7
  import { validateCommandOptions } from '../commandOptions.js';
8
8
  import { requestAndPrintResponse } from '../http.js';
9
+ export async function handleCompareProducts(options, deps) {
10
+ const parsed = parseCliArgs(options);
11
+ if (parsed.options.help === 'true') {
12
+ return printCommandHelp('compare', deps.writeOut, deps.writeErr);
13
+ }
14
+ if (validateCommandOptions('compare', parsed.options, deps.writeErr)) {
15
+ return 1;
16
+ }
17
+ const keyword = parsed.positionals[0] || parsed.options.keyword || parsed.options.q;
18
+ if (!keyword) {
19
+ deps.writeErr('compare 명령은 검색어가 필요합니다. 예: daiso compare 콜라');
20
+ return 1;
21
+ }
22
+ const targetUrl = toUrl('/api/compare/products');
23
+ targetUrl.searchParams.set('keyword', keyword);
24
+ applyOptionsToQuery(targetUrl, toQueryOptions(parsed.options));
25
+ return await requestAndPrintResponse(deps.fetchImpl, deps.writeOut, deps.writeErr, targetUrl, 'compare', parsed.options.json === 'true');
26
+ }
9
27
  export async function handlePlaces(options, deps) {
10
28
  const parsed = parseCliArgs(options);
11
29
  if (parsed.options.help === 'true') {
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@ import { DEFAULT_BASE_URL, DEFAULT_MCP_URL } from './cli/constants.js';
12
12
  import { parseCliArgs } from './cli/args.js';
13
13
  import { validateCommandOptions } from './cli/commandOptions.js';
14
14
  import { handleGet, handleProducts, handleProduct, handleStores, handleInventory, handleDisplayLocation, } from './cli/commands/daiso.js';
15
- import { handleCuStores, handleCuInventory, handleEmart24Stores, handleEmart24Products, handleEmart24Inventory, handleLotteMartStores, handleLotteMartProducts, handlePlaces, handleGs25Stores, handleGs25Products, handleGs25Inventory, handleSevenElevenProducts, handleSevenElevenStores, handleSevenElevenPopwords, handleSevenElevenCatalog, handleLottecinemaTheaters, handleLottecinemaMovies, handleLottecinemaSeats, } from './cli/commands/convenience.js';
15
+ import { handleCuStores, handleCuInventory, handleEmart24Stores, handleEmart24Products, handleEmart24Inventory, handleLotteMartStores, handleLotteMartProducts, handleCompareProducts, handlePlaces, handleGs25Stores, handleGs25Products, handleGs25Inventory, handleSevenElevenProducts, handleSevenElevenStores, handleSevenElevenPopwords, handleSevenElevenCatalog, handleLottecinemaTheaters, handleLottecinemaMovies, handleLottecinemaSeats, } from './cli/commands/convenience.js';
16
16
  function rejectTopLevelUnknownOptions(options, command, deps) {
17
17
  const parsed = parseCliArgs(options);
18
18
  return validateCommandOptions(command, parsed.options, deps.writeErr);
@@ -112,6 +112,8 @@ export async function runCli(argv, deps) {
112
112
  return await handleInventory(options, resolvedDeps);
113
113
  if (command === 'display-location')
114
114
  return await handleDisplayLocation(options, resolvedDeps);
115
+ if (command === 'compare')
116
+ return await handleCompareProducts(options, resolvedDeps);
115
117
  if (command === 'places')
116
118
  return await handlePlaces(options, resolvedDeps);
117
119
  if (command === 'cu-stores')
package/dist/cliHelp.js CHANGED
@@ -13,6 +13,7 @@ export const COMMAND_LIST = [
13
13
  'stores',
14
14
  'inventory',
15
15
  'display-location',
16
+ 'compare',
16
17
  'places',
17
18
  'cu-stores',
18
19
  'cu-inventory',
@@ -44,6 +45,7 @@ const COMMAND_SUMMARY = {
44
45
  stores: '다이소 매장 검색',
45
46
  inventory: '다이소 재고 조회',
46
47
  'display-location': '다이소 진열 위치 조회',
48
+ compare: '키 없는 통합 상품 가격 후보 비교',
47
49
  places: '음식점/카페 등 주변 장소 검색',
48
50
  'cu-stores': 'CU 매장 검색',
49
51
  'cu-inventory': 'CU 재고 조회',
@@ -139,6 +141,15 @@ const COMMAND_DETAIL = {
139
141
  '옵션: --json',
140
142
  '예시: daiso display-location 1034604 04515',
141
143
  ],
144
+ compare: [
145
+ '명령: compare',
146
+ '설명: 새 외부 키 없이 다이소, GS25, 세븐일레븐, 이마트24 상품 검색을 묶어 가격 후보를 비교합니다.',
147
+ '사용법: daiso compare <keyword> [--services daiso,gs25,seveneleven,emart24] [--limit N] [--json]',
148
+ '필수: <keyword>',
149
+ '옵션: --services, --limit, --json',
150
+ '예시: daiso compare 콜라 --limit 3',
151
+ '예시: daiso compare 컵라면 --services seveneleven,emart24 --json',
152
+ ],
142
153
  places: [
143
154
  '명령: places',
144
155
  '설명: 네이버 지역 검색으로 음식점, 카페, 디저트 가게 등 주변 장소를 검색합니다.',
@@ -174,6 +174,18 @@ function formatInventory(data) {
174
174
  }
175
175
  return lines;
176
176
  }
177
+ function formatCompare(data) {
178
+ const lines = [];
179
+ if (isRecord(data.bestPrice)) {
180
+ lines.push(`최저가 후보: ${toText(data.bestPrice.serviceName)} / ${toText(data.bestPrice.name)} / ${toText(data.bestPrice.price)}원`);
181
+ }
182
+ lines.push(...formatCollection('가격 후보', data.results));
183
+ lines.push(...formatCollection('오류', data.errors));
184
+ if (typeof data.note === 'string') {
185
+ lines.push(`참고: ${data.note}`);
186
+ }
187
+ return lines;
188
+ }
177
189
  export function renderApiEnvelope(command, url, payload) {
178
190
  if (!isRecord(payload)) {
179
191
  return `요청 성공: ${url.pathname}\n${toText(payload)}\n\n원본 JSON은 --json 옵션으로 확인하세요.`;
@@ -199,6 +211,9 @@ export function renderApiEnvelope(command, url, payload) {
199
211
  else if (command === 'display-location') {
200
212
  lines.push(...formatDisplayLocation(data));
201
213
  }
214
+ else if (command === 'compare') {
215
+ lines.push(...formatCompare(data));
216
+ }
202
217
  else if (command === 'cu-inventory') {
203
218
  lines.push(...formatCollection('매장 목록', isRecord(data.nearbyStores) ? data.nearbyStores.stores : undefined));
204
219
  lines.push(...formatCollection('재고 항목', isRecord(data.inventory) ? data.inventory.items : undefined));
@@ -7,6 +7,18 @@ import * as z from 'zod';
7
7
  import { getErrorMessage, toStandardErrorDiagnostics } from './errors.js';
8
8
  const DEFAULT_OUTPUT_SCHEMA = z.object({}).loose().describe('도구 실행 결과(JSON 객체)');
9
9
  function createFallbackOutputSchema(toolName) {
10
+ if (toolName.includes('compare')) {
11
+ return {
12
+ keyword: z.string().optional(),
13
+ services: z.array(z.string()).optional(),
14
+ serviceCount: z.number().optional(),
15
+ resultCount: z.number().optional(),
16
+ bestPrice: z.unknown().optional(),
17
+ results: z.array(z.unknown()).optional(),
18
+ errors: z.array(z.unknown()).optional(),
19
+ note: z.string().optional(),
20
+ };
21
+ }
10
22
  if (toolName.includes('search_products') ||
11
23
  toolName.includes('searchProducts') ||
12
24
  toolName.endsWith('_products')) {
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { createEmart24Service } from './services/emart24/index.js';
20
20
  import { createGs25Service } from './services/gs25/index.js';
21
21
  import { createSevenElevenService } from './services/seveneleven/index.js';
22
22
  import { createPlacesService } from './services/places/index.js';
23
+ import { createCompareService } from './services/compare/index.js';
23
24
  import { createPromptResponse } from './pages/prompt.js';
24
25
  import { createFullOpenApiJsonResponse, createFullOpenApiYamlResponse, createOpenApiJsonResponse, createOpenApiYamlResponse, } from './pages/openapi.js';
25
26
  import { createPrivacyResponse } from './pages/privacy.js';
@@ -35,6 +36,7 @@ import { registerEmart24Routes } from './api/routes/emart24Routes.js';
35
36
  import { registerGs25Routes } from './api/routes/gs25Routes.js';
36
37
  import { registerSevenElevenRoutes } from './api/routes/sevenelevenRoutes.js';
37
38
  import { registerPlacesRoutes } from './api/routes/placesRoutes.js';
39
+ import { registerCompareRoutes } from './api/routes/compareRoutes.js';
38
40
  import { registerHealthRoutes } from './api/routes/healthRoutes.js';
39
41
  import { buildConfigStatus } from './api/configStatus.js';
40
42
  // 서버 메타데이터
@@ -55,6 +57,7 @@ const createRegistry = (bindings) => {
55
57
  zyteApiKey: bindings?.ZYTE_API_KEY,
56
58
  }),
57
59
  createSevenElevenService,
60
+ createCompareService,
58
61
  () => createPlacesService({
59
62
  naverClientId: bindings?.NAVER_CLIENT_ID,
60
63
  naverClientSecret: bindings?.NAVER_CLIENT_SECRET,
@@ -195,6 +198,7 @@ app.get('/', (c) => {
195
198
  openapi: '/openapi.json (GET) - OpenAI Actions용 축약 OpenAPI',
196
199
  openapiFull: '/openapi-full.json (GET) - 전체 OpenAPI',
197
200
  actionsQuery: '/api/actions/query (GET) - 기존 GET API 통합 facade',
201
+ compareProducts: '/api/compare/products (GET) - 키 없는 통합 상품 가격 후보 비교',
198
202
  },
199
203
  services,
200
204
  tools: allTools,
@@ -243,6 +247,7 @@ app.get('/privacy', (c) => {
243
247
  registerDaisoRoutes(app);
244
248
  registerGs25Routes(app);
245
249
  registerSevenElevenRoutes(app);
250
+ registerCompareRoutes(app);
246
251
  registerPlacesRoutes(app);
247
252
  registerCuRoutes(app);
248
253
  registerEmart24Routes(app);
@@ -11,6 +11,7 @@ import { OPENAPI_PATHS_LOTTECINEMA } from './openapiSpecPathsLottecinema.js';
11
11
  import { OPENAPI_PATHS_GS25 } from './openapiSpecPathsGs25.js';
12
12
  import { OPENAPI_PATHS_SEVENELEVEN } from './openapiSpecPathsSeveneleven.js';
13
13
  import { OPENAPI_PATHS_PLACES } from './openapiSpecPathsPlaces.js';
14
+ import { OPENAPI_PATHS_COMPARE } from './openapiSpecPathsCompare.js';
14
15
  import { OPENAPI_COMPONENTS } from './openapiSpecComponents.js';
15
16
  export function generateFullOpenApiSpec(baseUrl) {
16
17
  return {
@@ -26,6 +27,7 @@ export function generateFullOpenApiSpec(baseUrl) {
26
27
  - 🧭 **진열 위치 조회**: 매장 내 상품 구역/층 정보 조회
27
28
  - 🏪 **GS25 매장/상품/재고 조회**: GS25 매장 탐색, 상품 검색, 재고 조회
28
29
  - 7️⃣ **세븐일레븐 상품/매장/재고 조회**: 상품 검색, 매장 검색, 실재고 조회
30
+ - 🧾 **통합 가격 후보 비교**: 새 외부 키 없이 상품 검색 결과를 묶어 가격 비교
29
31
  - 🏪 **CU 매장/재고 조회**: CU 매장 탐색 및 상품 재고 검색
30
32
  - 🎬 **메가박스 지점/영화 조회**: 주변 지점, 상영 목록, 잔여 좌석 조회
31
33
  - 🎞️ **롯데시네마 지점/영화 조회**: 주변 지점, 상영 목록, 잔여 좌석 조회
@@ -51,6 +53,7 @@ export function generateFullOpenApiSpec(baseUrl) {
51
53
  ...OPENAPI_PATHS_LOTTEMART,
52
54
  ...OPENAPI_PATHS_GS25,
53
55
  ...OPENAPI_PATHS_SEVENELEVEN,
56
+ ...OPENAPI_PATHS_COMPARE,
54
57
  ...OPENAPI_PATHS_PLACES,
55
58
  ...OPENAPI_PATHS_MEGABOX,
56
59
  ...OPENAPI_PATHS_LOTTECINEMA,
@@ -63,6 +63,13 @@ const ACTION_QUERY_PARAMETERS = [
63
63
  description: '주변 장소 카테고리(restaurant, cafe, food, dessert, all)',
64
64
  schema: { type: 'string' },
65
65
  },
66
+ {
67
+ name: 'services',
68
+ in: 'query',
69
+ required: false,
70
+ description: '통합 비교 서비스 목록(daiso,gs25,seveneleven,emart24)',
71
+ schema: { type: 'string' },
72
+ },
66
73
  {
67
74
  name: 'sido',
68
75
  in: 'query',
@@ -0,0 +1,59 @@
1
+ export const OPENAPI_PATHS_COMPARE = {
2
+ '/api/compare/products': {
3
+ get: {
4
+ operationId: 'compareProducts',
5
+ summary: '키 없는 통합 상품 가격 후보 비교',
6
+ description: '새 외부 키 없이 다이소, GS25, 세븐일레븐, 이마트24 상품 검색을 묶어 가격 후보를 비교합니다.',
7
+ parameters: [
8
+ {
9
+ name: 'keyword',
10
+ in: 'query',
11
+ required: true,
12
+ description: '비교할 상품 검색어',
13
+ schema: { type: 'string' },
14
+ },
15
+ {
16
+ name: 'services',
17
+ in: 'query',
18
+ required: false,
19
+ description: '쉼표로 구분한 서비스 목록(daiso,gs25,seveneleven,emart24)',
20
+ schema: { type: 'string' },
21
+ },
22
+ {
23
+ name: 'limit',
24
+ in: 'query',
25
+ required: false,
26
+ description: '서비스별 최대 결과 수',
27
+ schema: { type: 'integer', default: 5, minimum: 1, maximum: 20 },
28
+ },
29
+ ],
30
+ responses: {
31
+ '200': {
32
+ description: '통합 상품 가격 후보 비교 결과',
33
+ content: {
34
+ 'application/json': {
35
+ schema: {
36
+ type: 'object',
37
+ properties: {
38
+ success: { type: 'boolean' },
39
+ data: {
40
+ type: 'object',
41
+ properties: {
42
+ keyword: { type: 'string' },
43
+ serviceCount: { type: 'integer' },
44
+ resultCount: { type: 'integer' },
45
+ bestPrice: { type: ['object', 'null'] },
46
+ results: { type: 'array', items: { type: 'object' } },
47
+ errors: { type: 'array', items: { type: 'object' } },
48
+ note: { type: 'string' },
49
+ },
50
+ },
51
+ },
52
+ },
53
+ },
54
+ },
55
+ },
56
+ },
57
+ },
58
+ },
59
+ };
@@ -98,7 +98,8 @@ export function buildPromptReferenceText(baseUrl) {
98
98
  11. **이마트24 재고 조회**: /api/emart24/inventory는 pluCd + storeKeyword 조합도 지원하므로, 상품 선택 뒤 매장 코드를 다시 모으지 않아도 됩니다.
99
99
  12. **올리브영 재고 해석**: inventory.products[].storeInventory.stores[]가 있으면 그 매장별 stockLabel과 remainQuantity를 우선 사용하고, inStock는 그 주변 매장 기준 결과로 해석합니다.
100
100
  13. **올리브영 상품 이미지 표시**: /api/oliveyoung/products 또는 oliveyoung_search_products 결과에 imageUrl이 있으면 목록형 답변에서도 각 상품 이미지를 생략하지 말고 모두 렌더링합니다. 특히 \`?l=ko\` 같은 query string을 삭제하지 말고 전체 URL 그대로 마크다운 이미지 src에 넣습니다.
101
- 14. **주변 음식점/카페 검색**: 음식점이나 카페만 물으면 \`places_search_nearby\` 또는 /api/places/search를 먼저 사용합니다. 이 기능은 네이버 지역 검색 기반 키워드 검색이므로 좌표 반경 검색처럼 단정하지 말고, \`searchMode: "keyword"\`임을 필요할 때 짧게 밝혀주세요.
101
+ 14. **통합 가격 후보 비교**: 사용자가 브랜드를 특정하지 않고 "어디가 싸?", "비교해줘"처럼 물으면 \`compare_products\` 또는 /api/compare/products를 먼저 사용합니다. 새 외부 키 없이 기존 상품 검색을 묶은 1차 가격 후보이므로 실제 재고/행사가는 각 서비스 상세 조회로 확인해야 합니다.
102
+ 15. **주변 음식점/카페 검색**: 음식점이나 카페만 물으면 \`places_search_nearby\` 또는 /api/places/search를 먼저 사용합니다. 이 기능은 네이버 지역 검색 기반 키워드 검색이므로 좌표 반경 검색처럼 단정하지 말고, \`searchMode: "keyword"\`임을 필요할 때 짧게 밝혀주세요.
102
103
 
103
104
  ---
104
105
 
@@ -123,6 +124,7 @@ MCP 연결 정보: ${baseUrl}/mcp
123
124
  - seveneleven_check_inventory: 세븐일레븐 재고 조회
124
125
  - seveneleven_get_search_popwords: 세븐일레븐 인기 검색어 조회
125
126
  - seveneleven_get_catalog_snapshot: 세븐일레븐 카탈로그 조회
127
+ - compare_products: 키 없는 통합 상품 가격 후보 비교
126
128
  - places_search_nearby: 음식점/카페 등 주변 장소 검색
127
129
  - oliveyoung_search_products: 올리브영 상품 검색
128
130
  - oliveyoung_find_nearby_stores: 올리브영 주변 매장 탐색
@@ -2,7 +2,24 @@
2
2
  * 프롬프트 리테일/영화 API 섹션
3
3
  */
4
4
  export function buildPromptRetailCinemaText(baseUrl) {
5
- return `### 6-0. 주변 음식점/카페 검색
5
+ return `### 6-0. 키 없는 통합 상품 가격 후보 비교
6
+
7
+ **설명**: 새 외부 API 키 없이 기존 다이소, GS25, 세븐일레븐, 이마트24 상품 검색을 묶어 가격 후보를 비교합니다. 실제 매장 재고와 행사가까지 확정하는 기능이 아니라 "어디가 싸 보이는지"를 빠르게 고르는 1차 검색입니다.
8
+
9
+ **URL**:
10
+ - ${baseUrl}/api/compare/products?keyword={검색어}
11
+
12
+ **선택 파라미터**:
13
+ - services: daiso,gs25,seveneleven,emart24 중 쉼표 구분 목록
14
+ - limit: 서비스별 최대 결과 수 (기본값: 5)
15
+
16
+ **예시**:
17
+ - ${baseUrl}/api/compare/products?keyword=콜라&limit=3
18
+ - ${baseUrl}/api/compare/products?keyword=컵라면&services=seveneleven,emart24
19
+
20
+ ---
21
+
22
+ ### 6-1. 주변 음식점/카페 검색
6
23
 
7
24
  **설명**: 네이버 지역 검색으로 특정 지역의 음식점, 카페, 디저트 가게 등 주변 장소를 조회합니다. 좌표 반경 검색이 아니라 \`강남역 카페\` 같은 키워드 기반 검색입니다.
8
25
 
@@ -23,7 +40,7 @@ export function buildPromptRetailCinemaText(baseUrl) {
23
40
 
24
41
  ---
25
42
 
26
- ### 6-1. 롯데마트 매장/상품 조회
43
+ ### 6-2. 롯데마트 매장/상품 조회
27
44
 
28
45
  **설명**: 롯데마트 계열 매장 검색과 특정 매장 기준 상품 가격/재고 조회를 제공합니다.
29
46
 
@@ -49,7 +66,7 @@ export function buildPromptRetailCinemaText(baseUrl) {
49
66
 
50
67
  ---
51
68
 
52
- ### 6-2. GS25 매장/상품/재고 조회
69
+ ### 6-3. GS25 매장/상품/재고 조회
53
70
 
54
71
  **설명**: GS25 매장 탐색, 상품 키워드 검색, 재고 조회를 제공합니다.
55
72
 
@@ -73,7 +90,7 @@ export function buildPromptRetailCinemaText(baseUrl) {
73
90
 
74
91
  ---
75
92
 
76
- ### 6-3. 세븐일레븐 상품/매장/재고/인기검색어/카탈로그 조회
93
+ ### 6-4. 세븐일레븐 상품/매장/재고/인기검색어/카탈로그 조회
77
94
 
78
95
  **설명**: 세븐일레븐 상품 검색, 매장 검색, 재고 수량 조회, 인기 검색어, 카탈로그 스냅샷을 제공합니다.
79
96
 
@@ -0,0 +1,133 @@
1
+ import { fetchProducts as fetchDaisoProducts } from '../daiso/tools/searchProducts.js';
2
+ import { fetchGs25SearchProducts } from '../gs25/client.js';
3
+ import { searchSevenElevenProducts } from '../seveneleven/client.js';
4
+ import { searchEmart24Products } from '../emart24/client.js';
5
+ const DEFAULT_COMPARE_SERVICES = ['daiso', 'gs25', 'seveneleven', 'emart24'];
6
+ const SERVICE_NAMES = {
7
+ daiso: '다이소',
8
+ gs25: 'GS25',
9
+ seveneleven: '세븐일레븐',
10
+ emart24: '이마트24',
11
+ };
12
+ function toErrorMessage(error) {
13
+ return error instanceof Error ? error.message : String(error);
14
+ }
15
+ function parseServices(value) {
16
+ if (!value || value.trim().length === 0) {
17
+ return DEFAULT_COMPARE_SERVICES;
18
+ }
19
+ const allowed = new Set(DEFAULT_COMPARE_SERVICES);
20
+ const services = value
21
+ .split(',')
22
+ .map((item) => item.trim().toLowerCase())
23
+ .filter((item) => allowed.has(item));
24
+ return services.length > 0 ? [...new Set(services)] : DEFAULT_COMPARE_SERVICES;
25
+ }
26
+ function normalizePrice(value) {
27
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
28
+ }
29
+ function sortComparableProducts(products) {
30
+ const serviceOrder = new Map(DEFAULT_COMPARE_SERVICES.map((service, index) => [service, index]));
31
+ return [...products].sort((a, b) => {
32
+ if (a.price !== null && b.price !== null && a.price !== b.price) {
33
+ return a.price - b.price;
34
+ }
35
+ if (a.price !== null && b.price === null) {
36
+ return -1;
37
+ }
38
+ if (a.price === null && b.price !== null) {
39
+ return 1;
40
+ }
41
+ return serviceOrder.get(a.service) - serviceOrder.get(b.service);
42
+ });
43
+ }
44
+ async function searchService(service, keyword, limit) {
45
+ if (service === 'daiso') {
46
+ const result = await fetchDaisoProducts(keyword, 1, limit);
47
+ return result.products.map((product) => ({
48
+ service,
49
+ serviceName: SERVICE_NAMES[service],
50
+ code: product.id,
51
+ name: product.name,
52
+ price: normalizePrice(product.price),
53
+ imageUrl: product.imageUrl,
54
+ raw: product,
55
+ }));
56
+ }
57
+ if (service === 'gs25') {
58
+ const products = await fetchGs25SearchProducts(keyword, { timeout: 15000 });
59
+ return products.slice(0, limit).map((product) => ({
60
+ service,
61
+ serviceName: SERVICE_NAMES[service],
62
+ code: product.itemCode,
63
+ name: product.itemName || product.shortItemName,
64
+ price: null,
65
+ imageUrl: product.imageUrl,
66
+ stockCheckEnabled: product.stockCheckEnabled,
67
+ raw: product,
68
+ }));
69
+ }
70
+ if (service === 'seveneleven') {
71
+ const result = await searchSevenElevenProducts({ query: keyword, page: 1, size: limit });
72
+ return result.products.map((product) => ({
73
+ service,
74
+ serviceName: SERVICE_NAMES[service],
75
+ code: product.itemCode || product.productNo,
76
+ name: product.itemName,
77
+ price: normalizePrice(product.salePrice),
78
+ originalPrice: normalizePrice(product.originalPrice),
79
+ imageUrl: product.imageUrl,
80
+ raw: product,
81
+ }));
82
+ }
83
+ const result = await searchEmart24Products({
84
+ keyword,
85
+ page: 1,
86
+ pageSize: limit,
87
+ sortType: 'PRICE_ASC',
88
+ saleProductYn: 'N',
89
+ });
90
+ return result.products.map((product) => ({
91
+ service,
92
+ serviceName: SERVICE_NAMES[service],
93
+ code: product.pluCd,
94
+ name: product.goodsName,
95
+ price: normalizePrice(product.viewPrice),
96
+ originalPrice: normalizePrice(product.originPrice),
97
+ raw: product,
98
+ }));
99
+ }
100
+ export function parseCompareServices(value) {
101
+ return parseServices(value);
102
+ }
103
+ export async function compareProducts(params) {
104
+ const keyword = params.keyword.trim();
105
+ const limit = Math.max(1, Math.min(Math.trunc(params.limit || 5), 20));
106
+ const services = params.services && params.services.length > 0 ? params.services : DEFAULT_COMPARE_SERVICES;
107
+ const settled = await Promise.allSettled(services.map(async (service) => ({
108
+ service,
109
+ products: await searchService(service, keyword, limit),
110
+ })));
111
+ const results = [];
112
+ const errors = [];
113
+ for (const item of settled) {
114
+ if (item.status === 'fulfilled') {
115
+ results.push(...item.value.products);
116
+ }
117
+ else {
118
+ const service = services[settled.indexOf(item)];
119
+ errors.push({ service, message: toErrorMessage(item.reason) });
120
+ }
121
+ }
122
+ const sorted = sortComparableProducts(results);
123
+ return {
124
+ keyword,
125
+ services,
126
+ serviceCount: services.length,
127
+ resultCount: sorted.length,
128
+ bestPrice: sorted.find((product) => product.price !== null) || null,
129
+ results: sorted,
130
+ errors,
131
+ note: '새 외부 키 없이 기존 공개 상품 검색을 묶은 가격 후보입니다. 실제 재고와 행사가는 매장별로 달라질 수 있습니다.',
132
+ };
133
+ }
@@ -0,0 +1,17 @@
1
+ import { createCompareProductsTool } from './tools/compareProducts.js';
2
+ const COMPARE_METADATA = {
3
+ id: 'compare',
4
+ name: '통합 비교',
5
+ version: '1.0.0',
6
+ description: '새 외부 키 없이 기존 상품 검색을 묶어 가격 후보를 비교하는 서비스',
7
+ };
8
+ class CompareService {
9
+ metadata = COMPARE_METADATA;
10
+ getTools() {
11
+ return [createCompareProductsTool()];
12
+ }
13
+ }
14
+ export function createCompareService() {
15
+ return new CompareService();
16
+ }
17
+ export * from './types.js';
@@ -0,0 +1,45 @@
1
+ import * as z from 'zod';
2
+ import { compareProducts, parseCompareServices } from '../client.js';
3
+ async function compareProductsHandler(args) {
4
+ const keyword = args.keyword?.trim();
5
+ if (!keyword) {
6
+ throw new Error('검색어(keyword)를 입력해주세요.');
7
+ }
8
+ const result = await compareProducts({
9
+ keyword,
10
+ limit: args.limit,
11
+ services: parseCompareServices(args.services),
12
+ });
13
+ return {
14
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
15
+ structuredContent: { ...result },
16
+ };
17
+ }
18
+ export function createCompareProductsTool() {
19
+ return {
20
+ name: 'compare_products',
21
+ metadata: {
22
+ title: '가격 후보 통합 비교',
23
+ description: '새 외부 키 없이 다이소, GS25, 세븐일레븐, 이마트24 상품 검색을 묶어 가격 후보를 비교합니다.',
24
+ inputSchema: {
25
+ keyword: z.string().describe('비교할 상품 검색어'),
26
+ limit: z.number().optional().default(5).describe('서비스별 최대 결과 수'),
27
+ services: z
28
+ .string()
29
+ .optional()
30
+ .describe('쉼표로 구분한 서비스 목록(daiso,gs25,seveneleven,emart24)'),
31
+ },
32
+ outputSchema: {
33
+ keyword: z.string().optional(),
34
+ services: z.array(z.string()).optional(),
35
+ serviceCount: z.number().optional(),
36
+ resultCount: z.number().optional(),
37
+ bestPrice: z.unknown().optional(),
38
+ results: z.array(z.unknown()).optional(),
39
+ errors: z.array(z.unknown()).optional(),
40
+ note: z.string().optional(),
41
+ },
42
+ },
43
+ handler: compareProductsHandler,
44
+ };
45
+ }
@@ -0,0 +1 @@
1
+ export {};
package/openapi.json CHANGED
@@ -53,6 +53,7 @@
53
53
  "sevenelevenCheckInventory",
54
54
  "sevenelevenGetPopwords",
55
55
  "sevenelevenGetCatalog",
56
+ "compareProducts",
56
57
  "placesSearchNearby",
57
58
  "megaboxFindTheaters",
58
59
  "megaboxListMovies",
@@ -129,6 +130,15 @@
129
130
  "type": "string"
130
131
  }
131
132
  },
133
+ {
134
+ "name": "services",
135
+ "in": "query",
136
+ "required": false,
137
+ "description": "통합 비교 서비스 목록(daiso,gs25,seveneleven,emart24)",
138
+ "schema": {
139
+ "type": "string"
140
+ }
141
+ },
132
142
  {
133
143
  "name": "sido",
134
144
  "in": "query",
package/openapi.yaml CHANGED
@@ -54,6 +54,7 @@ paths:
54
54
  - sevenelevenCheckInventory
55
55
  - sevenelevenGetPopwords
56
56
  - sevenelevenGetCatalog
57
+ - compareProducts
57
58
  - placesSearchNearby
58
59
  - megaboxFindTheaters
59
60
  - megaboxListMovies
@@ -106,6 +107,12 @@ paths:
106
107
  description: 주변 장소 카테고리(restaurant, cafe, food, dessert, all)
107
108
  schema:
108
109
  type: string
110
+ - name: services
111
+ in: query
112
+ required: false
113
+ description: 통합 비교 서비스 목록(daiso,gs25,seveneleven,emart24)
114
+ schema:
115
+ type: string
109
116
  - name: sido
110
117
  in: query
111
118
  required: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "daiso",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Korean retail and cinema MCP server, CLI, and Codex Skill for Daiso, convenience stores, marts, Olive Young, and theaters",
5
5
  "main": "dist/index.js",
6
6
  "bin": {