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
|
@@ -7,7 +7,7 @@ import { __testOnlyClearLotteMartGeocodeCache, geocodeLotteMartAddress } from '.
|
|
|
7
7
|
import { __testOnlyClearLotteMartSessionCache, fetchLotteMartHtml, fetchLotteMartPageWithSession, getCachedLotteMartSessionCookie, } from './session.js';
|
|
8
8
|
import { attachDistance, fetchAllStoresForAreaList, fetchKeywordMatchedStores, getTargetAreas, normalizeArea, toDisplayArea, } from './storeSearch.js';
|
|
9
9
|
import { calculateDistanceM, dedupeProducts, matchesBrandVariant, matchesKeyword, parseMarketOptions, parseProductSummary, parseProducts, parseStores, sortStores, } from './parser.js';
|
|
10
|
-
import { createZettaFallbackStore, fetchZettaLotteMartProductsWithPrimaryError } from './zetta.js';
|
|
10
|
+
import { createZettaFallbackStore, fetchZettaLotteMartProducts, fetchZettaLotteMartProductsWithPrimaryError } from './zetta.js';
|
|
11
11
|
const STORE_CACHE_TTL_MS = 30 * 60 * 1000;
|
|
12
12
|
const LOTTEMART_PRODUCT_LEGACY_TIMEOUT_MS = 7000;
|
|
13
13
|
const storeCache = new Map();
|
|
@@ -177,6 +177,9 @@ export async function searchLotteMartProducts(params, options = {}) {
|
|
|
177
177
|
throw new Error('상품 검색어(keyword)를 입력해주세요.');
|
|
178
178
|
}
|
|
179
179
|
const timeout = options.timeout || DEFAULT_LOTTEMART_TIMEOUT_MS;
|
|
180
|
+
if (params.source === 'zetta') {
|
|
181
|
+
return fetchZettaLotteMartProducts(createZettaFallbackStore(params), normalizedKeyword, params.pageLimit || 3, timeout);
|
|
182
|
+
}
|
|
180
183
|
const sessionCookie = await getLotteMartSessionCookie({ timeout: LOTTEMART_PRODUCT_LEGACY_TIMEOUT_MS });
|
|
181
184
|
let resolvedStore;
|
|
182
185
|
try {
|
|
@@ -187,6 +190,9 @@ export async function searchLotteMartProducts(params, options = {}) {
|
|
|
187
190
|
});
|
|
188
191
|
}
|
|
189
192
|
catch (error) {
|
|
193
|
+
if (params.source === 'legacy') {
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
190
196
|
const fallbackStore = createZettaFallbackStore(params);
|
|
191
197
|
return fetchZettaLotteMartProductsWithPrimaryError(fallbackStore, normalizedKeyword, params.pageLimit || 3, timeout, error);
|
|
192
198
|
}
|
|
@@ -208,6 +214,9 @@ export async function searchLotteMartProducts(params, options = {}) {
|
|
|
208
214
|
}, LOTTEMART_PRODUCT_LEGACY_TIMEOUT_MS, sessionCookie, options.zyteApiKey);
|
|
209
215
|
}
|
|
210
216
|
catch (error) {
|
|
217
|
+
if (params.source === 'legacy') {
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
211
220
|
return fetchZettaLotteMartProductsWithPrimaryError(resolvedStore, normalizedKeyword, params.pageLimit || 3, timeout, error);
|
|
212
221
|
}
|
|
213
222
|
const pagedSessionCookie = await getCachedLotteMartSessionCookie(LOTTEMART_PRODUCT_LEGACY_TIMEOUT_MS);
|
|
@@ -5,13 +5,14 @@ import * as z from 'zod';
|
|
|
5
5
|
import { searchLotteMartProducts } from '../client.js';
|
|
6
6
|
import { DEFAULT_LOTTEMART_TIMEOUT_MS } from '../config.js';
|
|
7
7
|
async function searchProducts(args) {
|
|
8
|
-
const { area, storeCode, storeName, keyword, pageLimit = 3, timeoutMs = DEFAULT_LOTTEMART_TIMEOUT_MS, zyteApiKey, } = args;
|
|
8
|
+
const { area, storeCode, storeName, keyword, pageLimit = 3, source = 'auto', timeoutMs = DEFAULT_LOTTEMART_TIMEOUT_MS, zyteApiKey, } = args;
|
|
9
9
|
const result = await searchLotteMartProducts({
|
|
10
10
|
area,
|
|
11
11
|
storeCode,
|
|
12
12
|
storeName,
|
|
13
13
|
keyword,
|
|
14
14
|
pageLimit,
|
|
15
|
+
source,
|
|
15
16
|
}, {
|
|
16
17
|
timeout: timeoutMs,
|
|
17
18
|
zyteApiKey,
|
|
@@ -26,6 +27,7 @@ async function searchProducts(args) {
|
|
|
26
27
|
storeName: result.storeName,
|
|
27
28
|
keyword,
|
|
28
29
|
pageLimit,
|
|
30
|
+
source,
|
|
29
31
|
totalCount: result.totalCount,
|
|
30
32
|
totalPages: result.totalPages,
|
|
31
33
|
count: result.products.length,
|
|
@@ -47,6 +49,11 @@ export function createSearchProductsTool(zyteApiKey) {
|
|
|
47
49
|
storeName: z.string().optional().describe('매장명 (예: 강변점)'),
|
|
48
50
|
keyword: z.string().describe('상품 검색어 (예: 콜라, 우유, 과자)'),
|
|
49
51
|
pageLimit: z.number().optional().default(3).describe('추가 조회할 최대 페이지 수 (기본값: 3)'),
|
|
52
|
+
source: z
|
|
53
|
+
.enum(['auto', 'legacy', 'zetta'])
|
|
54
|
+
.optional()
|
|
55
|
+
.default('auto')
|
|
56
|
+
.describe('상품 검색 경로 (auto: 구형 경로 후 제타 대체, zetta: 빠른 제타 API 우선, legacy: 구형 경로만 사용)'),
|
|
50
57
|
timeoutMs: z
|
|
51
58
|
.number()
|
|
52
59
|
.optional()
|
|
@@ -71,7 +71,7 @@ function clampZettaPageLimit(pageLimit) {
|
|
|
71
71
|
}
|
|
72
72
|
return pageLimit;
|
|
73
73
|
}
|
|
74
|
-
async function fetchZettaLotteMartProducts(resolvedStore, keyword, pageLimit, timeout) {
|
|
74
|
+
export async function fetchZettaLotteMartProducts(resolvedStore, keyword, pageLimit, timeout) {
|
|
75
75
|
const products = [];
|
|
76
76
|
let nextPageToken;
|
|
77
77
|
let fetchedPages = 0;
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
import { OLIVEYOUNG_API } from './api.js';
|
|
7
7
|
import { decodeBase64, requestByZyte } from '../../utils/zyte.js';
|
|
8
8
|
const OLIVEYOUNG_IMAGE_HOST = 'https://image.oliveyoung.co.kr';
|
|
9
|
+
const OLIVEYOUNG_PRODUCT_ID_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
10
|
+
const OLIVEYOUNG_PRODUCT_SEARCH_STALE_CACHE_TTL_MS = 30 * 60 * 1000;
|
|
11
|
+
const oliveyoungProductIdCache = new Map();
|
|
12
|
+
const oliveyoungProductSearchCache = new Map();
|
|
9
13
|
const OLIVEYOUNG_IMAGE_PATH_PREFIX = '/uploads/images/goods';
|
|
10
14
|
function resolveOliveyoungInStock(o2oStockFlag, o2oRemainQuantity) {
|
|
11
15
|
return o2oStockFlag || o2oRemainQuantity > 0;
|
|
@@ -60,6 +64,29 @@ function resolveOliveyoungStoreStock(rawStore) {
|
|
|
60
64
|
openYn: Boolean(rawStore?.openYn),
|
|
61
65
|
};
|
|
62
66
|
}
|
|
67
|
+
function createOliveyoungProductSearchCacheKey(params) {
|
|
68
|
+
return JSON.stringify([params.keyword, params.page, params.size, params.sort, params.includeSoldOut]);
|
|
69
|
+
}
|
|
70
|
+
function cloneOliveyoungProductSearchResult(result) {
|
|
71
|
+
return {
|
|
72
|
+
totalCount: result.totalCount,
|
|
73
|
+
nextPage: result.nextPage,
|
|
74
|
+
products: result.products.map((product) => ({ ...product })),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function readStaleOliveyoungProductSearchResult(cacheKey) {
|
|
78
|
+
const cached = oliveyoungProductSearchCache.get(cacheKey);
|
|
79
|
+
if (!cached || cached.expiresAt <= Date.now()) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return cloneOliveyoungProductSearchResult(cached.result);
|
|
83
|
+
}
|
|
84
|
+
function writeStaleOliveyoungProductSearchResult(cacheKey, result) {
|
|
85
|
+
oliveyoungProductSearchCache.set(cacheKey, {
|
|
86
|
+
expiresAt: Date.now() + OLIVEYOUNG_PRODUCT_SEARCH_STALE_CACHE_TTL_MS,
|
|
87
|
+
result: cloneOliveyoungProductSearchResult(result),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
63
90
|
async function zyteExtract(targetPath, requestBody, options = {}) {
|
|
64
91
|
const { timeout = 15000, apiKey } = options;
|
|
65
92
|
try {
|
|
@@ -126,37 +153,65 @@ export async function fetchOliveyoungProducts(params, options = {}) {
|
|
|
126
153
|
sort: params.sort,
|
|
127
154
|
size: params.size,
|
|
128
155
|
};
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
156
|
+
const cacheKey = createOliveyoungProductSearchCacheKey(params);
|
|
157
|
+
try {
|
|
158
|
+
const body = await zyteExtract(OLIVEYOUNG_API.PRODUCT_SEARCH_PATH, payload, options);
|
|
159
|
+
const list = body.data?.serachList || body.data?.searchList || [];
|
|
160
|
+
const products = list.map((product) => {
|
|
161
|
+
const o2oStockFlag = Boolean(product.o2oStockFlag);
|
|
162
|
+
const o2oRemainQuantity = product.o2oRemainQuantity || 0;
|
|
163
|
+
const inStock = resolveOliveyoungInStock(o2oStockFlag, o2oRemainQuantity);
|
|
164
|
+
const stockStatus = inStock ? 'in_stock' : 'out_of_stock';
|
|
165
|
+
const stockSource = 'global_search';
|
|
166
|
+
return {
|
|
167
|
+
goodsNumber: product.goodsNumber || '',
|
|
168
|
+
goodsName: product.goodsName || '',
|
|
169
|
+
imageUrl: resolveOliveyoungImageUrl(product.imagePath),
|
|
170
|
+
priceToPay: product.priceToPay || 0,
|
|
171
|
+
originalPrice: product.originalPrice || 0,
|
|
172
|
+
discountRate: product.discountRate || 0,
|
|
173
|
+
o2oStockFlag,
|
|
174
|
+
o2oRemainQuantity,
|
|
175
|
+
inStock,
|
|
176
|
+
stockStatus,
|
|
177
|
+
stockSource,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
const result = {
|
|
181
|
+
totalCount: body.data?.totalCount || 0,
|
|
182
|
+
nextPage: Boolean(body.data?.nextPage),
|
|
183
|
+
products,
|
|
149
184
|
};
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
185
|
+
writeStaleOliveyoungProductSearchResult(cacheKey, result);
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
const staleResult = readStaleOliveyoungProductSearchResult(cacheKey);
|
|
190
|
+
if (staleResult) {
|
|
191
|
+
return staleResult;
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
156
195
|
}
|
|
157
196
|
async function fetchOliveyoungProductId(goodsNumber, options = {}) {
|
|
158
|
-
const
|
|
159
|
-
|
|
197
|
+
const normalizedGoodsNumber = goodsNumber.trim();
|
|
198
|
+
if (!normalizedGoodsNumber) {
|
|
199
|
+
return '';
|
|
200
|
+
}
|
|
201
|
+
const cached = oliveyoungProductIdCache.get(normalizedGoodsNumber);
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
if (cached && cached.expiresAt > now) {
|
|
204
|
+
return cached.productId;
|
|
205
|
+
}
|
|
206
|
+
const body = await zyteExtract(OLIVEYOUNG_API.STOCK_GOODS_INFO_PATH, { goodsNo: normalizedGoodsNumber }, options);
|
|
207
|
+
const productId = body.data?.goodsInfo?.masterGoodsNumber || '';
|
|
208
|
+
if (productId) {
|
|
209
|
+
oliveyoungProductIdCache.set(normalizedGoodsNumber, {
|
|
210
|
+
expiresAt: now + OLIVEYOUNG_PRODUCT_ID_CACHE_TTL_MS,
|
|
211
|
+
productId,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return productId;
|
|
160
215
|
}
|
|
161
216
|
async function fetchOliveyoungStockStores(params, options = {}) {
|
|
162
217
|
const body = await zyteExtract(OLIVEYOUNG_API.STOCK_STORES_PATH, {
|
|
@@ -193,39 +248,55 @@ export async function enrichOliveyoungProductsWithNearbyStoreInventory(products,
|
|
|
193
248
|
if (maxProducts === 0) {
|
|
194
249
|
return { checkedCount: 0, products };
|
|
195
250
|
}
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
251
|
+
const checkedProducts = await Promise.all(products.slice(0, maxProducts).map(async (product) => {
|
|
252
|
+
let productId = '';
|
|
253
|
+
try {
|
|
254
|
+
productId = await fetchOliveyoungProductId(product.goodsNumber, options);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return { checked: false, product };
|
|
202
258
|
}
|
|
203
|
-
const productId = await fetchOliveyoungProductId(product.goodsNumber, options);
|
|
204
259
|
if (!productId) {
|
|
205
|
-
|
|
206
|
-
|
|
260
|
+
return { checked: false, product };
|
|
261
|
+
}
|
|
262
|
+
let storeInventory;
|
|
263
|
+
try {
|
|
264
|
+
storeInventory = await fetchOliveyoungStockStores({
|
|
265
|
+
productId,
|
|
266
|
+
latitude: params.latitude,
|
|
267
|
+
longitude: params.longitude,
|
|
268
|
+
pageIdx: 1,
|
|
269
|
+
searchWords: params.storeKeyword,
|
|
270
|
+
}, options);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return { checked: false, product };
|
|
207
274
|
}
|
|
208
|
-
const storeInventory = await fetchOliveyoungStockStores({
|
|
209
|
-
productId,
|
|
210
|
-
latitude: params.latitude,
|
|
211
|
-
longitude: params.longitude,
|
|
212
|
-
pageIdx: 1,
|
|
213
|
-
searchWords: params.storeKeyword,
|
|
214
|
-
}, options);
|
|
215
|
-
checkedCount += 1;
|
|
216
275
|
const inStock = storeInventory.inStockCount > 0;
|
|
217
276
|
const stockStatus = inStock ? 'in_stock' : 'out_of_stock';
|
|
218
277
|
const stockSource = 'nearby_store';
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
278
|
+
return {
|
|
279
|
+
checked: true,
|
|
280
|
+
product: {
|
|
281
|
+
...product,
|
|
282
|
+
inStock,
|
|
283
|
+
stockStatus,
|
|
284
|
+
stockSource,
|
|
285
|
+
storeInventory,
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}));
|
|
289
|
+
const enrichedProducts = [
|
|
290
|
+
...checkedProducts.map((result) => result.product),
|
|
291
|
+
...products.slice(maxProducts),
|
|
292
|
+
];
|
|
293
|
+
const checkedCount = checkedProducts.filter((result) => result.checked).length;
|
|
227
294
|
return {
|
|
228
295
|
checkedCount,
|
|
229
296
|
products: sortOliveyoungProducts(enrichedProducts),
|
|
230
297
|
};
|
|
231
298
|
}
|
|
299
|
+
export function __testOnlyClearOliveyoungCaches() {
|
|
300
|
+
oliveyoungProductIdCache.clear();
|
|
301
|
+
oliveyoungProductSearchCache.clear();
|
|
302
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { enrichOliveyoungProductsWithNearbyStoreInventory, fetchOliveyoungProducts, fetchOliveyoungStores, } from '../client.js';
|
|
6
6
|
async function checkInventory(args) {
|
|
7
|
-
const { keyword, latitude = 37.5665, longitude = 126.978, storeKeyword = '', page = 1, size = 20, sort = '01', includeSoldOut = false, storeLimit = 10, timeoutMs = 15000, zyteApiKey, } = args;
|
|
7
|
+
const { keyword, latitude = 37.5665, longitude = 126.978, storeKeyword = '', page = 1, size = 20, sort = '01', includeSoldOut = false, storeLimit = 10, stockCheckLimit = 5, timeoutMs = 15000, zyteApiKey, } = args;
|
|
8
8
|
if (!keyword || keyword.trim().length === 0) {
|
|
9
9
|
throw new Error('상품 검색어(keyword)를 입력해주세요.');
|
|
10
10
|
}
|
|
@@ -33,7 +33,7 @@ async function checkInventory(args) {
|
|
|
33
33
|
latitude,
|
|
34
34
|
longitude,
|
|
35
35
|
storeKeyword,
|
|
36
|
-
maxProducts: Math.min(productResult.products.length,
|
|
36
|
+
maxProducts: Math.min(productResult.products.length, Math.max(0, stockCheckLimit)),
|
|
37
37
|
}, {
|
|
38
38
|
timeout: timeoutMs,
|
|
39
39
|
apiKey: zyteApiKey,
|
|
@@ -90,6 +90,11 @@ export function createCheckInventoryTool(apiKey) {
|
|
|
90
90
|
sort: z.string().optional().default('01').describe('정렬 코드 (기본값: 01)'),
|
|
91
91
|
includeSoldOut: z.boolean().optional().default(false).describe('품절 상품 포함 여부 (기본값: false)'),
|
|
92
92
|
storeLimit: z.number().optional().default(10).describe('반환할 주변 매장 최대 수 (기본값: 10)'),
|
|
93
|
+
stockCheckLimit: z
|
|
94
|
+
.number()
|
|
95
|
+
.optional()
|
|
96
|
+
.default(5)
|
|
97
|
+
.describe('주변 매장별 재고를 보강할 상품 수 (기본값: 5, 낮출수록 빠름)'),
|
|
93
98
|
timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
|
|
94
99
|
},
|
|
95
100
|
},
|
|
@@ -9,6 +9,10 @@ const SEVENELEVEN_DEFAULT_HEADERS = {
|
|
|
9
9
|
'Content-Type': 'application/json',
|
|
10
10
|
'User-Agent': 'Mozilla/5.0 (Linux; Android 15)',
|
|
11
11
|
};
|
|
12
|
+
const SEVENELEVEN_DEFAULT_FETCH_OPTIONS = {
|
|
13
|
+
retries: 1,
|
|
14
|
+
retryDelayMs: 250,
|
|
15
|
+
};
|
|
12
16
|
function toNumber(value) {
|
|
13
17
|
if (typeof value === 'number') {
|
|
14
18
|
return Number.isFinite(value) ? value : 0;
|
|
@@ -108,7 +112,9 @@ async function requestSevenElevenJson(path, method, body, options = {}) {
|
|
|
108
112
|
const { timeout = 15000 } = options;
|
|
109
113
|
const url = `${SEVENELEVEN_API.BASE_URL}${path}`;
|
|
110
114
|
return fetchJson(url, {
|
|
115
|
+
...SEVENELEVEN_DEFAULT_FETCH_OPTIONS,
|
|
111
116
|
method,
|
|
117
|
+
retryUnsafeMethods: method === 'POST',
|
|
112
118
|
timeout,
|
|
113
119
|
headers: SEVENELEVEN_DEFAULT_HEADERS,
|
|
114
120
|
body: method === 'POST' ? JSON.stringify(body || {}) : undefined,
|
|
@@ -211,6 +217,7 @@ export async function fetchSevenElevenStockProductMeta(itemCode, options = {}) {
|
|
|
211
217
|
const encodedItemCode = encodeURIComponent(itemCode.trim());
|
|
212
218
|
const url = `${SEVENELEVEN_API.BASE_URL}${SEVENELEVEN_API.PRODUCT_SEARCH_STOCK_PATH}?itemCd=${encodedItemCode}`;
|
|
213
219
|
const response = await fetchJson(url, {
|
|
220
|
+
...SEVENELEVEN_DEFAULT_FETCH_OPTIONS,
|
|
214
221
|
method: 'GET',
|
|
215
222
|
timeout,
|
|
216
223
|
headers: {
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { searchSevenElevenProductsWithVariants } from '../productKeyword.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 { query, page = 1, size = 20, sort = 'recommend', timeoutMs = 15000 } = args;
|
|
8
14
|
if (!query || query.trim().length === 0) {
|
|
@@ -14,25 +20,29 @@ async function searchProducts(args) {
|
|
|
14
20
|
sort,
|
|
15
21
|
timeout: timeoutMs,
|
|
16
22
|
});
|
|
17
|
-
return {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
collectionIds: result.collectionIds,
|
|
29
|
-
appliedQueries: result.appliedQueries,
|
|
30
|
-
products: result.products,
|
|
31
|
-
}, null, 2),
|
|
32
|
-
},
|
|
33
|
-
],
|
|
34
|
-
};
|
|
23
|
+
return buildTextResponse({
|
|
24
|
+
query: result.query,
|
|
25
|
+
page,
|
|
26
|
+
size,
|
|
27
|
+
sort,
|
|
28
|
+
totalCount: result.totalCount,
|
|
29
|
+
count: result.products.length,
|
|
30
|
+
collectionIds: result.collectionIds,
|
|
31
|
+
appliedQueries: result.appliedQueries,
|
|
32
|
+
products: result.products,
|
|
33
|
+
});
|
|
35
34
|
}
|
|
35
|
+
const searchProductsOutputSchema = {
|
|
36
|
+
query: z.string().describe('적용된 검색어'),
|
|
37
|
+
page: z.number().describe('페이지 번호'),
|
|
38
|
+
size: z.number().describe('페이지 크기'),
|
|
39
|
+
sort: z.string().describe('정렬 기준'),
|
|
40
|
+
totalCount: z.number().describe('검색된 전체 상품 수'),
|
|
41
|
+
count: z.number().describe('반환된 상품 수'),
|
|
42
|
+
collectionIds: z.array(z.string()).describe('검색 컬렉션 ID 목록'),
|
|
43
|
+
appliedQueries: z.array(z.string()).describe('실제로 시도한 검색어 목록'),
|
|
44
|
+
products: z.array(z.unknown()).describe('세븐일레븐 상품 검색 결과'),
|
|
45
|
+
};
|
|
36
46
|
export function createSearchProductsTool() {
|
|
37
47
|
return {
|
|
38
48
|
name: 'seveneleven_search_products',
|
|
@@ -50,6 +60,7 @@ export function createSearchProductsTool() {
|
|
|
50
60
|
.describe('정렬 기준 (기본값: recommend)'),
|
|
51
61
|
timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
|
|
52
62
|
},
|
|
63
|
+
outputSchema: searchProductsOutputSchema,
|
|
53
64
|
},
|
|
54
65
|
handler: searchProducts,
|
|
55
66
|
};
|
package/dist/utils/http.js
CHANGED
|
@@ -20,18 +20,90 @@ export function createTimeoutController(timeout) {
|
|
|
20
20
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
21
21
|
return { controller, timeoutId };
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
const DEFAULT_RETRY_STATUS_CODES = [408, 429, 500, 502, 503, 504, 522, 524];
|
|
24
|
+
const DEFAULT_RETRY_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
|
25
|
+
function isRetryableStatus(status, retryStatusCodes) {
|
|
26
|
+
return retryStatusCodes.includes(status);
|
|
27
|
+
}
|
|
28
|
+
function wait(ms) {
|
|
29
|
+
if (ms <= 0) {
|
|
30
|
+
return Promise.resolve();
|
|
31
|
+
}
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
setTimeout(resolve, ms);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function normalizeMethod(method) {
|
|
37
|
+
return (method || 'GET').toUpperCase();
|
|
38
|
+
}
|
|
39
|
+
function canRetryMethod(method, retryMethods, retryUnsafeMethods) {
|
|
40
|
+
return retryUnsafeMethods || retryMethods.map((item) => item.toUpperCase()).includes(method);
|
|
41
|
+
}
|
|
42
|
+
function parseRetryAfterDelayMs(response) {
|
|
43
|
+
const retryAfter = response.headers.get('retry-after');
|
|
44
|
+
if (!retryAfter) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const seconds = Number.parseFloat(retryAfter);
|
|
48
|
+
if (Number.isFinite(seconds)) {
|
|
49
|
+
return Math.max(0, Math.trunc(seconds * 1000));
|
|
50
|
+
}
|
|
51
|
+
const retryDate = Date.parse(retryAfter);
|
|
52
|
+
if (Number.isFinite(retryDate)) {
|
|
53
|
+
return Math.max(0, retryDate - Date.now());
|
|
31
54
|
}
|
|
32
|
-
|
|
33
|
-
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
export async function fetchWithTimeout(url, options = {}) {
|
|
58
|
+
const { timeout = 10000, retries = 0, retryDelayMs = 250, retryStatusCodes = DEFAULT_RETRY_STATUS_CODES, retryUnsafeMethods = false, retryMethods = DEFAULT_RETRY_METHODS, onRetry, ...restOptions } = options;
|
|
59
|
+
const maxAttempts = Math.max(1, Math.trunc(retries) + 1);
|
|
60
|
+
const method = normalizeMethod(restOptions.method);
|
|
61
|
+
const retryAllowed = canRetryMethod(method, retryMethods, retryUnsafeMethods);
|
|
62
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
63
|
+
const { controller, timeoutId } = createTimeoutController(timeout);
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(url, {
|
|
66
|
+
...restOptions,
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
});
|
|
69
|
+
if (retryAllowed && attempt < maxAttempts && isRetryableStatus(response.status, retryStatusCodes)) {
|
|
70
|
+
const delayMs = parseRetryAfterDelayMs(response) ?? retryDelayMs;
|
|
71
|
+
onRetry?.({
|
|
72
|
+
attempt,
|
|
73
|
+
maxAttempts,
|
|
74
|
+
delayMs,
|
|
75
|
+
reason: 'status',
|
|
76
|
+
status: response.status,
|
|
77
|
+
method,
|
|
78
|
+
url,
|
|
79
|
+
});
|
|
80
|
+
await response.body?.cancel();
|
|
81
|
+
await wait(delayMs);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
return response;
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (!retryAllowed || attempt >= maxAttempts) {
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
onRetry?.({
|
|
91
|
+
attempt,
|
|
92
|
+
maxAttempts,
|
|
93
|
+
delayMs: retryDelayMs,
|
|
94
|
+
reason: 'error',
|
|
95
|
+
error,
|
|
96
|
+
method,
|
|
97
|
+
url,
|
|
98
|
+
});
|
|
99
|
+
await wait(retryDelayMs);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
clearTimeout(timeoutId);
|
|
103
|
+
}
|
|
34
104
|
}
|
|
105
|
+
/* v8 ignore next */
|
|
106
|
+
throw new Error('API 요청 재시도 처리 중 알 수 없는 오류가 발생했습니다.');
|
|
35
107
|
}
|
|
36
108
|
export async function fetchJson(url, options = {}) {
|
|
37
109
|
const response = await fetchWithTimeout(url, options);
|
package/dist/utils/zyte.js
CHANGED
|
@@ -37,36 +37,57 @@ export function decodeBase64(value) {
|
|
|
37
37
|
/* c8 ignore end */
|
|
38
38
|
throw new Error('Base64 디코딩을 지원하지 않는 런타임입니다.');
|
|
39
39
|
}
|
|
40
|
+
function isAbortError(error) {
|
|
41
|
+
return error instanceof Error && error.name === 'AbortError';
|
|
42
|
+
}
|
|
43
|
+
function wait(ms) {
|
|
44
|
+
if (ms <= 0) {
|
|
45
|
+
return Promise.resolve();
|
|
46
|
+
}
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
setTimeout(resolve, ms);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
40
51
|
export async function requestByZyte(options) {
|
|
41
|
-
const { timeout = 15000, url, method = 'GET', headers = [], bodyText, apiKey } = options;
|
|
52
|
+
const { timeout = 15000, retries = 1, retryDelayMs = 250, url, method = 'GET', headers = [], bodyText, apiKey, } = options;
|
|
42
53
|
const auth = encodeBasicAuth(resolveZyteApiKey(apiKey));
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
const maxAttempts = Math.max(1, Math.trunc(retries) + 1);
|
|
55
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
56
|
+
const { controller, timeoutId } = createTimeoutController(timeout);
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetch('https://api.zyte.com/v1/extract', {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: {
|
|
61
|
+
Authorization: `Basic ${auth}`,
|
|
62
|
+
'Content-Type': 'application/json',
|
|
63
|
+
Accept: 'application/json',
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify({
|
|
66
|
+
url,
|
|
67
|
+
httpRequestMethod: method,
|
|
68
|
+
customHttpRequestHeaders: headers,
|
|
69
|
+
httpRequestText: bodyText,
|
|
70
|
+
httpResponseBody: true,
|
|
71
|
+
}),
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
});
|
|
74
|
+
const result = (await response.json());
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(`Zyte API 호출 실패: ${response.status} ${result.detail || result.title || ''}`.trim());
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (!isAbortError(error) || attempt >= maxAttempts) {
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
await wait(retryDelayMs);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
clearTimeout(timeoutId);
|
|
64
88
|
}
|
|
65
|
-
return result;
|
|
66
|
-
}
|
|
67
|
-
finally {
|
|
68
|
-
clearTimeout(timeoutId);
|
|
69
89
|
}
|
|
90
|
+
throw new Error('Zyte API 호출 실패');
|
|
70
91
|
}
|
|
71
92
|
export function decodeZyteHttpBody(result) {
|
|
72
93
|
if (!result.httpResponseBody) {
|
package/openapi.json
CHANGED
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"name": "productId",
|
|
97
97
|
"in": "query",
|
|
98
98
|
"required": false,
|
|
99
|
-
"description": "제품 ID 또는 상세 조회용 path
|
|
99
|
+
"description": "제품 ID 또는 상세 조회용 path 파라미터. 상품명만 알면 먼저 daisoSearchProducts 또는 /api/daiso/products로 productId를 확인하세요.",
|
|
100
100
|
"schema": {
|
|
101
101
|
"type": "string"
|
|
102
102
|
}
|
|
@@ -278,7 +278,7 @@
|
|
|
278
278
|
"name": "storeCode",
|
|
279
279
|
"in": "query",
|
|
280
280
|
"required": false,
|
|
281
|
-
"description": "매장
|
|
281
|
+
"description": "매장 코드. 다이소 진열 위치는 /api/daiso/inventory 응답의 storeInventory.stores[].storeCode에서 확인하세요.",
|
|
282
282
|
"schema": {
|
|
283
283
|
"type": "string"
|
|
284
284
|
}
|
|
@@ -287,7 +287,7 @@
|
|
|
287
287
|
"name": "storeName",
|
|
288
288
|
"in": "query",
|
|
289
289
|
"required": false,
|
|
290
|
-
"description": "
|
|
290
|
+
"description": "매장명. 롯데마트 상품 조회에서 storeCode가 없을 때 사용하며, 모르면 먼저 lottemartFindNearbyStores 또는 /api/lottemart/stores로 확인하세요.",
|
|
291
291
|
"schema": {
|
|
292
292
|
"type": "string"
|
|
293
293
|
}
|