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.
Files changed (42) hide show
  1. package/README.md +83 -4
  2. package/dist/api/actionsProxy.js +116 -21
  3. package/dist/api/compareHandlers.js +26 -0
  4. package/dist/api/configStatus.js +5 -1
  5. package/dist/api/gs25Handlers.js +45 -10
  6. package/dist/api/healthChecks.js +50 -9
  7. package/dist/api/placesHandlers.js +44 -0
  8. package/dist/api/routes/compareRoutes.js +9 -0
  9. package/dist/api/routes/healthRoutes.js +1 -1
  10. package/dist/api/routes/placesRoutes.js +9 -0
  11. package/dist/cli/commandOptions.js +65 -6
  12. package/dist/cli/commands/convenience.js +38 -0
  13. package/dist/cli.js +5 -1
  14. package/dist/cliHelp.js +25 -6
  15. package/dist/cliRenderer.js +24 -2
  16. package/dist/core/registry.js +33 -2
  17. package/dist/index.js +22 -3
  18. package/dist/pages/openapiFullSpec.js +6 -0
  19. package/dist/pages/openapiSpecActions.js +315 -42
  20. package/dist/pages/openapiSpecPathsCompare.js +59 -0
  21. package/dist/pages/openapiSpecPathsPlaces.js +85 -0
  22. package/dist/pages/promptReferenceText.js +5 -0
  23. package/dist/pages/promptRetailCinemaText.js +41 -3
  24. package/dist/services/compare/client.js +133 -0
  25. package/dist/services/compare/index.js +17 -0
  26. package/dist/services/compare/tools/compareProducts.js +45 -0
  27. package/dist/services/compare/types.js +1 -0
  28. package/dist/services/gs25/api.js +3 -0
  29. package/dist/services/gs25/client.js +211 -35
  30. package/dist/services/gs25/index.js +11 -3
  31. package/dist/services/gs25/tools/checkInventory.js +39 -12
  32. package/dist/services/gs25/tools/findNearbyStores.js +19 -5
  33. package/dist/services/gs25/tools/searchProducts.js +11 -4
  34. package/dist/services/oliveyoung/client.js +90 -41
  35. package/dist/services/places/client.js +116 -0
  36. package/dist/services/places/index.js +23 -0
  37. package/dist/services/places/tools/searchNearby.js +56 -0
  38. package/dist/services/places/types.js +1 -0
  39. package/dist/utils/zyte.js +45 -18
  40. package/openapi.json +29 -0
  41. package/openapi.yaml +20 -0
  42. package/package.json +3 -2
@@ -0,0 +1,133 @@
1
+ import { fetchProducts as fetchDaisoProducts } from '../daiso/tools/searchProducts.js';
2
+ import { fetchGs25SearchProducts } from '../gs25/client.js';
3
+ import { searchSevenElevenProducts } from '../seveneleven/client.js';
4
+ import { searchEmart24Products } from '../emart24/client.js';
5
+ const DEFAULT_COMPARE_SERVICES = ['daiso', 'gs25', 'seveneleven', 'emart24'];
6
+ const SERVICE_NAMES = {
7
+ daiso: '다이소',
8
+ gs25: 'GS25',
9
+ seveneleven: '세븐일레븐',
10
+ emart24: '이마트24',
11
+ };
12
+ function toErrorMessage(error) {
13
+ return error instanceof Error ? error.message : String(error);
14
+ }
15
+ function parseServices(value) {
16
+ if (!value || value.trim().length === 0) {
17
+ return DEFAULT_COMPARE_SERVICES;
18
+ }
19
+ const allowed = new Set(DEFAULT_COMPARE_SERVICES);
20
+ const services = value
21
+ .split(',')
22
+ .map((item) => item.trim().toLowerCase())
23
+ .filter((item) => allowed.has(item));
24
+ return services.length > 0 ? [...new Set(services)] : DEFAULT_COMPARE_SERVICES;
25
+ }
26
+ function normalizePrice(value) {
27
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
28
+ }
29
+ function sortComparableProducts(products) {
30
+ const serviceOrder = new Map(DEFAULT_COMPARE_SERVICES.map((service, index) => [service, index]));
31
+ return [...products].sort((a, b) => {
32
+ if (a.price !== null && b.price !== null && a.price !== b.price) {
33
+ return a.price - b.price;
34
+ }
35
+ if (a.price !== null && b.price === null) {
36
+ return -1;
37
+ }
38
+ if (a.price === null && b.price !== null) {
39
+ return 1;
40
+ }
41
+ return serviceOrder.get(a.service) - serviceOrder.get(b.service);
42
+ });
43
+ }
44
+ async function searchService(service, keyword, limit) {
45
+ if (service === 'daiso') {
46
+ const result = await fetchDaisoProducts(keyword, 1, limit);
47
+ return result.products.map((product) => ({
48
+ service,
49
+ serviceName: SERVICE_NAMES[service],
50
+ code: product.id,
51
+ name: product.name,
52
+ price: normalizePrice(product.price),
53
+ imageUrl: product.imageUrl,
54
+ raw: product,
55
+ }));
56
+ }
57
+ if (service === 'gs25') {
58
+ const products = await fetchGs25SearchProducts(keyword, { timeout: 15000 });
59
+ return products.slice(0, limit).map((product) => ({
60
+ service,
61
+ serviceName: SERVICE_NAMES[service],
62
+ code: product.itemCode,
63
+ name: product.itemName || product.shortItemName,
64
+ price: null,
65
+ imageUrl: product.imageUrl,
66
+ stockCheckEnabled: product.stockCheckEnabled,
67
+ raw: product,
68
+ }));
69
+ }
70
+ if (service === 'seveneleven') {
71
+ const result = await searchSevenElevenProducts({ query: keyword, page: 1, size: limit });
72
+ return result.products.map((product) => ({
73
+ service,
74
+ serviceName: SERVICE_NAMES[service],
75
+ code: product.itemCode || product.productNo,
76
+ name: product.itemName,
77
+ price: normalizePrice(product.salePrice),
78
+ originalPrice: normalizePrice(product.originalPrice),
79
+ imageUrl: product.imageUrl,
80
+ raw: product,
81
+ }));
82
+ }
83
+ const result = await searchEmart24Products({
84
+ keyword,
85
+ page: 1,
86
+ pageSize: limit,
87
+ sortType: 'PRICE_ASC',
88
+ saleProductYn: 'N',
89
+ });
90
+ return result.products.map((product) => ({
91
+ service,
92
+ serviceName: SERVICE_NAMES[service],
93
+ code: product.pluCd,
94
+ name: product.goodsName,
95
+ price: normalizePrice(product.viewPrice),
96
+ originalPrice: normalizePrice(product.originPrice),
97
+ raw: product,
98
+ }));
99
+ }
100
+ export function parseCompareServices(value) {
101
+ return parseServices(value);
102
+ }
103
+ export async function compareProducts(params) {
104
+ const keyword = params.keyword.trim();
105
+ const limit = Math.max(1, Math.min(Math.trunc(params.limit || 5), 20));
106
+ const services = params.services && params.services.length > 0 ? params.services : DEFAULT_COMPARE_SERVICES;
107
+ const settled = await Promise.allSettled(services.map(async (service) => ({
108
+ service,
109
+ products: await searchService(service, keyword, limit),
110
+ })));
111
+ const results = [];
112
+ const errors = [];
113
+ for (const item of settled) {
114
+ if (item.status === 'fulfilled') {
115
+ results.push(...item.value.products);
116
+ }
117
+ else {
118
+ const service = services[settled.indexOf(item)];
119
+ errors.push({ service, message: toErrorMessage(item.reason) });
120
+ }
121
+ }
122
+ const sorted = sortComparableProducts(results);
123
+ return {
124
+ keyword,
125
+ services,
126
+ serviceCount: services.length,
127
+ resultCount: sorted.length,
128
+ bestPrice: sorted.find((product) => product.price !== null) || null,
129
+ results: sorted,
130
+ errors,
131
+ note: '새 외부 키 없이 기존 공개 상품 검색을 묶은 가격 후보입니다. 실제 재고와 행사가는 매장별로 달라질 수 있습니다.',
132
+ };
133
+ }
@@ -0,0 +1,17 @@
1
+ import { createCompareProductsTool } from './tools/compareProducts.js';
2
+ const COMPARE_METADATA = {
3
+ id: 'compare',
4
+ name: '통합 비교',
5
+ version: '1.0.0',
6
+ description: '새 외부 키 없이 기존 상품 검색을 묶어 가격 후보를 비교하는 서비스',
7
+ };
8
+ class CompareService {
9
+ metadata = COMPARE_METADATA;
10
+ getTools() {
11
+ return [createCompareProductsTool()];
12
+ }
13
+ }
14
+ export function createCompareService() {
15
+ return new CompareService();
16
+ }
17
+ export * from './types.js';
@@ -0,0 +1,45 @@
1
+ import * as z from 'zod';
2
+ import { compareProducts, parseCompareServices } from '../client.js';
3
+ async function compareProductsHandler(args) {
4
+ const keyword = args.keyword?.trim();
5
+ if (!keyword) {
6
+ throw new Error('검색어(keyword)를 입력해주세요.');
7
+ }
8
+ const result = await compareProducts({
9
+ keyword,
10
+ limit: args.limit,
11
+ services: parseCompareServices(args.services),
12
+ });
13
+ return {
14
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
15
+ structuredContent: { ...result },
16
+ };
17
+ }
18
+ export function createCompareProductsTool() {
19
+ return {
20
+ name: 'compare_products',
21
+ metadata: {
22
+ title: '가격 후보 통합 비교',
23
+ description: '새 외부 키 없이 다이소, GS25, 세븐일레븐, 이마트24 상품 검색을 묶어 가격 후보를 비교합니다.',
24
+ inputSchema: {
25
+ keyword: z.string().describe('비교할 상품 검색어'),
26
+ limit: z.number().optional().default(5).describe('서비스별 최대 결과 수'),
27
+ services: z
28
+ .string()
29
+ .optional()
30
+ .describe('쉼표로 구분한 서비스 목록(daiso,gs25,seveneleven,emart24)'),
31
+ },
32
+ outputSchema: {
33
+ keyword: z.string().optional(),
34
+ services: z.array(z.string()).optional(),
35
+ serviceCount: z.number().optional(),
36
+ resultCount: z.number().optional(),
37
+ bestPrice: z.unknown().optional(),
38
+ results: z.array(z.unknown()).optional(),
39
+ errors: z.array(z.unknown()).optional(),
40
+ note: z.string().optional(),
41
+ },
42
+ },
43
+ handler: compareProductsHandler,
44
+ };
45
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -6,4 +6,7 @@ export const GS25_API = {
6
6
  STORE_STOCK_PATH: '/api/bff/v2/store/stock',
7
7
  APIGW_BASE_URL: 'https://b2c-apigw.woodongs.com',
8
8
  TOTAL_SEARCH_PATH: '/search/v3/totalSearch',
9
+ WEB_BASE_URL: 'https://gs25.gsretail.com',
10
+ WEB_LOCATIONS_PATH: '/gscvs/ko/store-services/locations',
11
+ WEB_LOCATION_LIST_PATH: '/gscvs/ko/store-services/locationList',
9
12
  };
@@ -2,12 +2,21 @@
2
2
  * GS25 API 클라이언트
3
3
  */
4
4
  /* c8 ignore start */
5
- import { fetchJson } from '../../utils/http.js';
5
+ import { fetchJson, fetchWithTimeout, HttpError } from '../../utils/http.js';
6
+ import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js';
6
7
  import { GS25_API } from './api.js';
7
8
  import { normalizeStore, toNumber } from './storeUtils.js';
8
9
  export { attachDistanceToGs25Stores, calculateDistanceM, extractGs25ProductCandidates, filterGs25StoresByKeyword, selectGs25StoresForKeyword, sortGs25Stores, } from './storeUtils.js';
9
10
  const GS25_DEFAULT_HEADERS = {
10
11
  Accept: 'application/json, text/plain, */*',
12
+ 'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
13
+ 'User-Agent': 'Mozilla/5.0 (Linux; Android 15; SM-S928N) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0 Mobile Safari/537.36',
14
+ Origin: 'https://woodongs.com',
15
+ Referer: 'https://woodongs.com/',
16
+ };
17
+ const GS25_TOTAL_SEARCH_HEADERS = {
18
+ ...GS25_DEFAULT_HEADERS,
19
+ 'Content-Type': 'application/json',
11
20
  };
12
21
  const GS25_DEFAULT_FETCH_OPTIONS = {
13
22
  retries: 1,
@@ -15,6 +24,107 @@ const GS25_DEFAULT_FETCH_OPTIONS = {
15
24
  };
16
25
  const GS25_STORES_CACHE_TTL_MS = 60 * 5 * 1000;
17
26
  const gs25StoresCache = new Map();
27
+ function extractGs25WebCsrfToken(html) {
28
+ return (html.match(/name="CSRFToken"\s+value="([^"]+)"/)?.[1] ||
29
+ html.match(/ACC\.config\.CSRFToken\s*=\s*"([^"]+)"/)?.[1] ||
30
+ null);
31
+ }
32
+ function extractGs25WebCookieHeader(headers) {
33
+ const setCookie = headers.get('set-cookie') || '';
34
+ const jsessionId = setCookie.match(/JSESSIONID=[^;,\s]+/)?.[0];
35
+ return jsessionId;
36
+ }
37
+ function parseGs25WebLocationResponse(body) {
38
+ if (typeof body === 'string') {
39
+ return JSON.parse(body);
40
+ }
41
+ return body;
42
+ }
43
+ function normalizeGs25WebStore(raw) {
44
+ const propertyNames = (raw.offeringService || []).filter((item) => item.trim().length > 0);
45
+ return {
46
+ storeCode: raw.shopCode || '',
47
+ storeName: raw.shopName || '',
48
+ address: raw.address || '',
49
+ phone: '',
50
+ latitude: toNumber(raw.longs),
51
+ longitude: toNumber(raw.lat),
52
+ serviceCode: '01',
53
+ realStockQuantity: 0,
54
+ pickupStockQuantity: 0,
55
+ deliveryStockQuantity: 0,
56
+ isSoldOut: false,
57
+ searchItemName: '',
58
+ searchItemSellPrice: null,
59
+ propertyNames,
60
+ properties: propertyNames.map((name) => ({
61
+ code: name,
62
+ name,
63
+ type: 'service',
64
+ imageUrl: '',
65
+ })),
66
+ distanceM: null,
67
+ };
68
+ }
69
+ async function fetchGs25StoreStock(url, options) {
70
+ try {
71
+ return await fetchJson(url, {
72
+ ...GS25_DEFAULT_FETCH_OPTIONS,
73
+ method: 'GET',
74
+ timeout: options.timeout,
75
+ headers: GS25_DEFAULT_HEADERS,
76
+ });
77
+ }
78
+ catch (error) {
79
+ const zyteApiKey = options.zyteApiKey?.trim();
80
+ if (!(error instanceof HttpError) || error.status !== 403 || !zyteApiKey) {
81
+ throw error;
82
+ }
83
+ const result = await requestByZyte({
84
+ apiKey: zyteApiKey,
85
+ url,
86
+ method: 'GET',
87
+ timeout: options.timeout,
88
+ retries: 1,
89
+ headers: Object.entries(GS25_DEFAULT_HEADERS).map(([name, value]) => ({ name, value })),
90
+ });
91
+ return decodeZyteHttpBody(result);
92
+ }
93
+ }
94
+ async function fetchGs25TotalSearchResponse(query, options) {
95
+ const { timeout = 20000 } = options;
96
+ const endpoint = new URL(GS25_API.TOTAL_SEARCH_PATH, GS25_API.APIGW_BASE_URL);
97
+ const bodyText = JSON.stringify({ query });
98
+ try {
99
+ return await fetchJson(endpoint.toString(), {
100
+ ...GS25_DEFAULT_FETCH_OPTIONS,
101
+ method: 'POST',
102
+ retryUnsafeMethods: true,
103
+ timeout,
104
+ headers: GS25_TOTAL_SEARCH_HEADERS,
105
+ body: bodyText,
106
+ });
107
+ }
108
+ catch (error) {
109
+ const zyteApiKey = options.zyteApiKey?.trim();
110
+ if (!(error instanceof HttpError) || error.status !== 403 || !zyteApiKey) {
111
+ throw error;
112
+ }
113
+ const result = await requestByZyte({
114
+ apiKey: zyteApiKey,
115
+ url: endpoint.toString(),
116
+ method: 'POST',
117
+ timeout,
118
+ retries: 1,
119
+ headers: Object.entries(GS25_TOTAL_SEARCH_HEADERS).map(([name, value]) => ({
120
+ name,
121
+ value,
122
+ })),
123
+ bodyText,
124
+ });
125
+ return decodeZyteHttpBody(result);
126
+ }
127
+ }
18
128
  function buildCacheKey(params) {
19
129
  return [
20
130
  params.serviceCode,
@@ -59,8 +169,94 @@ export async function geocodeGs25Address(address, options = {}) {
59
169
  }
60
170
  return { latitude, longitude };
61
171
  }
172
+ export async function fetchGs25WebStores(keyword, options = {}) {
173
+ const query = keyword.trim();
174
+ if (query.length === 0) {
175
+ return { totalCount: 0, stores: [] };
176
+ }
177
+ const { timeout = 20000 } = options;
178
+ const locationsUrl = new URL(GS25_API.WEB_LOCATIONS_PATH, GS25_API.WEB_BASE_URL);
179
+ const locationsResponse = await fetchWithTimeout(locationsUrl.toString(), {
180
+ ...GS25_DEFAULT_FETCH_OPTIONS,
181
+ method: 'GET',
182
+ timeout,
183
+ headers: {
184
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
185
+ 'Accept-Language': GS25_DEFAULT_HEADERS['Accept-Language'],
186
+ 'User-Agent': GS25_DEFAULT_HEADERS['User-Agent'],
187
+ },
188
+ });
189
+ if (!locationsResponse.ok) {
190
+ throw new HttpError(locationsResponse.status, locationsResponse.statusText, await locationsResponse.text());
191
+ }
192
+ const html = await locationsResponse.text();
193
+ const csrfToken = extractGs25WebCsrfToken(html);
194
+ if (!csrfToken) {
195
+ throw new Error('GS25 웹 매장 검색 CSRF 토큰을 찾을 수 없습니다.');
196
+ }
197
+ const form = new URLSearchParams({
198
+ pageNum: '1',
199
+ pageSize: '50',
200
+ searchShopName: query,
201
+ searchSido: '',
202
+ searchGugun: '',
203
+ searchDong: '',
204
+ searchType: '',
205
+ searchTypeService: '0',
206
+ searchTypeToto: '0',
207
+ searchTypeCafe25: '0',
208
+ searchTypeInstant: '0',
209
+ searchTypeDrug: '0',
210
+ searchTypeSelf25: '0',
211
+ searchTypePost: '0',
212
+ searchTypeATM: '0',
213
+ searchTypeWithdrawal: '0',
214
+ searchTypeTaxrefund: '0',
215
+ searchTypeSmartAtm: '0',
216
+ searchTypeSelfCookingUtensils: '0',
217
+ searchTypeDeliveryService: '0',
218
+ searchTypeParcelService: '0',
219
+ searchTypePotatoes: '0',
220
+ searchTypeCardiacDefi: '0',
221
+ searchTypeFishShapedBun: '0',
222
+ searchTypeWine25: '0',
223
+ searchTypeGoPizza: '0',
224
+ searchTypeSpiritWine: '0',
225
+ searchTypeFreshGanghw: '0',
226
+ searchTypeMusinsa: '0',
227
+ searchTypePosa: '0',
228
+ });
229
+ const locationListUrl = new URL(GS25_API.WEB_LOCATION_LIST_PATH, GS25_API.WEB_BASE_URL);
230
+ locationListUrl.searchParams.set('CSRFToken', csrfToken);
231
+ const cookieHeader = extractGs25WebCookieHeader(locationsResponse.headers);
232
+ const body = parseGs25WebLocationResponse(await fetchJson(locationListUrl.toString(), {
233
+ ...GS25_DEFAULT_FETCH_OPTIONS,
234
+ method: 'POST',
235
+ retryUnsafeMethods: true,
236
+ timeout,
237
+ headers: {
238
+ Accept: 'application/json, text/javascript, */*; q=0.01',
239
+ 'Accept-Language': GS25_DEFAULT_HEADERS['Accept-Language'],
240
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
241
+ Referer: locationsUrl.toString(),
242
+ 'User-Agent': GS25_DEFAULT_HEADERS['User-Agent'],
243
+ 'X-Requested-With': 'XMLHttpRequest',
244
+ ...(cookieHeader ? { Cookie: cookieHeader } : {}),
245
+ },
246
+ body: form.toString(),
247
+ }));
248
+ const stores = (body.results || [])
249
+ .map(normalizeGs25WebStore)
250
+ .filter((store) => store.storeCode.length > 0);
251
+ return {
252
+ totalCount: typeof body.pagination?.totalNumberOfResults === 'number'
253
+ ? body.pagination.totalNumberOfResults
254
+ : stores.length,
255
+ stores,
256
+ };
257
+ }
62
258
  export async function fetchGs25Stores(params = {}, options = {}) {
63
- const { serviceCode = '01', keyword = '', itemCode = '', storeCode = '', realTimeStockYn = 'Y', latitude, longitude, pageNumber = 0, pageCount = 20000, useCache = true, } = params;
259
+ const { serviceCode = '01', keyword = '', itemCode = '', storeCode = '', realTimeStockYn = 'Y', latitude, longitude, pageNumber, pageCount, useCache = true, } = params;
64
260
  const { timeout = 20000 } = options;
65
261
  const cacheKey = buildCacheKey({
66
262
  serviceCode,
@@ -82,9 +278,13 @@ export async function fetchGs25Stores(params = {}, options = {}) {
82
278
  }
83
279
  const endpoint = new URL(GS25_API.STORE_STOCK_PATH, GS25_API.BFF_BASE_URL);
84
280
  endpoint.searchParams.set('serviceCode', serviceCode);
85
- endpoint.searchParams.set('pageNumber', String(pageNumber));
86
- endpoint.searchParams.set('pageCount', String(pageCount));
87
281
  endpoint.searchParams.set('realTimeStockYn', realTimeStockYn);
282
+ if (typeof pageNumber === 'number' && Number.isFinite(pageNumber)) {
283
+ endpoint.searchParams.set('pageNumber', String(Math.trunc(pageNumber)));
284
+ }
285
+ if (typeof pageCount === 'number' && Number.isFinite(pageCount)) {
286
+ endpoint.searchParams.set('pageCount', String(Math.trunc(pageCount)));
287
+ }
88
288
  // itemCode가 있으면 itemCode 사용, 없으면 keyword 사용
89
289
  // 주의: itemCode 사용 시 좌표도 필수
90
290
  if (itemCode.trim().length > 0) {
@@ -113,13 +313,13 @@ export async function fetchGs25Stores(params = {}, options = {}) {
113
313
  endpoint.searchParams.set('isSuperDlvyStoreSelected', 'N');
114
314
  endpoint.searchParams.set('isGs25DlvyStoreSelected', 'N');
115
315
  }
116
- const body = await fetchJson(endpoint.toString(), {
117
- ...GS25_DEFAULT_FETCH_OPTIONS,
118
- method: 'GET',
316
+ const body = await fetchGs25StoreStock(endpoint.toString(), {
119
317
  timeout,
120
- headers: GS25_DEFAULT_HEADERS,
318
+ zyteApiKey: options.zyteApiKey,
121
319
  });
122
- const stores = (body.stores || []).map(normalizeStore).filter((store) => store.storeCode.length > 0);
320
+ const stores = (body.stores || [])
321
+ .map(normalizeStore)
322
+ .filter((store) => store.storeCode.length > 0);
123
323
  if (useCache) {
124
324
  gs25StoresCache.set(cacheKey, {
125
325
  stores,
@@ -137,19 +337,7 @@ export async function fetchGs25NormalizedKeyword(keyword, options = {}) {
137
337
  if (query.length === 0) {
138
338
  return null;
139
339
  }
140
- const { timeout = 20000 } = options;
141
- const endpoint = new URL(GS25_API.TOTAL_SEARCH_PATH, GS25_API.APIGW_BASE_URL);
142
- const body = await fetchJson(endpoint.toString(), {
143
- ...GS25_DEFAULT_FETCH_OPTIONS,
144
- method: 'POST',
145
- retryUnsafeMethods: true,
146
- timeout,
147
- headers: {
148
- Accept: 'application/json, text/plain, */*',
149
- 'Content-Type': 'application/json',
150
- },
151
- body: JSON.stringify({ query }),
152
- });
340
+ const body = await fetchGs25TotalSearchResponse(query, options);
153
341
  const normalizedKeyword = body.SearchQueryResult?.keywordInfo?.keyword?.trim() || '';
154
342
  const normalizedSearchKeyword = body.SearchQueryResult?.keywordInfo?.searchKeyword?.trim() || '';
155
343
  if (normalizedKeyword.length === 0 && normalizedSearchKeyword.length === 0) {
@@ -169,19 +357,7 @@ export async function fetchGs25SearchProducts(keyword, options = {}) {
169
357
  if (query.length === 0) {
170
358
  return [];
171
359
  }
172
- const { timeout = 20000 } = options;
173
- const endpoint = new URL(GS25_API.TOTAL_SEARCH_PATH, GS25_API.APIGW_BASE_URL);
174
- const body = await fetchJson(endpoint.toString(), {
175
- ...GS25_DEFAULT_FETCH_OPTIONS,
176
- method: 'POST',
177
- retryUnsafeMethods: true,
178
- timeout,
179
- headers: {
180
- Accept: 'application/json, text/plain, */*',
181
- 'Content-Type': 'application/json',
182
- },
183
- body: JSON.stringify({ query }),
184
- });
360
+ const body = await fetchGs25TotalSearchResponse(query, options);
185
361
  const products = [];
186
362
  const collections = body.SearchQueryResult?.Collection || [];
187
363
  for (const collection of collections) {
@@ -11,12 +11,20 @@ const GS25_METADATA = {
11
11
  description: 'GS25 매장 탐색, 상품 키워드 검색, 재고 조회 서비스',
12
12
  };
13
13
  class Gs25Service {
14
+ options;
14
15
  metadata = GS25_METADATA;
16
+ constructor(options = {}) {
17
+ this.options = options;
18
+ }
15
19
  getTools() {
16
- return [createFindNearbyStoresTool(), createSearchProductsTool(), createCheckInventoryTool()];
20
+ return [
21
+ createFindNearbyStoresTool(this.options.googleMapsApiKey, this.options.zyteApiKey),
22
+ createSearchProductsTool(this.options.zyteApiKey),
23
+ createCheckInventoryTool(this.options.googleMapsApiKey, this.options.zyteApiKey),
24
+ ];
17
25
  }
18
26
  }
19
- export function createGs25Service() {
20
- return new Gs25Service();
27
+ export function createGs25Service(options = {}) {
28
+ return new Gs25Service(options);
21
29
  }
22
30
  export * from './types.js';
@@ -4,8 +4,11 @@
4
4
  /* c8 ignore start */
5
5
  import * as z from 'zod';
6
6
  import { attachDistanceToGs25Stores, fetchGs25SearchProducts, fetchGs25Stores, filterGs25StoresByKeyword, geocodeGs25Address, selectGs25StoresForKeyword, sortGs25Stores, } from '../client.js';
7
+ function getProcessEnvValue(name) {
8
+ return typeof process !== 'undefined' ? process.env[name] : undefined;
9
+ }
7
10
  async function checkInventory(args) {
8
- const { keyword, itemCode: providedItemCode, latitude, longitude, storeKeyword = '', serviceCode = '01', storeLimit = 20, timeoutMs = 20000, } = args;
11
+ const { keyword, itemCode: providedItemCode, latitude, longitude, storeKeyword = '', serviceCode = '01', storeLimit = 20, timeoutMs = 20000, googleMapsApiKey = getProcessEnvValue('GOOGLE_MAPS_API_KEY'), zyteApiKey = getProcessEnvValue('ZYTE_API_KEY'), } = args;
9
12
  if (!keyword || keyword.trim().length === 0) {
10
13
  throw new Error('상품 검색어(keyword)를 입력해주세요.');
11
14
  }
@@ -21,13 +24,13 @@ async function checkInventory(args) {
21
24
  serviceCode,
22
25
  }, {
23
26
  timeout: timeoutMs,
27
+ zyteApiKey,
24
28
  });
25
- const firstAddress = filterGs25StoresByKeyword(baseStores.stores, storeKeyword).find((store) => store.address.trim().length > 0)
26
- ?.address || '';
29
+ const firstAddress = filterGs25StoresByKeyword(baseStores.stores, storeKeyword).find((store) => store.address.trim().length > 0)?.address || '';
27
30
  if (firstAddress.length > 0) {
28
31
  const geocoded = await geocodeGs25Address(firstAddress, {
29
32
  timeout: timeoutMs,
30
- googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
33
+ googleMapsApiKey,
31
34
  });
32
35
  if (geocoded) {
33
36
  resolvedLatitude = geocoded.latitude;
@@ -57,11 +60,15 @@ async function checkInventory(args) {
57
60
  useCache: false,
58
61
  }, {
59
62
  timeout: timeoutMs,
63
+ zyteApiKey,
60
64
  });
61
65
  }
62
66
  else {
63
67
  // itemCode가 없으면 totalSearch API로 키워드 → itemCode 변환
64
- const searchProducts = await fetchGs25SearchProducts(keyword, { timeout: timeoutMs });
68
+ const searchProducts = await fetchGs25SearchProducts(keyword, {
69
+ timeout: timeoutMs,
70
+ zyteApiKey,
71
+ });
65
72
  firstProduct = searchProducts.find((p) => p.itemCode.length > 0);
66
73
  if (firstProduct) {
67
74
  resolvedItemCode = firstProduct.itemCode;
@@ -75,6 +82,7 @@ async function checkInventory(args) {
75
82
  useCache: false,
76
83
  }, {
77
84
  timeout: timeoutMs,
85
+ zyteApiKey,
78
86
  });
79
87
  }
80
88
  else {
@@ -88,6 +96,7 @@ async function checkInventory(args) {
88
96
  useCache: false,
89
97
  }, {
90
98
  timeout: timeoutMs,
99
+ zyteApiKey,
91
100
  });
92
101
  }
93
102
  }
@@ -101,7 +110,8 @@ async function checkInventory(args) {
101
110
  const totalStockQuantity = filteredByStoreKeyword.reduce((sum, item) => sum + Math.max(item.realStockQuantity, 0), 0);
102
111
  // searchProducts에서 상품 정보 사용
103
112
  const firstProductName = firstProduct?.itemName || firstProduct?.shortItemName || null;
104
- const firstProductPrice = filteredByStoreKeyword.find((item) => item.searchItemSellPrice !== null)?.searchItemSellPrice ?? null;
113
+ const firstProductPrice = filteredByStoreKeyword.find((item) => item.searchItemSellPrice !== null)?.searchItemSellPrice ??
114
+ null;
105
115
  return {
106
116
  content: [
107
117
  {
@@ -137,7 +147,7 @@ async function checkInventory(args) {
137
147
  ],
138
148
  };
139
149
  }
140
- export function createCheckInventoryTool() {
150
+ export function createCheckInventoryTool(googleMapsApiKey, zyteApiKey) {
141
151
  return {
142
152
  name: 'gs25_check_inventory',
143
153
  metadata: {
@@ -149,15 +159,32 @@ export function createCheckInventoryTool() {
149
159
  .string()
150
160
  .optional()
151
161
  .describe('상품 코드 (gs25_search_products로 조회한 정확한 코드, 제공 시 keyword 검색 건너뜀)'),
152
- latitude: z.number().optional().describe('위도 (선택, 미입력 시 storeKeyword 지오코딩 시도)'),
153
- longitude: z.number().optional().describe('경도 (선택, 미입력 시 storeKeyword 지오코딩 시도)'),
154
- storeKeyword: z.string().optional().describe('매장명/주소 키워드 필터 (예: 강남, 안산 중앙역)'),
162
+ latitude: z
163
+ .number()
164
+ .optional()
165
+ .describe('위도 (선택, 미입력 시 storeKeyword 지오코딩 시도)'),
166
+ longitude: z
167
+ .number()
168
+ .optional()
169
+ .describe('경도 (선택, 미입력 시 storeKeyword 지오코딩 시도)'),
170
+ storeKeyword: z
171
+ .string()
172
+ .optional()
173
+ .describe('매장명/주소 키워드 필터 (예: 강남, 안산 중앙역)'),
155
174
  serviceCode: z.string().optional().default('01').describe('서비스 코드 (기본값: 01=GS25)'),
156
175
  storeLimit: z.number().optional().default(20).describe('반환할 매장 수 (기본값: 20)'),
157
- timeoutMs: z.number().optional().default(20000).describe('요청 제한 시간(ms, 기본값: 20000)'),
176
+ timeoutMs: z
177
+ .number()
178
+ .optional()
179
+ .default(20000)
180
+ .describe('요청 제한 시간(ms, 기본값: 20000)'),
158
181
  },
159
182
  },
160
- handler: checkInventory,
183
+ handler: ((args) => checkInventory({
184
+ ...args,
185
+ googleMapsApiKey: args.googleMapsApiKey || googleMapsApiKey,
186
+ zyteApiKey: args.zyteApiKey || zyteApiKey,
187
+ })),
161
188
  };
162
189
  }
163
190
  /* c8 ignore stop */