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.
Files changed (51) hide show
  1. package/README.md +45 -7
  2. package/dist/api/configStatus.js +19 -0
  3. package/dist/api/gs25Handlers.js +25 -14
  4. package/dist/api/handlers.js +32 -3
  5. package/dist/api/healthChecks.js +185 -13
  6. package/dist/api/lottemartHandlers.js +17 -1
  7. package/dist/api/response.js +2 -0
  8. package/dist/api/routes/healthRoutes.js +20 -3
  9. package/dist/bin.js +0 -0
  10. package/dist/cli/args.js +21 -0
  11. package/dist/cli/commandOptions.js +38 -0
  12. package/dist/cli/commands/convenience.js +53 -1
  13. package/dist/cli/commands/daiso.js +18 -2
  14. package/dist/cli/http.js +58 -0
  15. package/dist/cli/interactiveItemSearch.js +4 -2
  16. package/dist/cli/interactivePrompt.js +3 -2
  17. package/dist/cli.js +22 -0
  18. package/dist/cliHelp.js +6 -0
  19. package/dist/cliInteractive.js +75 -6
  20. package/dist/cliPicker.js +1 -0
  21. package/dist/cliRenderer.js +86 -3
  22. package/dist/core/errors.js +66 -0
  23. package/dist/core/registry.js +227 -3
  24. package/dist/index.js +2 -1
  25. package/dist/pages/openapiSpecActions.js +3 -3
  26. package/dist/pages/openapiSpecPathsCu.js +7 -0
  27. package/dist/pages/openapiSpecPathsLotteMart.js +14 -0
  28. package/dist/pages/openapiSpecPathsOliveyoung.js +35 -0
  29. package/dist/pages/promptReferenceText.js +13 -7
  30. package/dist/pages/promptRetailCinemaText.js +2 -1
  31. package/dist/services/daiso/client.js +8 -0
  32. package/dist/services/daiso/index.js +2 -0
  33. package/dist/services/daiso/tools/checkInventory.js +4 -2
  34. package/dist/services/daiso/tools/findInventoryByName.js +222 -0
  35. package/dist/services/daiso/tools/getDisplayLocation.js +2 -2
  36. package/dist/services/emart24/client.js +9 -0
  37. package/dist/services/emart24/tools/searchProducts.js +27 -17
  38. package/dist/services/gs25/client.js +10 -0
  39. package/dist/services/gs25/tools/searchProducts.js +28 -21
  40. package/dist/services/lottemart/client.js +10 -1
  41. package/dist/services/lottemart/tools/searchProducts.js +8 -1
  42. package/dist/services/lottemart/zetta.js +1 -1
  43. package/dist/services/oliveyoung/client.js +124 -53
  44. package/dist/services/oliveyoung/tools/checkInventory.js +7 -2
  45. package/dist/services/seveneleven/client.js +7 -0
  46. package/dist/services/seveneleven/tools/searchProducts.js +29 -18
  47. package/dist/utils/http.js +82 -10
  48. package/dist/utils/zyte.js +47 -26
  49. package/openapi.json +3 -3
  50. package/openapi.yaml +3 -3
  51. 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 body = await zyteExtract(OLIVEYOUNG_API.PRODUCT_SEARCH_PATH, payload, options);
130
- const list = body.data?.serachList || body.data?.searchList || [];
131
- const products = list.map((product) => {
132
- const o2oStockFlag = Boolean(product.o2oStockFlag);
133
- const o2oRemainQuantity = product.o2oRemainQuantity || 0;
134
- const inStock = resolveOliveyoungInStock(o2oStockFlag, o2oRemainQuantity);
135
- const stockStatus = inStock ? 'in_stock' : 'out_of_stock';
136
- const stockSource = 'global_search';
137
- return {
138
- goodsNumber: product.goodsNumber || '',
139
- goodsName: product.goodsName || '',
140
- imageUrl: resolveOliveyoungImageUrl(product.imagePath),
141
- priceToPay: product.priceToPay || 0,
142
- originalPrice: product.originalPrice || 0,
143
- discountRate: product.discountRate || 0,
144
- o2oStockFlag,
145
- o2oRemainQuantity,
146
- inStock,
147
- stockStatus,
148
- stockSource,
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
- return {
152
- totalCount: body.data?.totalCount || 0,
153
- nextPage: Boolean(body.data?.nextPage),
154
- products,
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 body = await zyteExtract(OLIVEYOUNG_API.STOCK_GOODS_INFO_PATH, { goodsNo: goodsNumber }, options);
159
- return body.data?.goodsInfo?.masterGoodsNumber || '';
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 enrichedProducts = [];
197
- let checkedCount = 0;
198
- for (const [index, product] of products.entries()) {
199
- if (index >= maxProducts) {
200
- enrichedProducts.push(product);
201
- continue;
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
- enrichedProducts.push(product);
206
- continue;
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
- enrichedProducts.push({
220
- ...product,
221
- inStock,
222
- stockStatus,
223
- stockSource,
224
- storeInventory,
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, 5),
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
- content: [
19
- {
20
- type: 'text',
21
- text: JSON.stringify({
22
- query: result.query,
23
- page,
24
- size,
25
- sort,
26
- totalCount: result.totalCount,
27
- count: result.products.length,
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
  };
@@ -20,18 +20,90 @@ export function createTimeoutController(timeout) {
20
20
  const timeoutId = setTimeout(() => controller.abort(), timeout);
21
21
  return { controller, timeoutId };
22
22
  }
23
- export async function fetchWithTimeout(url, options = {}) {
24
- const { timeout = 10000, ...restOptions } = options;
25
- const { controller, timeoutId } = createTimeoutController(timeout);
26
- try {
27
- return await fetch(url, {
28
- ...restOptions,
29
- signal: controller.signal,
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
- finally {
33
- clearTimeout(timeoutId);
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);
@@ -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 { controller, timeoutId } = createTimeoutController(timeout);
44
- try {
45
- const response = await fetch('https://api.zyte.com/v1/extract', {
46
- method: 'POST',
47
- headers: {
48
- Authorization: `Basic ${auth}`,
49
- 'Content-Type': 'application/json',
50
- Accept: 'application/json',
51
- },
52
- body: JSON.stringify({
53
- url,
54
- httpRequestMethod: method,
55
- customHttpRequestHeaders: headers,
56
- httpRequestText: bodyText,
57
- httpResponseBody: true,
58
- }),
59
- signal: controller.signal,
60
- });
61
- const result = (await response.json());
62
- if (!response.ok) {
63
- throw new Error(`Zyte API 호출 실패: ${response.status} ${result.detail || result.title || ''}`.trim());
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
  }