daiso 1.0.5 → 1.0.7

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 (64) hide show
  1. package/README.md +101 -7
  2. package/dist/api/actionsProxy.js +111 -21
  3. package/dist/api/configStatus.js +23 -0
  4. package/dist/api/gs25Handlers.js +68 -22
  5. package/dist/api/handlers.js +32 -3
  6. package/dist/api/healthChecks.js +233 -20
  7. package/dist/api/lottemartHandlers.js +17 -1
  8. package/dist/api/placesHandlers.js +44 -0
  9. package/dist/api/response.js +2 -0
  10. package/dist/api/routes/healthRoutes.js +20 -3
  11. package/dist/api/routes/placesRoutes.js +9 -0
  12. package/dist/bin.js +0 -0
  13. package/dist/cli/args.js +21 -0
  14. package/dist/cli/commandOptions.js +96 -0
  15. package/dist/cli/commands/convenience.js +73 -1
  16. package/dist/cli/commands/daiso.js +18 -2
  17. package/dist/cli/http.js +58 -0
  18. package/dist/cli/interactiveItemSearch.js +4 -2
  19. package/dist/cli/interactivePrompt.js +3 -2
  20. package/dist/cli.js +25 -1
  21. package/dist/cliHelp.js +20 -6
  22. package/dist/cliInteractive.js +75 -6
  23. package/dist/cliPicker.js +1 -0
  24. package/dist/cliRenderer.js +93 -3
  25. package/dist/core/errors.js +66 -0
  26. package/dist/core/registry.js +246 -3
  27. package/dist/index.js +19 -4
  28. package/dist/pages/openapiFullSpec.js +3 -0
  29. package/dist/pages/openapiSpecActions.js +308 -42
  30. package/dist/pages/openapiSpecPathsCu.js +7 -0
  31. package/dist/pages/openapiSpecPathsLotteMart.js +14 -0
  32. package/dist/pages/openapiSpecPathsOliveyoung.js +35 -0
  33. package/dist/pages/openapiSpecPathsPlaces.js +85 -0
  34. package/dist/pages/promptReferenceText.js +16 -7
  35. package/dist/pages/promptRetailCinemaText.js +24 -2
  36. package/dist/services/daiso/client.js +8 -0
  37. package/dist/services/daiso/index.js +2 -0
  38. package/dist/services/daiso/tools/checkInventory.js +4 -2
  39. package/dist/services/daiso/tools/findInventoryByName.js +222 -0
  40. package/dist/services/daiso/tools/getDisplayLocation.js +2 -2
  41. package/dist/services/emart24/client.js +9 -0
  42. package/dist/services/emart24/tools/searchProducts.js +27 -17
  43. package/dist/services/gs25/api.js +3 -0
  44. package/dist/services/gs25/client.js +216 -30
  45. package/dist/services/gs25/index.js +11 -3
  46. package/dist/services/gs25/tools/checkInventory.js +39 -12
  47. package/dist/services/gs25/tools/findNearbyStores.js +19 -5
  48. package/dist/services/gs25/tools/searchProducts.js +39 -25
  49. package/dist/services/lottemart/client.js +10 -1
  50. package/dist/services/lottemart/tools/searchProducts.js +8 -1
  51. package/dist/services/lottemart/zetta.js +1 -1
  52. package/dist/services/oliveyoung/client.js +206 -86
  53. package/dist/services/oliveyoung/tools/checkInventory.js +7 -2
  54. package/dist/services/places/client.js +116 -0
  55. package/dist/services/places/index.js +23 -0
  56. package/dist/services/places/tools/searchNearby.js +56 -0
  57. package/dist/services/places/types.js +1 -0
  58. package/dist/services/seveneleven/client.js +7 -0
  59. package/dist/services/seveneleven/tools/searchProducts.js +29 -18
  60. package/dist/utils/http.js +82 -10
  61. package/dist/utils/zyte.js +74 -26
  62. package/openapi.json +22 -3
  63. package/openapi.yaml +16 -3
  64. package/package.json +28 -9
@@ -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,12 @@
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();
13
+ const oliveyoungStoreSearchCache = new Map();
14
+ const oliveyoungStockStoresCache = new Map();
9
15
  const OLIVEYOUNG_IMAGE_PATH_PREFIX = '/uploads/images/goods';
10
16
  function resolveOliveyoungInStock(o2oStockFlag, o2oRemainQuantity) {
11
17
  return o2oStockFlag || o2oRemainQuantity > 0;
@@ -60,6 +66,50 @@ function resolveOliveyoungStoreStock(rawStore) {
60
66
  openYn: Boolean(rawStore?.openYn),
61
67
  };
62
68
  }
69
+ function createOliveyoungProductSearchCacheKey(params) {
70
+ return JSON.stringify([params.keyword, params.page, params.size, params.sort, params.includeSoldOut]);
71
+ }
72
+ function createOliveyoungStoreSearchCacheKey(params) {
73
+ return JSON.stringify([params.latitude, params.longitude, params.pageIdx, params.searchWords]);
74
+ }
75
+ function createOliveyoungStockStoresCacheKey(params) {
76
+ return JSON.stringify([params.productId, params.latitude, params.longitude, params.pageIdx, params.searchWords]);
77
+ }
78
+ function cloneOliveyoungProductSearchResult(result) {
79
+ return {
80
+ totalCount: result.totalCount,
81
+ nextPage: result.nextPage,
82
+ products: result.products.map((product) => ({ ...product })),
83
+ };
84
+ }
85
+ function cloneOliveyoungStoreSearchResult(result) {
86
+ return {
87
+ totalCount: result.totalCount,
88
+ stores: result.stores.map((store) => ({ ...store })),
89
+ };
90
+ }
91
+ function cloneOliveyoungStockStoresResult(result) {
92
+ return {
93
+ totalCount: result.totalCount,
94
+ inStockCount: result.inStockCount,
95
+ outOfStockCount: result.outOfStockCount,
96
+ notSoldCount: result.notSoldCount,
97
+ stores: result.stores.map((store) => ({ ...store })),
98
+ };
99
+ }
100
+ function readStaleResult(cache, cacheKey, cloneResult) {
101
+ const cached = cache.get(cacheKey);
102
+ if (!cached || cached.expiresAt <= Date.now()) {
103
+ return null;
104
+ }
105
+ return cloneResult(cached.result);
106
+ }
107
+ function writeStaleResult(cache, cacheKey, result, cloneResult) {
108
+ cache.set(cacheKey, {
109
+ expiresAt: Date.now() + OLIVEYOUNG_PRODUCT_SEARCH_STALE_CACHE_TTL_MS,
110
+ result: cloneResult(result),
111
+ });
112
+ }
63
113
  async function zyteExtract(targetPath, requestBody, options = {}) {
64
114
  const { timeout = 15000, apiKey } = options;
65
115
  try {
@@ -103,20 +153,32 @@ export async function fetchOliveyoungStores(params, options = {}) {
103
153
  mapLat: params.latitude,
104
154
  mapLon: params.longitude,
105
155
  };
106
- const body = await zyteExtract(OLIVEYOUNG_API.STORE_FINDER_PATH, payload, options);
107
- const stores = (body.data?.storeList || []).map((store) => ({
108
- storeCode: store.storeCode || '',
109
- storeName: store.storeName || '',
110
- address: store.address || '',
111
- latitude: store.latitude || 0,
112
- longitude: store.longitude || 0,
113
- pickupYn: Boolean(store.pickupYn),
114
- o2oRemainQuantity: store.o2oRemainQuantity || 0,
115
- }));
116
- return {
117
- totalCount: body.data?.totalCount || 0,
118
- stores,
119
- };
156
+ const cacheKey = createOliveyoungStoreSearchCacheKey(params);
157
+ try {
158
+ const body = await zyteExtract(OLIVEYOUNG_API.STORE_FINDER_PATH, payload, options);
159
+ const stores = (body.data?.storeList || []).map((store) => ({
160
+ storeCode: store.storeCode || '',
161
+ storeName: store.storeName || '',
162
+ address: store.address || '',
163
+ latitude: store.latitude || 0,
164
+ longitude: store.longitude || 0,
165
+ pickupYn: Boolean(store.pickupYn),
166
+ o2oRemainQuantity: store.o2oRemainQuantity || 0,
167
+ }));
168
+ const result = {
169
+ totalCount: body.data?.totalCount || 0,
170
+ stores,
171
+ };
172
+ writeStaleResult(oliveyoungStoreSearchCache, cacheKey, result, cloneOliveyoungStoreSearchResult);
173
+ return result;
174
+ }
175
+ catch (error) {
176
+ const staleResult = readStaleResult(oliveyoungStoreSearchCache, cacheKey, cloneOliveyoungStoreSearchResult);
177
+ if (staleResult) {
178
+ return staleResult;
179
+ }
180
+ throw error;
181
+ }
120
182
  }
121
183
  export async function fetchOliveyoungProducts(params, options = {}) {
122
184
  const payload = {
@@ -126,58 +188,98 @@ export async function fetchOliveyoungProducts(params, options = {}) {
126
188
  sort: params.sort,
127
189
  size: params.size,
128
190
  };
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,
191
+ const cacheKey = createOliveyoungProductSearchCacheKey(params);
192
+ try {
193
+ const body = await zyteExtract(OLIVEYOUNG_API.PRODUCT_SEARCH_PATH, payload, options);
194
+ const list = body.data?.serachList || body.data?.searchList || [];
195
+ const products = list.map((product) => {
196
+ const o2oStockFlag = Boolean(product.o2oStockFlag);
197
+ const o2oRemainQuantity = product.o2oRemainQuantity || 0;
198
+ const inStock = resolveOliveyoungInStock(o2oStockFlag, o2oRemainQuantity);
199
+ const stockStatus = inStock ? 'in_stock' : 'out_of_stock';
200
+ const stockSource = 'global_search';
201
+ return {
202
+ goodsNumber: product.goodsNumber || '',
203
+ goodsName: product.goodsName || '',
204
+ imageUrl: resolveOliveyoungImageUrl(product.imagePath),
205
+ priceToPay: product.priceToPay || 0,
206
+ originalPrice: product.originalPrice || 0,
207
+ discountRate: product.discountRate || 0,
208
+ o2oStockFlag,
209
+ o2oRemainQuantity,
210
+ inStock,
211
+ stockStatus,
212
+ stockSource,
213
+ };
214
+ });
215
+ const result = {
216
+ totalCount: body.data?.totalCount || 0,
217
+ nextPage: Boolean(body.data?.nextPage),
218
+ products,
149
219
  };
150
- });
151
- return {
152
- totalCount: body.data?.totalCount || 0,
153
- nextPage: Boolean(body.data?.nextPage),
154
- products,
155
- };
220
+ writeStaleResult(oliveyoungProductSearchCache, cacheKey, result, cloneOliveyoungProductSearchResult);
221
+ return result;
222
+ }
223
+ catch (error) {
224
+ const staleResult = readStaleResult(oliveyoungProductSearchCache, cacheKey, cloneOliveyoungProductSearchResult);
225
+ if (staleResult) {
226
+ return staleResult;
227
+ }
228
+ throw error;
229
+ }
156
230
  }
157
231
  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 || '';
232
+ const normalizedGoodsNumber = goodsNumber.trim();
233
+ if (!normalizedGoodsNumber) {
234
+ return '';
235
+ }
236
+ const cached = oliveyoungProductIdCache.get(normalizedGoodsNumber);
237
+ const now = Date.now();
238
+ if (cached && cached.expiresAt > now) {
239
+ return cached.productId;
240
+ }
241
+ const body = await zyteExtract(OLIVEYOUNG_API.STOCK_GOODS_INFO_PATH, { goodsNo: normalizedGoodsNumber }, options);
242
+ const productId = body.data?.goodsInfo?.masterGoodsNumber || '';
243
+ if (productId) {
244
+ oliveyoungProductIdCache.set(normalizedGoodsNumber, {
245
+ expiresAt: now + OLIVEYOUNG_PRODUCT_ID_CACHE_TTL_MS,
246
+ productId,
247
+ });
248
+ }
249
+ return productId;
160
250
  }
161
251
  async function fetchOliveyoungStockStores(params, options = {}) {
162
- const body = await zyteExtract(OLIVEYOUNG_API.STOCK_STORES_PATH, {
163
- productId: params.productId,
164
- lat: params.latitude,
165
- lon: params.longitude,
166
- pageIdx: params.pageIdx,
167
- searchWords: params.searchWords,
168
- mapLat: params.latitude,
169
- mapLon: params.longitude,
170
- }, options);
171
- const stores = (body.data?.storeList || []).map((store) => resolveOliveyoungStoreStock(store));
172
- const inStockCount = stores.filter((store) => store.stockStatus === 'in_stock').length;
173
- const notSoldCount = stores.filter((store) => store.stockStatus === 'not_sold').length;
174
- return {
175
- totalCount: body.data?.totalCount || 0,
176
- inStockCount,
177
- outOfStockCount: stores.length - inStockCount - notSoldCount,
178
- notSoldCount,
179
- stores,
180
- };
252
+ const cacheKey = createOliveyoungStockStoresCacheKey(params);
253
+ try {
254
+ const body = await zyteExtract(OLIVEYOUNG_API.STOCK_STORES_PATH, {
255
+ productId: params.productId,
256
+ lat: params.latitude,
257
+ lon: params.longitude,
258
+ pageIdx: params.pageIdx,
259
+ searchWords: params.searchWords,
260
+ mapLat: params.latitude,
261
+ mapLon: params.longitude,
262
+ }, options);
263
+ const stores = (body.data?.storeList || []).map((store) => resolveOliveyoungStoreStock(store));
264
+ const inStockCount = stores.filter((store) => store.stockStatus === 'in_stock').length;
265
+ const notSoldCount = stores.filter((store) => store.stockStatus === 'not_sold').length;
266
+ const result = {
267
+ totalCount: body.data?.totalCount || 0,
268
+ inStockCount,
269
+ outOfStockCount: stores.length - inStockCount - notSoldCount,
270
+ notSoldCount,
271
+ stores,
272
+ };
273
+ writeStaleResult(oliveyoungStockStoresCache, cacheKey, result, cloneOliveyoungStockStoresResult);
274
+ return result;
275
+ }
276
+ catch (error) {
277
+ const staleResult = readStaleResult(oliveyoungStockStoresCache, cacheKey, cloneOliveyoungStockStoresResult);
278
+ if (staleResult) {
279
+ return staleResult;
280
+ }
281
+ throw error;
282
+ }
181
283
  }
182
284
  function sortOliveyoungProducts(products) {
183
285
  const score = (product) => {
@@ -193,39 +295,57 @@ export async function enrichOliveyoungProductsWithNearbyStoreInventory(products,
193
295
  if (maxProducts === 0) {
194
296
  return { checkedCount: 0, products };
195
297
  }
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;
298
+ const checkedProducts = await Promise.all(products.slice(0, maxProducts).map(async (product) => {
299
+ let productId = '';
300
+ try {
301
+ productId = await fetchOliveyoungProductId(product.goodsNumber, options);
302
+ }
303
+ catch {
304
+ return { checked: false, product };
202
305
  }
203
- const productId = await fetchOliveyoungProductId(product.goodsNumber, options);
204
306
  if (!productId) {
205
- enrichedProducts.push(product);
206
- continue;
307
+ return { checked: false, product };
308
+ }
309
+ let storeInventory;
310
+ try {
311
+ storeInventory = await fetchOliveyoungStockStores({
312
+ productId,
313
+ latitude: params.latitude,
314
+ longitude: params.longitude,
315
+ pageIdx: 1,
316
+ searchWords: params.storeKeyword,
317
+ }, options);
318
+ }
319
+ catch {
320
+ return { checked: false, product };
207
321
  }
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
322
  const inStock = storeInventory.inStockCount > 0;
217
323
  const stockStatus = inStock ? 'in_stock' : 'out_of_stock';
218
324
  const stockSource = 'nearby_store';
219
- enrichedProducts.push({
220
- ...product,
221
- inStock,
222
- stockStatus,
223
- stockSource,
224
- storeInventory,
225
- });
226
- }
325
+ return {
326
+ checked: true,
327
+ product: {
328
+ ...product,
329
+ inStock,
330
+ stockStatus,
331
+ stockSource,
332
+ storeInventory,
333
+ },
334
+ };
335
+ }));
336
+ const enrichedProducts = [
337
+ ...checkedProducts.map((result) => result.product),
338
+ ...products.slice(maxProducts),
339
+ ];
340
+ const checkedCount = checkedProducts.filter((result) => result.checked).length;
227
341
  return {
228
342
  checkedCount,
229
343
  products: sortOliveyoungProducts(enrichedProducts),
230
344
  };
231
345
  }
346
+ export function __testOnlyClearOliveyoungCaches() {
347
+ oliveyoungProductIdCache.clear();
348
+ oliveyoungProductSearchCache.clear();
349
+ oliveyoungStoreSearchCache.clear();
350
+ oliveyoungStockStoresCache.clear();
351
+ }
@@ -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
  },
@@ -0,0 +1,116 @@
1
+ const CATEGORY_KEYWORDS = {
2
+ restaurant: '음식점',
3
+ cafe: '카페',
4
+ food: '음식점',
5
+ dessert: '디저트',
6
+ all: '',
7
+ };
8
+ function getProcessEnvValue(name) {
9
+ /* c8 ignore next -- Node 테스트와 CLI 런타임은 process를 제공하며, Worker에는 API에서 키를 주입합니다. */
10
+ return typeof process !== 'undefined' ? process.env[name] : undefined;
11
+ }
12
+ function cleanHtml(value) {
13
+ if (!value) {
14
+ return '';
15
+ }
16
+ return value
17
+ .replace(/<[^>]*>/g, '')
18
+ .replace(/&amp;/g, '&')
19
+ .replace(/&lt;/g, '<')
20
+ .replace(/&gt;/g, '>')
21
+ .replace(/&quot;/g, '"')
22
+ .replace(/&#39;/g, "'")
23
+ .trim();
24
+ }
25
+ function toCoordinate(value) {
26
+ if (!value) {
27
+ return null;
28
+ }
29
+ const parsed = Number.parseInt(value, 10);
30
+ if (!Number.isFinite(parsed)) {
31
+ return null;
32
+ }
33
+ return parsed / 10000000;
34
+ }
35
+ function clampDisplay(value) {
36
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
37
+ return 5;
38
+ }
39
+ return Math.min(Math.max(Math.trunc(value), 1), 5);
40
+ }
41
+ function clampStart(value) {
42
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
43
+ return 1;
44
+ }
45
+ return Math.min(Math.max(Math.trunc(value), 1), 1000);
46
+ }
47
+ export function buildNaverLocalQuery({ location = '', keyword = '', category = 'all', }) {
48
+ const parts = [location.trim(), keyword.trim() || CATEGORY_KEYWORDS[category]].filter((part) => part.length > 0);
49
+ return parts.join(' ').trim();
50
+ }
51
+ function normalizePlace(item) {
52
+ return {
53
+ name: cleanHtml(item.title),
54
+ category: cleanHtml(item.category),
55
+ address: cleanHtml(item.address),
56
+ roadAddress: cleanHtml(item.roadAddress),
57
+ phone: cleanHtml(item.telephone),
58
+ link: item.link,
59
+ longitude: toCoordinate(item.mapx),
60
+ latitude: toCoordinate(item.mapy),
61
+ raw: item,
62
+ };
63
+ }
64
+ export async function searchNaverLocalPlaces(args) {
65
+ const naverClientId = args.naverClientId?.trim() || getProcessEnvValue('NAVER_CLIENT_ID')?.trim();
66
+ const naverClientSecret = args.naverClientSecret?.trim() || getProcessEnvValue('NAVER_CLIENT_SECRET')?.trim();
67
+ if (!naverClientId || !naverClientSecret) {
68
+ throw new Error('NAVER_CLIENT_ID와 NAVER_CLIENT_SECRET 환경 변수가 필요합니다.');
69
+ }
70
+ const category = args.category ?? 'all';
71
+ const location = args.location?.trim() ?? '';
72
+ const query = buildNaverLocalQuery({
73
+ location,
74
+ keyword: args.keyword,
75
+ category,
76
+ });
77
+ if (query.length === 0) {
78
+ throw new Error('위치(location) 또는 검색어(keyword)를 입력해주세요.');
79
+ }
80
+ const url = new URL('https://openapi.naver.com/v1/search/local.json');
81
+ url.searchParams.set('query', query);
82
+ url.searchParams.set('display', String(clampDisplay(args.limit)));
83
+ url.searchParams.set('start', String(clampStart(args.start)));
84
+ url.searchParams.set('sort', args.sort ?? 'random');
85
+ const fetchImpl = args.fetchImpl ?? fetch;
86
+ const response = await fetchImpl(url.toString(), {
87
+ headers: {
88
+ 'X-Naver-Client-Id': naverClientId,
89
+ 'X-Naver-Client-Secret': naverClientSecret,
90
+ },
91
+ signal: AbortSignal.timeout(args.timeoutMs ?? 10000),
92
+ });
93
+ const bodyText = await response.text();
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(bodyText);
97
+ }
98
+ catch {
99
+ parsed = {};
100
+ }
101
+ if (!response.ok) {
102
+ const message = parsed.errorMessage || bodyText || `HTTP ${response.status}`;
103
+ throw new Error(`네이버 지역 검색 실패: ${message}`);
104
+ }
105
+ const places = (parsed.items ?? []).map(normalizePlace);
106
+ return {
107
+ provider: 'naverLocal',
108
+ searchMode: 'keyword',
109
+ query,
110
+ category,
111
+ location,
112
+ totalCount: parsed.total ?? places.length,
113
+ count: places.length,
114
+ places,
115
+ };
116
+ }
@@ -0,0 +1,23 @@
1
+ import { createSearchNearbyPlacesTool } from './tools/searchNearby.js';
2
+ const PLACES_METADATA = {
3
+ id: 'places',
4
+ name: '장소 검색',
5
+ version: '1.0.0',
6
+ description: '네이버 지역 검색 기반 음식점, 카페, 주변 장소 탐색 서비스',
7
+ };
8
+ class PlacesService {
9
+ options;
10
+ metadata = PLACES_METADATA;
11
+ constructor(options = {}) {
12
+ this.options = options;
13
+ }
14
+ getTools() {
15
+ return [
16
+ createSearchNearbyPlacesTool(this.options.naverClientId, this.options.naverClientSecret),
17
+ ];
18
+ }
19
+ }
20
+ export function createPlacesService(options = {}) {
21
+ return new PlacesService(options);
22
+ }
23
+ export * from './types.js';
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 네이버 지역 검색 기반 주변 장소 탐색 도구
3
+ */
4
+ import * as z from 'zod';
5
+ import { searchNaverLocalPlaces } from '../client.js';
6
+ async function searchNearbyPlaces(args) {
7
+ const result = await searchNaverLocalPlaces(args);
8
+ return {
9
+ content: [
10
+ {
11
+ type: 'text',
12
+ text: JSON.stringify(result, null, 2),
13
+ },
14
+ ],
15
+ structuredContent: result,
16
+ };
17
+ }
18
+ export function createSearchNearbyPlacesTool(naverClientId, naverClientSecret) {
19
+ return {
20
+ name: 'places_search_nearby',
21
+ metadata: {
22
+ title: '주변 음식점/카페 검색',
23
+ description: '네이버 지역 검색으로 특정 지역의 음식점, 카페, 디저트 가게 등 주변 장소를 키워드 기반으로 찾습니다.',
24
+ inputSchema: {
25
+ location: z.string().optional().describe('지역/역/주소 키워드 (예: 강남역, 성수동)'),
26
+ keyword: z.string().optional().describe('검색어 (예: 라멘, 브런치, 조용한 카페)'),
27
+ category: z
28
+ .enum(['restaurant', 'cafe', 'food', 'dessert', 'all'])
29
+ .optional()
30
+ .default('all')
31
+ .describe('장소 카테고리. restaurant/food=음식점, cafe=카페, dessert=디저트'),
32
+ limit: z
33
+ .number()
34
+ .optional()
35
+ .default(5)
36
+ .describe('반환할 최대 장소 수 (네이버 지역 검색 최대 5)'),
37
+ start: z.number().optional().default(1).describe('검색 시작 위치 (기본값: 1)'),
38
+ sort: z
39
+ .enum(['random', 'comment'])
40
+ .optional()
41
+ .default('random')
42
+ .describe('정렬 방식. random=유사도순, comment=리뷰순'),
43
+ timeoutMs: z
44
+ .number()
45
+ .optional()
46
+ .default(10000)
47
+ .describe('요청 제한 시간(ms, 기본값: 10000)'),
48
+ },
49
+ },
50
+ handler: ((args) => searchNearbyPlaces({
51
+ ...args,
52
+ naverClientId: args.naverClientId || naverClientId,
53
+ naverClientSecret: args.naverClientSecret || naverClientSecret,
54
+ })),
55
+ };
56
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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: {