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
|
@@ -44,6 +44,13 @@ export const OPENAPI_PATHS_OLIVEYOUNG = {
|
|
|
44
44
|
description: '품절 포함 여부 (true/false)',
|
|
45
45
|
schema: { type: 'boolean', default: false },
|
|
46
46
|
},
|
|
47
|
+
{
|
|
48
|
+
name: 'timeoutMs',
|
|
49
|
+
in: 'query',
|
|
50
|
+
required: false,
|
|
51
|
+
description: '요청 제한 시간(ms)',
|
|
52
|
+
schema: { type: 'integer', default: 15000, minimum: 1 },
|
|
53
|
+
},
|
|
47
54
|
],
|
|
48
55
|
responses: {
|
|
49
56
|
'200': {
|
|
@@ -115,6 +122,13 @@ export const OPENAPI_PATHS_OLIVEYOUNG = {
|
|
|
115
122
|
description: '최대 결과 수',
|
|
116
123
|
schema: { type: 'integer', default: 20, minimum: 1, maximum: 100 },
|
|
117
124
|
},
|
|
125
|
+
{
|
|
126
|
+
name: 'timeoutMs',
|
|
127
|
+
in: 'query',
|
|
128
|
+
required: false,
|
|
129
|
+
description: '요청 제한 시간(ms)',
|
|
130
|
+
schema: { type: 'integer', default: 15000, minimum: 1 },
|
|
131
|
+
},
|
|
118
132
|
],
|
|
119
133
|
responses: {
|
|
120
134
|
'200': {
|
|
@@ -199,6 +213,27 @@ export const OPENAPI_PATHS_OLIVEYOUNG = {
|
|
|
199
213
|
description: '품절 포함 여부 (true/false)',
|
|
200
214
|
schema: { type: 'boolean', default: false },
|
|
201
215
|
},
|
|
216
|
+
{
|
|
217
|
+
name: 'storeLimit',
|
|
218
|
+
in: 'query',
|
|
219
|
+
required: false,
|
|
220
|
+
description: '반환할 주변 매장 최대 수',
|
|
221
|
+
schema: { type: 'integer', default: 10, minimum: 1, maximum: 100 },
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: 'stockCheckLimit',
|
|
225
|
+
in: 'query',
|
|
226
|
+
required: false,
|
|
227
|
+
description: '주변 매장별 재고를 보강할 상품 수. 낮출수록 빠르게 응답합니다.',
|
|
228
|
+
schema: { type: 'integer', default: 5, minimum: 0, maximum: 20 },
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: 'timeoutMs',
|
|
232
|
+
in: 'query',
|
|
233
|
+
required: false,
|
|
234
|
+
description: '요청 제한 시간(ms)',
|
|
235
|
+
schema: { type: 'integer', default: 15000, minimum: 1 },
|
|
236
|
+
},
|
|
202
237
|
],
|
|
203
238
|
responses: {
|
|
204
239
|
'200': {
|
|
@@ -81,16 +81,22 @@ export function buildPromptReferenceText(baseUrl) {
|
|
|
81
81
|
- 결과에서 원하는 제품의 id 확인
|
|
82
82
|
- /api/daiso/inventory에 해당 id로 재고 조회
|
|
83
83
|
- 진열 위치가 필요하면 재고 응답의 storeCode를 확인한 뒤 /api/daiso/display-location에 productId + storeCode로 조회
|
|
84
|
-
|
|
84
|
+
- 다이소 재고 조회는 storeCode가 필요하지 않습니다.
|
|
85
|
+
6. **최소 정보 요청 처리**:
|
|
86
|
+
- 사용자가 상품명만 주면 먼저 상품 검색 도구로 후보를 찾고, 응답의 productId/itemCode/pluCd를 다음 단계에 전달합니다.
|
|
87
|
+
- 사용자가 위치를 대강 말하면 keyword/storeKeyword에 그대로 넣어 먼저 조회하고, 결과가 없을 때만 공백 제거, 역/점 접미어 제거 같은 변형을 시도합니다.
|
|
88
|
+
- 도구가 productId, itemCode, storeCode 같은 내부 ID를 요구하면 사용자에게 바로 ID를 요구하지 말고, 검색/재고 응답에서 먼저 확인합니다.
|
|
89
|
+
- "근처", "주변"처럼 위치가 없는 표현만 있으면 기본 좌표 결과를 단정하지 말고 사용자의 지역/역명/좌표를 물어봅니다.
|
|
90
|
+
7. **상품명 단계 검색**:
|
|
85
91
|
- 재고 검색은 어떤 서비스이든 검색어 전체를 먼저 그대로 조회하고, 실패할 때만 공백/브랜드어 제거, 표기 변형, 더 짧은 핵심어 순으로 단계적으로 넓혀서 다시 시도합니다.
|
|
86
92
|
- 너무 넓은 축약어로만 매칭된 경우에는 확정 상품처럼 단정하지 말고, 추정 매칭임을 짧게 밝힌 뒤 재고를 안내합니다.
|
|
87
93
|
- 위치/브랜드/상품명은 한 인자에 섞지 말고 가능한 한 분리해서 도구에 전달합니다.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
8. **위치 기반 재고**: lat, lng 파라미터로 가까운 매장 우선 조회
|
|
95
|
+
9. **롯데마트 상품 조회**: /api/lottemart/products는 keyword와 함께 storeCode 또는 storeName이 필요합니다.
|
|
96
|
+
10. **세븐일레븐 재고 조회**: /api/seveneleven/inventory에 keyword + storeKeyword를 함께 주면 매장별 수량을 바로 확인할 수 있습니다.
|
|
97
|
+
11. **이마트24 재고 조회**: /api/emart24/inventory는 pluCd + storeKeyword 조합도 지원하므로, 상품 선택 뒤 매장 코드를 다시 모으지 않아도 됩니다.
|
|
98
|
+
12. **올리브영 재고 해석**: inventory.products[].storeInventory.stores[]가 있으면 그 매장별 stockLabel과 remainQuantity를 우선 사용하고, inStock는 그 주변 매장 기준 결과로 해석합니다.
|
|
99
|
+
13. **올리브영 상품 이미지 표시**: /api/oliveyoung/products 또는 oliveyoung_search_products 결과에 imageUrl이 있으면 목록형 답변에서도 각 상품 이미지를 생략하지 말고 모두 렌더링합니다. 특히 \`?l=ko\` 같은 query string을 삭제하지 말고 전체 URL 그대로 마크다운 이미지 src에 넣습니다.
|
|
94
100
|
|
|
95
101
|
---
|
|
96
102
|
|
|
@@ -18,12 +18,13 @@ export function buildPromptRetailCinemaText(baseUrl) {
|
|
|
18
18
|
- limit: 최대 결과 수 (기본값: 20)
|
|
19
19
|
- storeCode 또는 storeName: 상품 검색 대상 매장
|
|
20
20
|
- pageLimit: 추가 조회할 최대 페이지 수 (기본값: 3)
|
|
21
|
+
- source: 상품 검색 경로 (auto, legacy, zetta). 빠른 검색이나 헬스체크에는 zetta 권장
|
|
21
22
|
|
|
22
23
|
**예시**:
|
|
23
24
|
- ${baseUrl}/api/lottemart/stores?keyword=잠실&area=서울&limit=10
|
|
24
25
|
- ${baseUrl}/api/lottemart/stores?area=경기&brandVariant=lottemart&limit=10
|
|
25
26
|
- ${baseUrl}/api/lottemart/products?keyword=콜라&storeName=강변점&area=서울
|
|
26
|
-
- ${baseUrl}/api/lottemart/products?keyword=우유&storeCode=2301&pageLimit=2
|
|
27
|
+
- ${baseUrl}/api/lottemart/products?keyword=우유&storeCode=2301&pageLimit=2&source=zetta
|
|
27
28
|
|
|
28
29
|
---
|
|
29
30
|
|
|
@@ -11,6 +11,10 @@ const DAISO_DEFAULT_HEADERS = {
|
|
|
11
11
|
Accept: 'application/json, text/html, */*',
|
|
12
12
|
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
13
13
|
};
|
|
14
|
+
const DAISO_DEFAULT_FETCH_OPTIONS = {
|
|
15
|
+
retries: 1,
|
|
16
|
+
retryDelayMs: 250,
|
|
17
|
+
};
|
|
14
18
|
function withDaisoHeaders(headers = {}) {
|
|
15
19
|
return {
|
|
16
20
|
...DAISO_DEFAULT_HEADERS,
|
|
@@ -19,18 +23,21 @@ function withDaisoHeaders(headers = {}) {
|
|
|
19
23
|
}
|
|
20
24
|
export async function daisoFetch(url, options = {}) {
|
|
21
25
|
return fetchWithTimeout(url, {
|
|
26
|
+
...DAISO_DEFAULT_FETCH_OPTIONS,
|
|
22
27
|
...options,
|
|
23
28
|
headers: withDaisoHeaders(options.headers),
|
|
24
29
|
});
|
|
25
30
|
}
|
|
26
31
|
export async function fetchDaisoJson(url, options = {}) {
|
|
27
32
|
return fetchJson(url, {
|
|
33
|
+
...DAISO_DEFAULT_FETCH_OPTIONS,
|
|
28
34
|
...options,
|
|
29
35
|
headers: withDaisoHeaders(options.headers),
|
|
30
36
|
});
|
|
31
37
|
}
|
|
32
38
|
export async function fetchDaisoHtml(url, options = {}) {
|
|
33
39
|
return fetchText(url, {
|
|
40
|
+
...DAISO_DEFAULT_FETCH_OPTIONS,
|
|
34
41
|
...options,
|
|
35
42
|
headers: withDaisoHeaders(options.headers),
|
|
36
43
|
});
|
|
@@ -83,6 +90,7 @@ export async function createDaisoAuthContext() {
|
|
|
83
90
|
export async function fetchDaisoJsonWithAuth(url, options = {}) {
|
|
84
91
|
const auth = await createDaisoAuthContext();
|
|
85
92
|
return fetchJson(url, {
|
|
93
|
+
...DAISO_DEFAULT_FETCH_OPTIONS,
|
|
86
94
|
...options,
|
|
87
95
|
headers: withDaisoHeaders({
|
|
88
96
|
...options.headers,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { createSearchProductsTool } from './tools/searchProducts.js';
|
|
7
7
|
import { createFindStoresTool } from './tools/findStores.js';
|
|
8
8
|
import { createCheckInventoryTool } from './tools/checkInventory.js';
|
|
9
|
+
import { createFindInventoryByNameTool } from './tools/findInventoryByName.js';
|
|
9
10
|
import { createGetPriceInfoTool } from './tools/getPriceInfo.js';
|
|
10
11
|
import { createGetDisplayLocationTool } from './tools/getDisplayLocation.js';
|
|
11
12
|
/**
|
|
@@ -27,6 +28,7 @@ class DaisoService {
|
|
|
27
28
|
createSearchProductsTool(),
|
|
28
29
|
createFindStoresTool(),
|
|
29
30
|
createCheckInventoryTool(),
|
|
31
|
+
createFindInventoryByNameTool(),
|
|
30
32
|
createGetPriceInfoTool(),
|
|
31
33
|
createGetDisplayLocationTool(),
|
|
32
34
|
];
|
|
@@ -142,9 +142,11 @@ export function createCheckInventoryTool() {
|
|
|
142
142
|
name: 'daiso_check_inventory',
|
|
143
143
|
metadata: {
|
|
144
144
|
title: '재고 확인',
|
|
145
|
-
description: '특정 제품의 매장별 재고를 확인합니다. 매장명/주소 검색 또는 위치 기반으로 조회합니다.',
|
|
145
|
+
description: '특정 제품의 매장별 재고를 확인합니다. 상품명만 아는 경우 daiso_search_products로 후보를 먼저 찾은 뒤 productId를 전달하세요. 매장명/주소 검색 또는 위치 기반으로 조회합니다. "근처/주변"처럼 위치가 모호하면 storeQuery, latitude/longitude 또는 사용자 위치를 먼저 확인하세요.',
|
|
146
146
|
inputSchema: {
|
|
147
|
-
productId: z
|
|
147
|
+
productId: z
|
|
148
|
+
.string()
|
|
149
|
+
.describe('제품 ID. 상품명만 알면 먼저 daiso_search_products로 상품의 id를 확인하세요.'),
|
|
148
150
|
storeQuery: z.string().optional().describe('매장 검색어 (매장명 또는 주소, 예: 안산 중앙역)'),
|
|
149
151
|
latitude: z.number().optional().default(37.5665).describe('위도 (기본값: 서울 시청 37.5665)'),
|
|
150
152
|
longitude: z.number().optional().default(126.978).describe('경도 (기본값: 서울 시청 126.978)'),
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 상품명 기반 재고 통합 조회 도구
|
|
3
|
+
*
|
|
4
|
+
* 상품명과 위치만 아는 사용자를 위해 상품 검색과 재고 조회를 한 번에 수행합니다.
|
|
5
|
+
*/
|
|
6
|
+
import * as z from 'zod';
|
|
7
|
+
import { fetchProducts } from './searchProducts.js';
|
|
8
|
+
import { fetchOnlineStock, fetchStoreInventory } from './checkInventory.js';
|
|
9
|
+
import { fetchStores } from './findStores.js';
|
|
10
|
+
function buildTextResponse(payload) {
|
|
11
|
+
return {
|
|
12
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
13
|
+
structuredContent: payload,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function buildEmptySummary(query, storeQuery) {
|
|
17
|
+
return {
|
|
18
|
+
headline: `"${query}" 상품 후보를 찾지 못했습니다.`,
|
|
19
|
+
selectedProduct: '',
|
|
20
|
+
storeQuery: storeQuery || '미지정',
|
|
21
|
+
inventorySummary: '상품 후보가 없어 온라인/매장 재고 조회를 건너뛰었습니다.',
|
|
22
|
+
displayLocationHint: '상품 후보를 먼저 확인한 뒤 daiso_get_display_location을 사용할 수 있습니다.',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async function resolveInventoryLocation(args) {
|
|
26
|
+
if (typeof args.latitude === 'number' && typeof args.longitude === 'number') {
|
|
27
|
+
return {
|
|
28
|
+
latitude: args.latitude,
|
|
29
|
+
longitude: args.longitude,
|
|
30
|
+
source: 'input',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (args.storeQuery.trim().length > 0) {
|
|
34
|
+
const stores = await fetchStores(args.storeQuery);
|
|
35
|
+
const firstStore = stores[0];
|
|
36
|
+
if (firstStore) {
|
|
37
|
+
return {
|
|
38
|
+
latitude: firstStore.lat,
|
|
39
|
+
longitude: firstStore.lng,
|
|
40
|
+
source: 'storeQuery',
|
|
41
|
+
storeName: firstStore.name,
|
|
42
|
+
storeAddress: firstStore.address,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
latitude: args.latitude ?? 37.5665,
|
|
48
|
+
longitude: args.longitude ?? 126.978,
|
|
49
|
+
source: 'default',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function buildInventorySummary(args) {
|
|
53
|
+
const storeScope = args.storeQuery || '기본 위치 주변';
|
|
54
|
+
return {
|
|
55
|
+
headline: `"${args.query}" 조회 결과, "${args.selectedProductName}" 상품을 기준으로 재고를 확인했습니다.`,
|
|
56
|
+
selectedProduct: args.selectedProductName,
|
|
57
|
+
storeQuery: args.storeQuery || '미지정',
|
|
58
|
+
inventorySummary: `${storeScope} 매장 ${args.totalStores}곳 중 ${args.inStockCount}곳에서 재고가 확인되었습니다. 온라인 재고는 ${args.onlineStock}개입니다.`,
|
|
59
|
+
displayLocationHint: '진열 위치가 필요하면 storeInventory.stores[].storeCode로 daiso_get_display_location을 호출하세요.',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function toSummary(product) {
|
|
63
|
+
return {
|
|
64
|
+
id: product.id,
|
|
65
|
+
name: product.name,
|
|
66
|
+
imageUrl: product.imageUrl,
|
|
67
|
+
brand: product.brand,
|
|
68
|
+
soldOut: product.soldOut,
|
|
69
|
+
isNew: product.isNew,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function normalizeProductName(value) {
|
|
73
|
+
return value.toLowerCase().replace(/\s+/g, '');
|
|
74
|
+
}
|
|
75
|
+
function scoreProductCandidate(query, candidate, index) {
|
|
76
|
+
const normalizedQuery = normalizeProductName(query);
|
|
77
|
+
const normalizedName = normalizeProductName(candidate.name);
|
|
78
|
+
const exactMatchScore = normalizedName === normalizedQuery ? 100 : 0;
|
|
79
|
+
const availabilityScore = candidate.soldOut ? 0 : 20;
|
|
80
|
+
const startsWithScore = normalizedName.startsWith(normalizedQuery) ? 10 : 0;
|
|
81
|
+
const containsScore = normalizedName.includes(normalizedQuery) ? 5 : 0;
|
|
82
|
+
return exactMatchScore + availabilityScore + startsWithScore + containsScore - index / 1000;
|
|
83
|
+
}
|
|
84
|
+
function selectProductCandidate(query, candidates) {
|
|
85
|
+
return (candidates
|
|
86
|
+
.map((candidate, index) => ({
|
|
87
|
+
candidate,
|
|
88
|
+
score: scoreProductCandidate(query, candidate, index),
|
|
89
|
+
}))
|
|
90
|
+
.sort((a, b) => b.score - a.score)[0]?.candidate ?? null);
|
|
91
|
+
}
|
|
92
|
+
async function findInventoryByName(args) {
|
|
93
|
+
const { query, storeQuery = '', latitude, longitude, page = 1, pageSize = 30, productLimit = 5, } = args;
|
|
94
|
+
if (!query || query.trim().length === 0) {
|
|
95
|
+
throw new Error('상품명(query)을 입력해주세요.');
|
|
96
|
+
}
|
|
97
|
+
const productResult = await fetchProducts(query, 1, productLimit);
|
|
98
|
+
const productCandidates = productResult.products.map(toSummary);
|
|
99
|
+
const selectedProduct = selectProductCandidate(query, productCandidates);
|
|
100
|
+
if (!selectedProduct) {
|
|
101
|
+
return buildTextResponse({
|
|
102
|
+
query,
|
|
103
|
+
storeQuery,
|
|
104
|
+
summary: buildEmptySummary(query, storeQuery),
|
|
105
|
+
productCandidates: [],
|
|
106
|
+
selectedProduct: null,
|
|
107
|
+
onlineStock: 0,
|
|
108
|
+
storeInventory: {
|
|
109
|
+
totalStores: 0,
|
|
110
|
+
inStockCount: 0,
|
|
111
|
+
outOfStockCount: 0,
|
|
112
|
+
page,
|
|
113
|
+
pageSize,
|
|
114
|
+
stores: [],
|
|
115
|
+
},
|
|
116
|
+
nextSteps: {
|
|
117
|
+
productSearchTool: 'daiso_search_products',
|
|
118
|
+
inventoryTool: 'daiso_check_inventory',
|
|
119
|
+
note: '상품 후보가 없어서 재고 조회를 진행하지 않았습니다.',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const resolvedLocation = await resolveInventoryLocation({
|
|
124
|
+
storeQuery,
|
|
125
|
+
latitude,
|
|
126
|
+
longitude,
|
|
127
|
+
});
|
|
128
|
+
const [onlineStock, storeResult] = await Promise.all([
|
|
129
|
+
fetchOnlineStock(selectedProduct.id),
|
|
130
|
+
fetchStoreInventory(selectedProduct.id, resolvedLocation.latitude, resolvedLocation.longitude, page, pageSize, storeQuery),
|
|
131
|
+
]);
|
|
132
|
+
const stores = storeResult.stores;
|
|
133
|
+
const inStockCount = stores.filter((store) => store.quantity > 0).length;
|
|
134
|
+
const result = {
|
|
135
|
+
query,
|
|
136
|
+
storeQuery,
|
|
137
|
+
location: resolvedLocation,
|
|
138
|
+
summary: buildInventorySummary({
|
|
139
|
+
query,
|
|
140
|
+
storeQuery,
|
|
141
|
+
selectedProductName: selectedProduct.name,
|
|
142
|
+
onlineStock,
|
|
143
|
+
inStockCount,
|
|
144
|
+
totalStores: storeResult.totalCount,
|
|
145
|
+
}),
|
|
146
|
+
productCandidates,
|
|
147
|
+
selectedProduct,
|
|
148
|
+
onlineStock,
|
|
149
|
+
storeInventory: {
|
|
150
|
+
totalStores: storeResult.totalCount,
|
|
151
|
+
inStockCount,
|
|
152
|
+
outOfStockCount: stores.filter((store) => store.quantity === 0).length,
|
|
153
|
+
page,
|
|
154
|
+
pageSize,
|
|
155
|
+
stores,
|
|
156
|
+
},
|
|
157
|
+
nextSteps: {
|
|
158
|
+
productSearchTool: 'daiso_search_products',
|
|
159
|
+
inventoryTool: 'daiso_check_inventory',
|
|
160
|
+
displayLocationTool: 'daiso_get_display_location',
|
|
161
|
+
storeCodeSource: '진열 위치가 필요하면 storeInventory.stores[].storeCode를 사용하세요.',
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
return buildTextResponse(result);
|
|
165
|
+
}
|
|
166
|
+
const inventoryByNameOutputSchema = {
|
|
167
|
+
query: z.string().describe('사용자가 입력한 상품 검색어'),
|
|
168
|
+
storeQuery: z.string().describe('사용자가 입력한 위치 키워드'),
|
|
169
|
+
location: z
|
|
170
|
+
.object({
|
|
171
|
+
latitude: z.number(),
|
|
172
|
+
longitude: z.number(),
|
|
173
|
+
source: z.enum(['input', 'storeQuery', 'default']),
|
|
174
|
+
storeName: z.string().optional(),
|
|
175
|
+
storeAddress: z.string().optional(),
|
|
176
|
+
})
|
|
177
|
+
.optional()
|
|
178
|
+
.describe('재고 조회에 사용한 위치와 위치 해석 출처'),
|
|
179
|
+
summary: z
|
|
180
|
+
.object({
|
|
181
|
+
headline: z.string(),
|
|
182
|
+
selectedProduct: z.string(),
|
|
183
|
+
storeQuery: z.string(),
|
|
184
|
+
inventorySummary: z.string(),
|
|
185
|
+
displayLocationHint: z.string(),
|
|
186
|
+
})
|
|
187
|
+
.describe('사용자에게 바로 보여줄 요약'),
|
|
188
|
+
productCandidates: z.array(z.unknown()).describe('검색된 상품 후보 목록'),
|
|
189
|
+
selectedProduct: z.unknown().nullable().describe('재고 조회 기준으로 선택한 상품'),
|
|
190
|
+
onlineStock: z.number().describe('온라인 재고 수량'),
|
|
191
|
+
storeInventory: z
|
|
192
|
+
.object({
|
|
193
|
+
totalStores: z.number(),
|
|
194
|
+
inStockCount: z.number(),
|
|
195
|
+
outOfStockCount: z.number(),
|
|
196
|
+
page: z.number(),
|
|
197
|
+
pageSize: z.number(),
|
|
198
|
+
stores: z.array(z.unknown()),
|
|
199
|
+
})
|
|
200
|
+
.describe('매장별 재고 조회 결과'),
|
|
201
|
+
nextSteps: z.record(z.string(), z.unknown()).describe('후속 도구 호출 안내'),
|
|
202
|
+
};
|
|
203
|
+
export function createFindInventoryByNameTool() {
|
|
204
|
+
return {
|
|
205
|
+
name: 'daiso_find_inventory_by_name',
|
|
206
|
+
metadata: {
|
|
207
|
+
title: '상품명 기반 재고 통합 조회',
|
|
208
|
+
description: '상품명과 대강의 위치만 아는 사용자를 위해 제품 검색부터 재고 조회까지 한 번에 수행합니다. 결과의 storeInventory.stores[].storeCode는 진열 위치 조회에 사용할 수 있습니다.',
|
|
209
|
+
inputSchema: {
|
|
210
|
+
query: z.string().describe('검색할 상품명 또는 키워드'),
|
|
211
|
+
storeQuery: z.string().optional().describe('역명, 동네, 매장명 같은 대강의 위치'),
|
|
212
|
+
latitude: z.number().optional().describe('위도 (생략 시 서울 시청 37.5665)'),
|
|
213
|
+
longitude: z.number().optional().describe('경도 (생략 시 서울 시청 126.978)'),
|
|
214
|
+
page: z.number().optional().default(1).describe('재고 매장 페이지 번호 (기본값: 1)'),
|
|
215
|
+
pageSize: z.number().optional().default(30).describe('재고 매장 페이지 크기 (기본값: 30)'),
|
|
216
|
+
productLimit: z.number().optional().default(5).describe('상품 후보 수 (기본값: 5)'),
|
|
217
|
+
},
|
|
218
|
+
outputSchema: inventoryByNameOutputSchema,
|
|
219
|
+
},
|
|
220
|
+
handler: findInventoryByName,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -62,12 +62,12 @@ export function createGetDisplayLocationTool() {
|
|
|
62
62
|
name: 'daiso_get_display_location',
|
|
63
63
|
metadata: {
|
|
64
64
|
title: '진열 위치 조회',
|
|
65
|
-
description: '다이소 매장 내 상품의 진열 위치(구역/층)를 조회합니다. 상품 ID와 매장 코드가 필요합니다.',
|
|
65
|
+
description: '다이소 매장 내 상품의 진열 위치(구역/층)를 조회합니다. 상품 ID와 매장 코드가 필요합니다. storeCode를 모르면 먼저 daiso_check_inventory로 재고를 조회하고 storeInventory.stores[].storeCode를 사용하세요.',
|
|
66
66
|
inputSchema: {
|
|
67
67
|
productId: z.string().describe('상품 ID (daiso_search_products로 조회한 상품의 id)'),
|
|
68
68
|
storeCode: z
|
|
69
69
|
.string()
|
|
70
|
-
.describe('매장
|
|
70
|
+
.describe('매장 코드. storeCode를 모르면 먼저 daiso_check_inventory 결과의 storeInventory.stores[].storeCode를 확인하세요.'),
|
|
71
71
|
},
|
|
72
72
|
},
|
|
73
73
|
handler: getDisplayLocation,
|
|
@@ -12,6 +12,10 @@ const EMART24_FORM_HEADERS = {
|
|
|
12
12
|
Accept: 'application/json, text/javascript, */*; q=0.01',
|
|
13
13
|
'X-Requested-With': 'XMLHttpRequest',
|
|
14
14
|
};
|
|
15
|
+
const EMART24_DEFAULT_FETCH_OPTIONS = {
|
|
16
|
+
retries: 1,
|
|
17
|
+
retryDelayMs: 250,
|
|
18
|
+
};
|
|
15
19
|
function buildKeywordVariants(keyword) {
|
|
16
20
|
const trimmed = keyword.trim();
|
|
17
21
|
if (trimmed.length === 0) {
|
|
@@ -101,6 +105,7 @@ export async function fetchEmart24Stores(params = {}, options = {}) {
|
|
|
101
105
|
endpoint.searchParams.set('SVR_24', '1');
|
|
102
106
|
}
|
|
103
107
|
const body = await fetchJson(endpoint.toString(), {
|
|
108
|
+
...EMART24_DEFAULT_FETCH_OPTIONS,
|
|
104
109
|
method: 'GET',
|
|
105
110
|
timeout,
|
|
106
111
|
headers: EMART24_JSON_HEADERS,
|
|
@@ -131,7 +136,9 @@ export async function searchEmart24Products(params, options = {}) {
|
|
|
131
136
|
form.set('saleProductYn', saleProductYn);
|
|
132
137
|
form.set('searchWord', keyword.trim());
|
|
133
138
|
const body = await fetchJson(`${EMART24_API.EVERSE_BASE_URL}${EMART24_API.PRODUCT_SEARCH_PATH}`, {
|
|
139
|
+
...EMART24_DEFAULT_FETCH_OPTIONS,
|
|
134
140
|
method: 'POST',
|
|
141
|
+
retryUnsafeMethods: true,
|
|
135
142
|
timeout,
|
|
136
143
|
headers: EMART24_FORM_HEADERS,
|
|
137
144
|
body: form.toString(),
|
|
@@ -158,6 +165,7 @@ export async function searchEmart24StockByStores(params, options = {}) {
|
|
|
158
165
|
endpoint.searchParams.set('searchPluCode', pluCd);
|
|
159
166
|
endpoint.searchParams.set('bizNoArr', filteredBizNos.join(','));
|
|
160
167
|
return fetchJson(endpoint.toString(), {
|
|
168
|
+
...EMART24_DEFAULT_FETCH_OPTIONS,
|
|
161
169
|
method: 'GET',
|
|
162
170
|
timeout,
|
|
163
171
|
headers: EMART24_JSON_HEADERS,
|
|
@@ -166,6 +174,7 @@ export async function searchEmart24StockByStores(params, options = {}) {
|
|
|
166
174
|
export async function fetchEmart24StoreDetail(bizNo, options = {}) {
|
|
167
175
|
const { timeout = 15000 } = options;
|
|
168
176
|
return fetchJson(`${EMART24_API.EVERSE_BASE_URL}${EMART24_API.STORE_DETAIL_PATH_PREFIX}${encodeURIComponent(bizNo)}`, {
|
|
177
|
+
...EMART24_DEFAULT_FETCH_OPTIONS,
|
|
169
178
|
method: 'GET',
|
|
170
179
|
timeout,
|
|
171
180
|
headers: EMART24_JSON_HEADERS,
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { searchEmart24Products } from '../client.js';
|
|
6
|
+
function buildTextResponse(payload) {
|
|
7
|
+
return {
|
|
8
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
9
|
+
structuredContent: payload,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
6
12
|
async function searchProducts(args) {
|
|
7
13
|
const { keyword, page = 1, pageSize = 10, sortType = 'SALE', saleProductYn = 'N', timeoutMs = 15000, } = args;
|
|
8
14
|
if (!keyword || keyword.trim().length === 0) {
|
|
@@ -17,24 +23,27 @@ async function searchProducts(args) {
|
|
|
17
23
|
}, {
|
|
18
24
|
timeout: timeoutMs,
|
|
19
25
|
});
|
|
20
|
-
return {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
totalCount: result.totalCount,
|
|
31
|
-
count: result.products.length,
|
|
32
|
-
products: result.products,
|
|
33
|
-
}, null, 2),
|
|
34
|
-
},
|
|
35
|
-
],
|
|
36
|
-
};
|
|
26
|
+
return buildTextResponse({
|
|
27
|
+
keyword,
|
|
28
|
+
page,
|
|
29
|
+
pageSize,
|
|
30
|
+
sortType,
|
|
31
|
+
saleProductYn,
|
|
32
|
+
totalCount: result.totalCount,
|
|
33
|
+
count: result.products.length,
|
|
34
|
+
products: result.products,
|
|
35
|
+
});
|
|
37
36
|
}
|
|
37
|
+
const searchProductsOutputSchema = {
|
|
38
|
+
keyword: z.string().describe('검색어'),
|
|
39
|
+
page: z.number().describe('조회 페이지'),
|
|
40
|
+
pageSize: z.number().describe('페이지당 결과 수'),
|
|
41
|
+
sortType: z.string().describe('정렬 기준'),
|
|
42
|
+
saleProductYn: z.string().describe('행사 상품 여부 필터'),
|
|
43
|
+
totalCount: z.number().describe('검색된 전체 상품 수'),
|
|
44
|
+
count: z.number().describe('반환된 상품 수'),
|
|
45
|
+
products: z.array(z.unknown()).describe('이마트24 상품 검색 결과'),
|
|
46
|
+
};
|
|
38
47
|
export function createSearchProductsTool() {
|
|
39
48
|
return {
|
|
40
49
|
name: 'emart24_search_products',
|
|
@@ -57,6 +66,7 @@ export function createSearchProductsTool() {
|
|
|
57
66
|
.describe('행사 상품 여부 필터 (기본값: N)'),
|
|
58
67
|
timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
|
|
59
68
|
},
|
|
69
|
+
outputSchema: searchProductsOutputSchema,
|
|
60
70
|
},
|
|
61
71
|
handler: searchProducts,
|
|
62
72
|
};
|
|
@@ -9,6 +9,10 @@ export { attachDistanceToGs25Stores, calculateDistanceM, extractGs25ProductCandi
|
|
|
9
9
|
const GS25_DEFAULT_HEADERS = {
|
|
10
10
|
Accept: 'application/json, text/plain, */*',
|
|
11
11
|
};
|
|
12
|
+
const GS25_DEFAULT_FETCH_OPTIONS = {
|
|
13
|
+
retries: 1,
|
|
14
|
+
retryDelayMs: 250,
|
|
15
|
+
};
|
|
12
16
|
const GS25_STORES_CACHE_TTL_MS = 60 * 5 * 1000;
|
|
13
17
|
const gs25StoresCache = new Map();
|
|
14
18
|
function buildCacheKey(params) {
|
|
@@ -34,6 +38,7 @@ export async function geocodeGs25Address(address, options = {}) {
|
|
|
34
38
|
endpoint.searchParams.set('address', keyword);
|
|
35
39
|
endpoint.searchParams.set('key', apiKey);
|
|
36
40
|
const body = await fetchJson(endpoint.toString(), {
|
|
41
|
+
...GS25_DEFAULT_FETCH_OPTIONS,
|
|
37
42
|
method: 'GET',
|
|
38
43
|
timeout,
|
|
39
44
|
headers: {
|
|
@@ -109,6 +114,7 @@ export async function fetchGs25Stores(params = {}, options = {}) {
|
|
|
109
114
|
endpoint.searchParams.set('isGs25DlvyStoreSelected', 'N');
|
|
110
115
|
}
|
|
111
116
|
const body = await fetchJson(endpoint.toString(), {
|
|
117
|
+
...GS25_DEFAULT_FETCH_OPTIONS,
|
|
112
118
|
method: 'GET',
|
|
113
119
|
timeout,
|
|
114
120
|
headers: GS25_DEFAULT_HEADERS,
|
|
@@ -134,7 +140,9 @@ export async function fetchGs25NormalizedKeyword(keyword, options = {}) {
|
|
|
134
140
|
const { timeout = 20000 } = options;
|
|
135
141
|
const endpoint = new URL(GS25_API.TOTAL_SEARCH_PATH, GS25_API.APIGW_BASE_URL);
|
|
136
142
|
const body = await fetchJson(endpoint.toString(), {
|
|
143
|
+
...GS25_DEFAULT_FETCH_OPTIONS,
|
|
137
144
|
method: 'POST',
|
|
145
|
+
retryUnsafeMethods: true,
|
|
138
146
|
timeout,
|
|
139
147
|
headers: {
|
|
140
148
|
Accept: 'application/json, text/plain, */*',
|
|
@@ -164,7 +172,9 @@ export async function fetchGs25SearchProducts(keyword, options = {}) {
|
|
|
164
172
|
const { timeout = 20000 } = options;
|
|
165
173
|
const endpoint = new URL(GS25_API.TOTAL_SEARCH_PATH, GS25_API.APIGW_BASE_URL);
|
|
166
174
|
const body = await fetchJson(endpoint.toString(), {
|
|
175
|
+
...GS25_DEFAULT_FETCH_OPTIONS,
|
|
167
176
|
method: 'POST',
|
|
177
|
+
retryUnsafeMethods: true,
|
|
168
178
|
timeout,
|
|
169
179
|
headers: {
|
|
170
180
|
Accept: 'application/json, text/plain, */*',
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { fetchGs25SearchProducts } from '../client.js';
|
|
6
|
+
function buildTextResponse(payload) {
|
|
7
|
+
return {
|
|
8
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
9
|
+
structuredContent: payload,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
6
12
|
async function searchProducts(args) {
|
|
7
13
|
const { keyword, limit = 20, timeoutMs = 20000 } = args;
|
|
8
14
|
if (!keyword || keyword.trim().length === 0) {
|
|
@@ -10,29 +16,29 @@ async function searchProducts(args) {
|
|
|
10
16
|
}
|
|
11
17
|
const products = await fetchGs25SearchProducts(keyword, { timeout: timeoutMs });
|
|
12
18
|
const limitedProducts = products.slice(0, limit);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
})),
|
|
28
|
-
note: limitedProducts.length === 0
|
|
29
|
-
? '검색 결과가 없습니다. 다른 키워드로 검색해보세요.'
|
|
30
|
-
: '재고 확인 시 itemCode를 gs25_check_inventory에 전달하면 정확한 상품 재고를 조회할 수 있습니다.',
|
|
31
|
-
}, null, 2),
|
|
32
|
-
},
|
|
33
|
-
],
|
|
19
|
+
const payload = {
|
|
20
|
+
keyword,
|
|
21
|
+
count: limitedProducts.length,
|
|
22
|
+
products: limitedProducts.map((p) => ({
|
|
23
|
+
itemCode: p.itemCode,
|
|
24
|
+
itemName: p.itemName,
|
|
25
|
+
shortItemName: p.shortItemName,
|
|
26
|
+
imageUrl: p.imageUrl,
|
|
27
|
+
rating: p.rating,
|
|
28
|
+
stockCheckEnabled: p.stockCheckEnabled,
|
|
29
|
+
})),
|
|
30
|
+
note: limitedProducts.length === 0
|
|
31
|
+
? '검색 결과가 없습니다. 다른 키워드로 검색해보세요.'
|
|
32
|
+
: '재고 확인 시 itemCode를 gs25_check_inventory에 전달하면 정확한 상품 재고를 조회할 수 있습니다.',
|
|
34
33
|
};
|
|
34
|
+
return buildTextResponse(payload);
|
|
35
35
|
}
|
|
36
|
+
const searchProductsOutputSchema = {
|
|
37
|
+
keyword: z.string().describe('검색어'),
|
|
38
|
+
count: z.number().describe('반환된 상품 수'),
|
|
39
|
+
products: z.array(z.unknown()).describe('GS25 상품 검색 결과'),
|
|
40
|
+
note: z.string().describe('후속 재고 조회 안내'),
|
|
41
|
+
};
|
|
36
42
|
export function createSearchProductsTool() {
|
|
37
43
|
return {
|
|
38
44
|
name: 'gs25_search_products',
|
|
@@ -44,6 +50,7 @@ export function createSearchProductsTool() {
|
|
|
44
50
|
limit: z.number().optional().default(20).describe('반환할 최대 상품 수 (기본값: 20)'),
|
|
45
51
|
timeoutMs: z.number().optional().default(20000).describe('요청 제한 시간(ms, 기본값: 20000)'),
|
|
46
52
|
},
|
|
53
|
+
outputSchema: searchProductsOutputSchema,
|
|
47
54
|
},
|
|
48
55
|
handler: searchProducts,
|
|
49
56
|
};
|