daiso 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -4
- package/dist/api/actionsProxy.js +116 -21
- package/dist/api/compareHandlers.js +26 -0
- package/dist/api/configStatus.js +5 -1
- package/dist/api/gs25Handlers.js +45 -10
- package/dist/api/healthChecks.js +50 -9
- package/dist/api/placesHandlers.js +44 -0
- package/dist/api/routes/compareRoutes.js +9 -0
- package/dist/api/routes/healthRoutes.js +1 -1
- package/dist/api/routes/placesRoutes.js +9 -0
- package/dist/cli/commandOptions.js +65 -6
- package/dist/cli/commands/convenience.js +38 -0
- package/dist/cli.js +5 -1
- package/dist/cliHelp.js +25 -6
- package/dist/cliRenderer.js +24 -2
- package/dist/core/registry.js +33 -2
- package/dist/index.js +22 -3
- package/dist/pages/openapiFullSpec.js +6 -0
- package/dist/pages/openapiSpecActions.js +315 -42
- package/dist/pages/openapiSpecPathsCompare.js +59 -0
- package/dist/pages/openapiSpecPathsPlaces.js +85 -0
- package/dist/pages/promptReferenceText.js +5 -0
- package/dist/pages/promptRetailCinemaText.js +41 -3
- package/dist/services/compare/client.js +133 -0
- package/dist/services/compare/index.js +17 -0
- package/dist/services/compare/tools/compareProducts.js +45 -0
- package/dist/services/compare/types.js +1 -0
- package/dist/services/gs25/api.js +3 -0
- package/dist/services/gs25/client.js +211 -35
- package/dist/services/gs25/index.js +11 -3
- package/dist/services/gs25/tools/checkInventory.js +39 -12
- package/dist/services/gs25/tools/findNearbyStores.js +19 -5
- package/dist/services/gs25/tools/searchProducts.js +11 -4
- package/dist/services/oliveyoung/client.js +90 -41
- package/dist/services/places/client.js +116 -0
- package/dist/services/places/index.js +23 -0
- package/dist/services/places/tools/searchNearby.js +56 -0
- package/dist/services/places/types.js +1 -0
- package/dist/utils/zyte.js +45 -18
- package/openapi.json +29 -0
- package/openapi.yaml +20 -0
- package/package.json +3 -2
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { attachDistanceToGs25Stores, fetchGs25Stores, geocodeGs25Address, selectGs25StoresForKeyword, sortGs25Stores, } from '../client.js';
|
|
6
6
|
const FALLBACK_STORE_LOOKUP_ITEM_CODE = '8801117752804';
|
|
7
|
+
function getProcessEnvValue(name) {
|
|
8
|
+
/* c8 ignore next -- Node 테스트와 CLI 런타임은 process를 제공하며, Worker에는 API에서 키를 주입합니다. */
|
|
9
|
+
return typeof process !== 'undefined' ? process.env[name] : undefined;
|
|
10
|
+
}
|
|
7
11
|
async function findNearbyStores(args) {
|
|
8
|
-
const { latitude, longitude, keyword = '', serviceCode = '01', limit = 20, timeoutMs = 20000, } = args;
|
|
12
|
+
const { latitude, longitude, keyword = '', serviceCode = '01', limit = 20, timeoutMs = 20000, googleMapsApiKey = getProcessEnvValue('GOOGLE_MAPS_API_KEY'), zyteApiKey = getProcessEnvValue('ZYTE_API_KEY'), } = args;
|
|
9
13
|
let resolvedLatitude = latitude;
|
|
10
14
|
let resolvedLongitude = longitude;
|
|
11
15
|
let geocodeUsed = false;
|
|
@@ -13,7 +17,7 @@ async function findNearbyStores(args) {
|
|
|
13
17
|
keyword.trim().length > 0) {
|
|
14
18
|
const geocoded = await geocodeGs25Address(keyword, {
|
|
15
19
|
timeout: timeoutMs,
|
|
16
|
-
googleMapsApiKey
|
|
20
|
+
googleMapsApiKey,
|
|
17
21
|
});
|
|
18
22
|
if (geocoded) {
|
|
19
23
|
resolvedLatitude = geocoded.latitude;
|
|
@@ -27,6 +31,7 @@ async function findNearbyStores(args) {
|
|
|
27
31
|
longitude: resolvedLongitude,
|
|
28
32
|
}, {
|
|
29
33
|
timeout: timeoutMs,
|
|
34
|
+
zyteApiKey,
|
|
30
35
|
});
|
|
31
36
|
let fallbackUsed = false;
|
|
32
37
|
if (result.stores.length === 0 &&
|
|
@@ -42,6 +47,7 @@ async function findNearbyStores(args) {
|
|
|
42
47
|
useCache: false,
|
|
43
48
|
}, {
|
|
44
49
|
timeout: timeoutMs,
|
|
50
|
+
zyteApiKey,
|
|
45
51
|
});
|
|
46
52
|
if (fallbackResult.stores.length > 0) {
|
|
47
53
|
result = fallbackResult;
|
|
@@ -83,7 +89,7 @@ async function findNearbyStores(args) {
|
|
|
83
89
|
],
|
|
84
90
|
};
|
|
85
91
|
}
|
|
86
|
-
export function createFindNearbyStoresTool() {
|
|
92
|
+
export function createFindNearbyStoresTool(googleMapsApiKey, zyteApiKey) {
|
|
87
93
|
return {
|
|
88
94
|
name: 'gs25_find_nearby_stores',
|
|
89
95
|
metadata: {
|
|
@@ -95,9 +101,17 @@ export function createFindNearbyStoresTool() {
|
|
|
95
101
|
keyword: z.string().optional().describe('매장명/주소 키워드 (예: 강남, 안산 중앙역)'),
|
|
96
102
|
serviceCode: z.string().optional().default('01').describe('서비스 코드 (기본값: 01=GS25)'),
|
|
97
103
|
limit: z.number().optional().default(20).describe('반환할 최대 매장 수 (기본값: 20)'),
|
|
98
|
-
timeoutMs: z
|
|
104
|
+
timeoutMs: z
|
|
105
|
+
.number()
|
|
106
|
+
.optional()
|
|
107
|
+
.default(20000)
|
|
108
|
+
.describe('요청 제한 시간(ms, 기본값: 20000)'),
|
|
99
109
|
},
|
|
100
110
|
},
|
|
101
|
-
handler: findNearbyStores
|
|
111
|
+
handler: ((args) => findNearbyStores({
|
|
112
|
+
...args,
|
|
113
|
+
googleMapsApiKey: args.googleMapsApiKey || googleMapsApiKey,
|
|
114
|
+
zyteApiKey: args.zyteApiKey || zyteApiKey,
|
|
115
|
+
})),
|
|
102
116
|
};
|
|
103
117
|
}
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { fetchGs25SearchProducts } from '../client.js';
|
|
6
|
+
function getProcessEnvValue(name) {
|
|
7
|
+
if (typeof process !== 'undefined') {
|
|
8
|
+
return process.env[name];
|
|
9
|
+
}
|
|
10
|
+
/* c8 ignore next -- process가 없는 런타임 방어 분기 */
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
6
13
|
function buildTextResponse(payload) {
|
|
7
14
|
return {
|
|
8
15
|
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
@@ -10,11 +17,11 @@ function buildTextResponse(payload) {
|
|
|
10
17
|
};
|
|
11
18
|
}
|
|
12
19
|
async function searchProducts(args) {
|
|
13
|
-
const { keyword, limit = 20, timeoutMs = 20000 } = args;
|
|
20
|
+
const { keyword, limit = 20, timeoutMs = 20000, zyteApiKey = getProcessEnvValue('ZYTE_API_KEY'), } = args;
|
|
14
21
|
if (!keyword || keyword.trim().length === 0) {
|
|
15
22
|
throw new Error('상품 검색어(keyword)를 입력해주세요.');
|
|
16
23
|
}
|
|
17
|
-
const products = await fetchGs25SearchProducts(keyword, { timeout: timeoutMs });
|
|
24
|
+
const products = await fetchGs25SearchProducts(keyword, { timeout: timeoutMs, zyteApiKey });
|
|
18
25
|
const limitedProducts = products.slice(0, limit);
|
|
19
26
|
const payload = {
|
|
20
27
|
keyword,
|
|
@@ -39,7 +46,7 @@ const searchProductsOutputSchema = {
|
|
|
39
46
|
products: z.array(z.unknown()).describe('GS25 상품 검색 결과'),
|
|
40
47
|
note: z.string().describe('후속 재고 조회 안내'),
|
|
41
48
|
};
|
|
42
|
-
export function createSearchProductsTool() {
|
|
49
|
+
export function createSearchProductsTool(zyteApiKey) {
|
|
43
50
|
return {
|
|
44
51
|
name: 'gs25_search_products',
|
|
45
52
|
metadata: {
|
|
@@ -52,6 +59,6 @@ export function createSearchProductsTool() {
|
|
|
52
59
|
},
|
|
53
60
|
outputSchema: searchProductsOutputSchema,
|
|
54
61
|
},
|
|
55
|
-
handler: searchProducts,
|
|
62
|
+
handler: ((args) => searchProducts({ ...args, zyteApiKey: zyteApiKey ?? args.zyteApiKey })),
|
|
56
63
|
};
|
|
57
64
|
}
|
|
@@ -10,6 +10,8 @@ const OLIVEYOUNG_PRODUCT_ID_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
|
10
10
|
const OLIVEYOUNG_PRODUCT_SEARCH_STALE_CACHE_TTL_MS = 30 * 60 * 1000;
|
|
11
11
|
const oliveyoungProductIdCache = new Map();
|
|
12
12
|
const oliveyoungProductSearchCache = new Map();
|
|
13
|
+
const oliveyoungStoreSearchCache = new Map();
|
|
14
|
+
const oliveyoungStockStoresCache = new Map();
|
|
13
15
|
const OLIVEYOUNG_IMAGE_PATH_PREFIX = '/uploads/images/goods';
|
|
14
16
|
function resolveOliveyoungInStock(o2oStockFlag, o2oRemainQuantity) {
|
|
15
17
|
return o2oStockFlag || o2oRemainQuantity > 0;
|
|
@@ -67,6 +69,12 @@ function resolveOliveyoungStoreStock(rawStore) {
|
|
|
67
69
|
function createOliveyoungProductSearchCacheKey(params) {
|
|
68
70
|
return JSON.stringify([params.keyword, params.page, params.size, params.sort, params.includeSoldOut]);
|
|
69
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
|
+
}
|
|
70
78
|
function cloneOliveyoungProductSearchResult(result) {
|
|
71
79
|
return {
|
|
72
80
|
totalCount: result.totalCount,
|
|
@@ -74,17 +82,32 @@ function cloneOliveyoungProductSearchResult(result) {
|
|
|
74
82
|
products: result.products.map((product) => ({ ...product })),
|
|
75
83
|
};
|
|
76
84
|
}
|
|
77
|
-
function
|
|
78
|
-
|
|
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);
|
|
79
102
|
if (!cached || cached.expiresAt <= Date.now()) {
|
|
80
103
|
return null;
|
|
81
104
|
}
|
|
82
|
-
return
|
|
105
|
+
return cloneResult(cached.result);
|
|
83
106
|
}
|
|
84
|
-
function
|
|
85
|
-
|
|
107
|
+
function writeStaleResult(cache, cacheKey, result, cloneResult) {
|
|
108
|
+
cache.set(cacheKey, {
|
|
86
109
|
expiresAt: Date.now() + OLIVEYOUNG_PRODUCT_SEARCH_STALE_CACHE_TTL_MS,
|
|
87
|
-
result:
|
|
110
|
+
result: cloneResult(result),
|
|
88
111
|
});
|
|
89
112
|
}
|
|
90
113
|
async function zyteExtract(targetPath, requestBody, options = {}) {
|
|
@@ -130,20 +153,32 @@ export async function fetchOliveyoungStores(params, options = {}) {
|
|
|
130
153
|
mapLat: params.latitude,
|
|
131
154
|
mapLon: params.longitude,
|
|
132
155
|
};
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
+
}
|
|
147
182
|
}
|
|
148
183
|
export async function fetchOliveyoungProducts(params, options = {}) {
|
|
149
184
|
const payload = {
|
|
@@ -182,11 +217,11 @@ export async function fetchOliveyoungProducts(params, options = {}) {
|
|
|
182
217
|
nextPage: Boolean(body.data?.nextPage),
|
|
183
218
|
products,
|
|
184
219
|
};
|
|
185
|
-
|
|
220
|
+
writeStaleResult(oliveyoungProductSearchCache, cacheKey, result, cloneOliveyoungProductSearchResult);
|
|
186
221
|
return result;
|
|
187
222
|
}
|
|
188
223
|
catch (error) {
|
|
189
|
-
const staleResult =
|
|
224
|
+
const staleResult = readStaleResult(oliveyoungProductSearchCache, cacheKey, cloneOliveyoungProductSearchResult);
|
|
190
225
|
if (staleResult) {
|
|
191
226
|
return staleResult;
|
|
192
227
|
}
|
|
@@ -214,25 +249,37 @@ async function fetchOliveyoungProductId(goodsNumber, options = {}) {
|
|
|
214
249
|
return productId;
|
|
215
250
|
}
|
|
216
251
|
async function fetchOliveyoungStockStores(params, options = {}) {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
+
}
|
|
236
283
|
}
|
|
237
284
|
function sortOliveyoungProducts(products) {
|
|
238
285
|
const score = (product) => {
|
|
@@ -299,4 +346,6 @@ export async function enrichOliveyoungProductsWithNearbyStoreInventory(products,
|
|
|
299
346
|
export function __testOnlyClearOliveyoungCaches() {
|
|
300
347
|
oliveyoungProductIdCache.clear();
|
|
301
348
|
oliveyoungProductSearchCache.clear();
|
|
349
|
+
oliveyoungStoreSearchCache.clear();
|
|
350
|
+
oliveyoungStockStoresCache.clear();
|
|
302
351
|
}
|
|
@@ -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(/&/g, '&')
|
|
19
|
+
.replace(/</g, '<')
|
|
20
|
+
.replace(/>/g, '>')
|
|
21
|
+
.replace(/"/g, '"')
|
|
22
|
+
.replace(/'/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 {};
|
package/dist/utils/zyte.js
CHANGED
|
@@ -40,6 +40,17 @@ export function decodeBase64(value) {
|
|
|
40
40
|
function isAbortError(error) {
|
|
41
41
|
return error instanceof Error && error.name === 'AbortError';
|
|
42
42
|
}
|
|
43
|
+
class RetryableZyteError extends Error {
|
|
44
|
+
}
|
|
45
|
+
function isRetryableZyteStatus(status) {
|
|
46
|
+
return status === 408 || status === 429 || status >= 500;
|
|
47
|
+
}
|
|
48
|
+
function isRetryableTargetStatus(statusCode) {
|
|
49
|
+
return typeof statusCode === 'number' && statusCode >= 500;
|
|
50
|
+
}
|
|
51
|
+
function isRetryableError(error) {
|
|
52
|
+
return isAbortError(error) || error instanceof RetryableZyteError;
|
|
53
|
+
}
|
|
43
54
|
function wait(ms) {
|
|
44
55
|
if (ms <= 0) {
|
|
45
56
|
return Promise.resolve();
|
|
@@ -55,30 +66,46 @@ export async function requestByZyte(options) {
|
|
|
55
66
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
56
67
|
const { controller, timeoutId } = createTimeoutController(timeout);
|
|
57
68
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
let response;
|
|
70
|
+
try {
|
|
71
|
+
response = await fetch('https://api.zyte.com/v1/extract', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
Authorization: `Basic ${auth}`,
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
Accept: 'application/json',
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
url,
|
|
80
|
+
httpRequestMethod: method,
|
|
81
|
+
customHttpRequestHeaders: headers,
|
|
82
|
+
httpRequestText: bodyText,
|
|
83
|
+
httpResponseBody: true,
|
|
84
|
+
}),
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error instanceof TypeError) {
|
|
90
|
+
throw new RetryableZyteError(error.message);
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
74
94
|
const result = (await response.json());
|
|
75
95
|
if (!response.ok) {
|
|
76
|
-
|
|
96
|
+
const message = `Zyte API 호출 실패: ${response.status} ${result.detail || result.title || ''}`.trim();
|
|
97
|
+
if (isRetryableZyteStatus(response.status)) {
|
|
98
|
+
throw new RetryableZyteError(message);
|
|
99
|
+
}
|
|
100
|
+
throw new Error(message);
|
|
101
|
+
}
|
|
102
|
+
if (isRetryableTargetStatus(result.statusCode) && attempt < maxAttempts) {
|
|
103
|
+
throw new RetryableZyteError(`Zyte target response failed: ${result.statusCode}`);
|
|
77
104
|
}
|
|
78
105
|
return result;
|
|
79
106
|
}
|
|
80
107
|
catch (error) {
|
|
81
|
-
if (!
|
|
108
|
+
if (!isRetryableError(error) || attempt >= maxAttempts) {
|
|
82
109
|
throw error;
|
|
83
110
|
}
|
|
84
111
|
await wait(retryDelayMs);
|
package/openapi.json
CHANGED
|
@@ -53,6 +53,8 @@
|
|
|
53
53
|
"sevenelevenCheckInventory",
|
|
54
54
|
"sevenelevenGetPopwords",
|
|
55
55
|
"sevenelevenGetCatalog",
|
|
56
|
+
"compareProducts",
|
|
57
|
+
"placesSearchNearby",
|
|
56
58
|
"megaboxFindTheaters",
|
|
57
59
|
"megaboxListMovies",
|
|
58
60
|
"megaboxGetSeats",
|
|
@@ -110,6 +112,33 @@
|
|
|
110
112
|
"type": "string"
|
|
111
113
|
}
|
|
112
114
|
},
|
|
115
|
+
{
|
|
116
|
+
"name": "location",
|
|
117
|
+
"in": "query",
|
|
118
|
+
"required": false,
|
|
119
|
+
"description": "주변 장소 검색 지역/역/주소 키워드",
|
|
120
|
+
"schema": {
|
|
121
|
+
"type": "string"
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"name": "category",
|
|
126
|
+
"in": "query",
|
|
127
|
+
"required": false,
|
|
128
|
+
"description": "주변 장소 카테고리(restaurant, cafe, food, dessert, all)",
|
|
129
|
+
"schema": {
|
|
130
|
+
"type": "string"
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"name": "services",
|
|
135
|
+
"in": "query",
|
|
136
|
+
"required": false,
|
|
137
|
+
"description": "통합 비교 서비스 목록(daiso,gs25,seveneleven,emart24)",
|
|
138
|
+
"schema": {
|
|
139
|
+
"type": "string"
|
|
140
|
+
}
|
|
141
|
+
},
|
|
113
142
|
{
|
|
114
143
|
"name": "sido",
|
|
115
144
|
"in": "query",
|
package/openapi.yaml
CHANGED
|
@@ -54,6 +54,8 @@ paths:
|
|
|
54
54
|
- sevenelevenCheckInventory
|
|
55
55
|
- sevenelevenGetPopwords
|
|
56
56
|
- sevenelevenGetCatalog
|
|
57
|
+
- compareProducts
|
|
58
|
+
- placesSearchNearby
|
|
57
59
|
- megaboxFindTheaters
|
|
58
60
|
- megaboxListMovies
|
|
59
61
|
- megaboxGetSeats
|
|
@@ -93,6 +95,24 @@ paths:
|
|
|
93
95
|
description: 공통 키워드 파라미터
|
|
94
96
|
schema:
|
|
95
97
|
type: string
|
|
98
|
+
- name: location
|
|
99
|
+
in: query
|
|
100
|
+
required: false
|
|
101
|
+
description: 주변 장소 검색 지역/역/주소 키워드
|
|
102
|
+
schema:
|
|
103
|
+
type: string
|
|
104
|
+
- name: category
|
|
105
|
+
in: query
|
|
106
|
+
required: false
|
|
107
|
+
description: 주변 장소 카테고리(restaurant, cafe, food, dessert, all)
|
|
108
|
+
schema:
|
|
109
|
+
type: string
|
|
110
|
+
- name: services
|
|
111
|
+
in: query
|
|
112
|
+
required: false
|
|
113
|
+
description: 통합 비교 서비스 목록(daiso,gs25,seveneleven,emart24)
|
|
114
|
+
schema:
|
|
115
|
+
type: string
|
|
96
116
|
- name: sido
|
|
97
117
|
in: query
|
|
98
118
|
required: false
|