daiso 1.0.10 → 1.2.0

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 (88) hide show
  1. package/README.md +77 -5
  2. package/dist/api/actionsProxy.js +3 -0
  3. package/dist/api/cgvHandlers.js +15 -3
  4. package/dist/api/configStatus.js +2 -1
  5. package/dist/api/dtryxHandlers.js +113 -0
  6. package/dist/api/emart24Handlers.js +6 -2
  7. package/dist/api/gs25Handlers.js +22 -19
  8. package/dist/api/handlers.js +13 -5
  9. package/dist/api/healthCheckDefinitions.js +46 -16
  10. package/dist/api/healthCheckShape.js +24 -8
  11. package/dist/api/healthChecks.js +39 -20
  12. package/dist/api/operationalAuth.js +32 -0
  13. package/dist/api/routes/dtryxRoutes.js +22 -0
  14. package/dist/api/routes/healthRoutes.js +19 -11
  15. package/dist/api/routes/rateLimitStatsRoutes.js +274 -0
  16. package/dist/api/routes/registerServiceRoutes.js +37 -0
  17. package/dist/api/sevenelevenHandlers.js +7 -3
  18. package/dist/cli/commandOptions.js +24 -0
  19. package/dist/cli/commands/cinema.js +27 -0
  20. package/dist/cli.js +7 -1
  21. package/dist/cliHelp.js +3 -337
  22. package/dist/cliHelpDefinitions.js +369 -0
  23. package/dist/cliInteractive.js +8 -5
  24. package/dist/core/errors.js +29 -2
  25. package/dist/core/registry.js +13 -15
  26. package/dist/durableObjects/dailyRateLimiter.js +185 -0
  27. package/dist/durableObjects/rateLimitMetricsStore.js +144 -0
  28. package/dist/index.js +18 -31
  29. package/dist/middleware/dailyRateLimit.js +176 -0
  30. package/dist/pages/openapiFullSpec.js +2 -0
  31. package/dist/pages/openapiSpecActionParameters.js +429 -0
  32. package/dist/pages/openapiSpecActions.js +1 -391
  33. package/dist/pages/openapiSpecComponentsLottecinema.js +2 -2
  34. package/dist/pages/openapiSpecPathsDtryx.js +148 -0
  35. package/dist/pages/openapiSpecPathsLotteMart.js +10 -2
  36. package/dist/pages/promptRetailCinemaText.js +9 -1
  37. package/dist/services/cgv/errors.js +13 -0
  38. package/dist/services/cgv/transport.js +5 -22
  39. package/dist/services/cu/client.js +44 -30
  40. package/dist/services/cu/index.js +13 -3
  41. package/dist/services/cu/tools/checkInventory.js +65 -45
  42. package/dist/services/cu/tools/findNearbyStores.js +4 -3
  43. package/dist/services/cu/upstreamError.js +11 -0
  44. package/dist/services/dtryx/api.js +25 -0
  45. package/dist/services/dtryx/client.js +133 -0
  46. package/dist/services/dtryx/index.js +24 -0
  47. package/dist/services/dtryx/location.js +216 -0
  48. package/dist/services/dtryx/tools/getRemainingSeats.js +117 -0
  49. package/dist/services/dtryx/tools/listCinemas.js +46 -0
  50. package/dist/services/dtryx/tools/listNowShowing.js +77 -0
  51. package/dist/services/dtryx/types.js +4 -0
  52. package/dist/services/dtryx/validation.js +21 -0
  53. package/dist/services/feedback/tools/submitDeveloperRequest.js +15 -1
  54. package/dist/services/gs25/api.js +2 -2
  55. package/dist/services/gs25/client.js +16 -123
  56. package/dist/services/gs25/errors.js +12 -0
  57. package/dist/services/gs25/index.js +2 -2
  58. package/dist/services/gs25/productSearch.js +2 -14
  59. package/dist/services/gs25/storeStockTransport.js +30 -0
  60. package/dist/services/gs25/tools/checkInventory.js +7 -2
  61. package/dist/services/gs25/tools/findNearbyStores.js +20 -5
  62. package/dist/services/lottecinema/client.js +4 -2
  63. package/dist/services/lottemart/config.js +1 -1
  64. package/dist/services/lottemart/session.js +33 -201
  65. package/dist/services/lottemart/socketTransport.js +149 -0
  66. package/dist/services/oliveyoung/client.js +7 -39
  67. package/dist/services/oliveyoung/index.js +4 -4
  68. package/dist/services/oliveyoung/tools/checkInventory.js +6 -3
  69. package/dist/services/oliveyoung/tools/findNearbyStores.js +4 -3
  70. package/dist/services/oliveyoung/tools/searchProducts.js +4 -3
  71. package/dist/services/oliveyoung/transport.js +46 -0
  72. package/dist/services/seveneleven/client.js +20 -9
  73. package/dist/services/seveneleven/index.js +11 -7
  74. package/dist/services/seveneleven/inventory.js +5 -2
  75. package/dist/services/seveneleven/productKeyword.js +3 -2
  76. package/dist/services/seveneleven/readCache.js +49 -0
  77. package/dist/services/seveneleven/tools/checkInventory.js +4 -3
  78. package/dist/services/seveneleven/tools/getCatalogSnapshot.js +4 -3
  79. package/dist/services/seveneleven/tools/getSearchPopwords.js +4 -3
  80. package/dist/services/seveneleven/tools/searchProducts.js +4 -3
  81. package/dist/services/seveneleven/tools/searchStores.js +4 -3
  82. package/dist/utils/format.js +10 -3
  83. package/dist/utils/http.js +40 -12
  84. package/dist/utils/zyte.js +4 -101
  85. package/dist/utils/zyteJsonFallback.js +28 -0
  86. package/openapi.json +51 -3
  87. package/openapi.yaml +36 -3
  88. package/package.json +28 -27
@@ -0,0 +1,46 @@
1
+ /** 올리브영 무료 직접 요청 및 운영자가 설정한 브라우저 릴레이 전송. */
2
+ import { fetchJson } from '../../utils/http.js';
3
+ import { OLIVEYOUNG_API } from './api.js';
4
+ export async function requestOliveyoung(path, body, options = {}) {
5
+ const { timeout = 15000, relayUrl, relayToken } = options;
6
+ const headers = {
7
+ 'Content-Type': 'application/json', Accept: 'application/json',
8
+ 'X-Requested-With': 'XMLHttpRequest',
9
+ };
10
+ let url = `${OLIVEYOUNG_API.BASE_URL}${path}`;
11
+ if (relayUrl) {
12
+ let relay;
13
+ try {
14
+ relay = new URL(relayUrl);
15
+ }
16
+ catch {
17
+ throw new Error('올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.');
18
+ }
19
+ if (relay.username || relay.password || relay.search || relay.hash ||
20
+ (relay.protocol !== 'https:' && !(relay.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(relay.hostname)))) {
21
+ throw new Error('올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.');
22
+ }
23
+ if (!relayToken?.trim())
24
+ throw new Error('OY_RELAY_TOKEN이 필요합니다.');
25
+ url = `${relayUrl.replace(/\/$/, '')}/v1/oliveyoung/${path.split('/').pop()}`;
26
+ headers.Authorization = `Bearer ${relayToken}`;
27
+ }
28
+ let result;
29
+ try {
30
+ result = await fetchJson(url, {
31
+ method: 'POST', headers, body: JSON.stringify(body), timeout, retries: 0, expectedStatus: 200,
32
+ });
33
+ }
34
+ catch (error) {
35
+ if (error instanceof Error && error.name === 'AbortError') {
36
+ throw new Error('올리브영 API 요청 시간 초과');
37
+ }
38
+ if (relayUrl)
39
+ throw new Error('올리브영 브라우저 릴레이 요청 실패');
40
+ throw new Error('올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', { cause: error });
41
+ }
42
+ if (result?.status !== 'SUCCESS') {
43
+ throw new Error(`올리브영 API 상태 오류: ${result?.status || 'UNKNOWN'}`);
44
+ }
45
+ return result;
46
+ }
@@ -2,7 +2,8 @@
2
2
  * 세븐일레븐 API 클라이언트
3
3
  */
4
4
  /* c8 ignore start */
5
- import { fetchJson } from '../../utils/http.js';
5
+ import { withSevenElevenReadCache } from './readCache.js';
6
+ import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js';
6
7
  import { SEVENELEVEN_API } from './api.js';
7
8
  const SEVENELEVEN_DEFAULT_HEADERS = {
8
9
  Accept: 'application/json, text/plain, */*',
@@ -109,16 +110,18 @@ function normalizeStores(items) {
109
110
  .filter((store) => store.storeCode.length > 0 || store.storeName.length > 0);
110
111
  }
111
112
  async function requestSevenElevenJson(path, method, body, options = {}) {
112
- const { timeout = 15000 } = options;
113
+ const { timeout = 15000, zyteApiKey } = options;
113
114
  const url = `${SEVENELEVEN_API.BASE_URL}${path}`;
114
- return fetchJson(url, {
115
+ return withSevenElevenReadCache(path, body, timeout, () => fetchJsonWithZyteFallback(url, {
115
116
  ...SEVENELEVEN_DEFAULT_FETCH_OPTIONS,
116
117
  method,
117
118
  retryUnsafeMethods: method === 'POST',
118
119
  timeout,
119
120
  headers: SEVENELEVEN_DEFAULT_HEADERS,
120
121
  body: method === 'POST' ? JSON.stringify(body || {}) : undefined,
121
- });
122
+ zyteApiKey,
123
+ zyteTags: { service: 'seveneleven' },
124
+ }));
122
125
  }
123
126
  export async function searchSevenElevenProducts(params, options = {}) {
124
127
  const { query, page = 1, size = 20 } = params;
@@ -216,7 +219,7 @@ export async function fetchSevenElevenStockProductMeta(itemCode, options = {}) {
216
219
  const { timeout = 15000 } = options;
217
220
  const encodedItemCode = encodeURIComponent(itemCode.trim());
218
221
  const url = `${SEVENELEVEN_API.BASE_URL}${SEVENELEVEN_API.PRODUCT_SEARCH_STOCK_PATH}?itemCd=${encodedItemCode}`;
219
- const response = await fetchJson(url, {
222
+ const response = await fetchJsonWithZyteFallback(url, {
220
223
  ...SEVENELEVEN_DEFAULT_FETCH_OPTIONS,
221
224
  method: 'GET',
222
225
  timeout,
@@ -224,6 +227,8 @@ export async function fetchSevenElevenStockProductMeta(itemCode, options = {}) {
224
227
  Accept: SEVENELEVEN_DEFAULT_HEADERS.Accept,
225
228
  'User-Agent': SEVENELEVEN_DEFAULT_HEADERS['User-Agent'],
226
229
  },
230
+ zyteApiKey: options.zyteApiKey,
231
+ zyteTags: { service: 'seveneleven' },
227
232
  });
228
233
  const productMeta = normalizeStockProductMeta(response);
229
234
  if (productMeta.itemCode.length === 0 || productMeta.smCode.length === 0) {
@@ -263,14 +268,20 @@ function extractContentArray(data) {
263
268
  return [];
264
269
  }
265
270
  export async function fetchSevenElevenCatalogSnapshot(options = {}) {
266
- const { includeIssues = true, includeExhibition = true, timeout = 15000 } = options;
271
+ const { includeIssues = true, includeExhibition = true, timeout = 15000, zyteApiKey } = options;
267
272
  const tasks = [
268
- requestSevenElevenJson(SEVENELEVEN_API.PRODUCT_PAGES_PATH, 'GET', null, { timeout }),
273
+ requestSevenElevenJson(SEVENELEVEN_API.PRODUCT_PAGES_PATH, 'GET', null, { timeout, zyteApiKey }),
269
274
  includeIssues
270
- ? requestSevenElevenJson(SEVENELEVEN_API.PRODUCT_ISSUES_PATH, 'GET', null, { timeout })
275
+ ? requestSevenElevenJson(SEVENELEVEN_API.PRODUCT_ISSUES_PATH, 'GET', null, {
276
+ timeout,
277
+ zyteApiKey,
278
+ })
271
279
  : Promise.resolve({ data: { content: [] } }),
272
280
  includeExhibition
273
- ? requestSevenElevenJson(SEVENELEVEN_API.EXHIBITION_MAIN_PATH, 'GET', null, { timeout })
281
+ ? requestSevenElevenJson(SEVENELEVEN_API.EXHIBITION_MAIN_PATH, 'GET', null, {
282
+ timeout,
283
+ zyteApiKey,
284
+ })
274
285
  : Promise.resolve({ data: [] }),
275
286
  ];
276
287
  const [pagesResult, issuesResult, exhibitionsResult] = await Promise.allSettled(tasks);
@@ -13,18 +13,22 @@ const SEVENELEVEN_METADATA = {
13
13
  description: '세븐일레븐 공개 상품/검색/카탈로그 조회 서비스',
14
14
  };
15
15
  class SevenElevenService {
16
+ options;
16
17
  metadata = SEVENELEVEN_METADATA;
18
+ constructor(options = {}) {
19
+ this.options = options;
20
+ }
17
21
  getTools() {
18
22
  return [
19
- createSearchProductsTool(),
20
- createSearchStoresTool(),
21
- createCheckInventoryTool(),
22
- createGetSearchPopwordsTool(),
23
- createGetCatalogSnapshotTool(),
23
+ createSearchProductsTool(this.options.zyteApiKey),
24
+ createSearchStoresTool(this.options.zyteApiKey),
25
+ createCheckInventoryTool(this.options.zyteApiKey),
26
+ createGetSearchPopwordsTool(this.options.zyteApiKey),
27
+ createGetCatalogSnapshotTool(this.options.zyteApiKey),
24
28
  ];
25
29
  }
26
30
  }
27
- export function createSevenElevenService() {
28
- return new SevenElevenService();
31
+ export function createSevenElevenService(options = {}) {
32
+ return new SevenElevenService(options);
29
33
  }
30
34
  export * from './types.js';
@@ -5,7 +5,8 @@
5
5
  * 재고 API가 암호화로 차단된 경우에도 상품/매장 정보를 graceful하게 반환합니다.
6
6
  */
7
7
  /* c8 ignore start */
8
- import { fetchJson, HttpError } from '../../utils/http.js';
8
+ import { HttpError } from '../../utils/http.js';
9
+ import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js';
9
10
  import { SEVENELEVEN_API } from './api.js';
10
11
  import { fetchSevenElevenStockProductMeta, fetchSevenElevenStoresByKeyword, } from './client.js';
11
12
  import { pickBestSevenElevenProduct, searchSevenElevenProductsWithVariants } from './productKeyword.js';
@@ -180,11 +181,13 @@ async function tryStockApi(stockProduct, stores, options = {}) {
180
181
  return { stores: null, error: null };
181
182
  }
182
183
  try {
183
- const response = await fetchJson(url, {
184
+ const response = await fetchJsonWithZyteFallback(url, {
184
185
  method: 'POST',
185
186
  timeout,
186
187
  headers: SEVENELEVEN_DEFAULT_HEADERS,
187
188
  body: JSON.stringify(payload),
189
+ zyteApiKey: options.zyteApiKey,
190
+ zyteTags: { service: 'seveneleven', operation: 'inventory' },
188
191
  });
189
192
  const responseError = normalizeEnvelopeError(response);
190
193
  if (responseError) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 세븐일레븐 상품 검색어 보정
3
3
  */
4
- import { searchSevenElevenProducts } from './client.js';
4
+ import { searchSevenElevenProducts, } from './client.js';
5
5
  const SANDWICH_SUFFIX_FAMILY = ['샌드위치', '샌드', '산도'];
6
6
  function normalizeProductText(value) {
7
7
  return value.replace(/[^0-9A-Za-z가-힣]+/g, '').toLowerCase();
@@ -95,7 +95,7 @@ function scoreSevenElevenProduct(product, rawKeyword) {
95
95
  * 보정 질의를 순차 적용해 세븐일레븐 상품을 찾습니다.
96
96
  */
97
97
  export async function searchSevenElevenProductsWithVariants(query, options = {}) {
98
- const { page = 1, size = 20, sort = 'recommend', timeout } = options;
98
+ const { page = 1, size = 20, sort = 'recommend', timeout, zyteApiKey } = options;
99
99
  const appliedQueries = buildSevenElevenProductKeywordVariants(query);
100
100
  const seenKeys = new Set();
101
101
  const collectionIds = new Set();
@@ -108,6 +108,7 @@ export async function searchSevenElevenProductsWithVariants(query, options = {})
108
108
  sort,
109
109
  }, {
110
110
  timeout,
111
+ zyteApiKey,
111
112
  });
112
113
  for (const collectionId of result.collectionIds) {
113
114
  collectionIds.add(collectionId);
@@ -0,0 +1,49 @@
1
+ /** 공개 상품·매장 조회를 REST와 MCP에서 함께 재사용합니다. 재고는 저장하지 않습니다. */
2
+ import { SEVENELEVEN_API } from './api.js';
3
+ const MAX_ENTRIES = 128;
4
+ const TTL_BY_PATH = new Map([
5
+ [SEVENELEVEN_API.SEARCH_GOODS_PATH, 5 * 60_000],
6
+ [SEVENELEVEN_API.SEARCH_STORE_PATH, 30 * 60_000],
7
+ [SEVENELEVEN_API.SEARCH_POPWORD_PATH, 5 * 60_000],
8
+ [SEVENELEVEN_API.PRODUCT_PAGES_PATH, 5 * 60_000],
9
+ [SEVENELEVEN_API.PRODUCT_ISSUES_PATH, 5 * 60_000],
10
+ [SEVENELEVEN_API.EXHIBITION_MAIN_PATH, 5 * 60_000],
11
+ ]);
12
+ const values = new Map();
13
+ const pending = new Map();
14
+ export function clearSevenElevenReadCache() {
15
+ values.clear();
16
+ pending.clear();
17
+ }
18
+ export async function withSevenElevenReadCache(path, body, timeout, request) {
19
+ const ttl = TTL_BY_PATH.get(path.split('?')[0]);
20
+ if (!ttl)
21
+ return request();
22
+ const key = JSON.stringify([path, body, timeout]);
23
+ const cached = values.get(key);
24
+ if (cached && cached.expiresAt > Date.now()) {
25
+ return structuredClone(cached.value);
26
+ }
27
+ values.delete(key);
28
+ const existing = pending.get(key);
29
+ if (existing)
30
+ return structuredClone(await existing);
31
+ // 부하가 높아져도 진행 중인 조회 키를 무제한으로 보관하지 않습니다.
32
+ if (pending.size >= MAX_ENTRIES)
33
+ return request();
34
+ const promise = request().then((value) => {
35
+ if (value.success === true && value.data !== undefined) {
36
+ if (values.size >= MAX_ENTRIES)
37
+ values.delete(values.keys().next().value);
38
+ values.set(key, { expiresAt: Date.now() + ttl, value: structuredClone(value) });
39
+ }
40
+ return value;
41
+ });
42
+ pending.set(key, promise);
43
+ try {
44
+ return structuredClone(await promise);
45
+ }
46
+ finally {
47
+ pending.delete(key);
48
+ }
49
+ }
@@ -16,7 +16,7 @@ function isEncryptedStockFailure(stockError) {
16
16
  const messageMatched = /RSA|복호화|암호화|서비스를 사용할 수 없습니다/i.test(messageSource);
17
17
  return codeMatched || messageMatched;
18
18
  }
19
- async function checkInventory(args) {
19
+ async function checkInventory(args, zyteApiKey) {
20
20
  const { keyword, storeKeyword = '', storeLimit = 20, timeoutMs = 20000, } = args;
21
21
  if (!keyword || keyword.trim().length === 0) {
22
22
  throw new Error('상품 검색어(keyword)를 입력해주세요.');
@@ -27,6 +27,7 @@ async function checkInventory(args) {
27
27
  storeLimit,
28
28
  }, {
29
29
  timeout: timeoutMs,
30
+ zyteApiKey,
30
31
  });
31
32
  const note = !result.product
32
33
  ? '상품 검색 결과가 없어 재고를 조회하지 못했습니다. 다른 상품명이나 더 짧은 키워드로 다시 시도해주세요.'
@@ -59,7 +60,7 @@ async function checkInventory(args) {
59
60
  ],
60
61
  };
61
62
  }
62
- export function createCheckInventoryTool() {
63
+ export function createCheckInventoryTool(zyteApiKey) {
63
64
  return {
64
65
  name: 'seveneleven_check_inventory',
65
66
  metadata: {
@@ -76,7 +77,7 @@ export function createCheckInventoryTool() {
76
77
  timeoutMs: z.number().optional().default(20000).describe('요청 제한 시간(ms, 기본값: 20000)'),
77
78
  },
78
79
  },
79
- handler: checkInventory,
80
+ handler: (args) => checkInventory(args, zyteApiKey),
80
81
  };
81
82
  }
82
83
  /* c8 ignore stop */
@@ -3,12 +3,13 @@
3
3
  */
4
4
  import * as z from 'zod';
5
5
  import { fetchSevenElevenCatalogSnapshot } from '../client.js';
6
- async function getCatalogSnapshot(args) {
6
+ async function getCatalogSnapshot(args, zyteApiKey) {
7
7
  const { includeIssues = true, includeExhibition = true, limit = 20, timeoutMs = 15000 } = args;
8
8
  const result = await fetchSevenElevenCatalogSnapshot({
9
9
  includeIssues,
10
10
  includeExhibition,
11
11
  timeout: timeoutMs,
12
+ zyteApiKey,
12
13
  });
13
14
  return {
14
15
  content: [
@@ -37,7 +38,7 @@ async function getCatalogSnapshot(args) {
37
38
  ],
38
39
  };
39
40
  }
40
- export function createGetCatalogSnapshotTool() {
41
+ export function createGetCatalogSnapshotTool(zyteApiKey) {
41
42
  return {
42
43
  name: 'seveneleven_get_catalog_snapshot',
43
44
  metadata: {
@@ -50,6 +51,6 @@ export function createGetCatalogSnapshotTool() {
50
51
  timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
51
52
  },
52
53
  },
53
- handler: getCatalogSnapshot,
54
+ handler: (args) => getCatalogSnapshot(args, zyteApiKey),
54
55
  };
55
56
  }
@@ -3,10 +3,11 @@
3
3
  */
4
4
  import * as z from 'zod';
5
5
  import { fetchSevenElevenSearchPopwords } from '../client.js';
6
- async function getSearchPopwords(args) {
6
+ async function getSearchPopwords(args, zyteApiKey) {
7
7
  const { label = 'home', timeoutMs = 15000 } = args;
8
8
  const keywords = await fetchSevenElevenSearchPopwords(label, {
9
9
  timeout: timeoutMs,
10
+ zyteApiKey,
10
11
  });
11
12
  return {
12
13
  content: [
@@ -25,7 +26,7 @@ async function getSearchPopwords(args) {
25
26
  ],
26
27
  };
27
28
  }
28
- export function createGetSearchPopwordsTool() {
29
+ export function createGetSearchPopwordsTool(zyteApiKey) {
29
30
  return {
30
31
  name: 'seveneleven_get_search_popwords',
31
32
  metadata: {
@@ -36,6 +37,6 @@ export function createGetSearchPopwordsTool() {
36
37
  timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
37
38
  },
38
39
  },
39
- handler: getSearchPopwords,
40
+ handler: (args) => getSearchPopwords(args, zyteApiKey),
40
41
  };
41
42
  }
@@ -9,7 +9,7 @@ function buildTextResponse(payload) {
9
9
  structuredContent: payload,
10
10
  };
11
11
  }
12
- async function searchProducts(args) {
12
+ async function searchProducts(args, zyteApiKey) {
13
13
  const { query, page = 1, size = 20, sort = 'recommend', timeoutMs = 15000 } = args;
14
14
  if (!query || query.trim().length === 0) {
15
15
  throw new Error('상품 검색어(query)를 입력해주세요.');
@@ -20,6 +20,7 @@ async function searchProducts(args) {
20
20
  size,
21
21
  sort,
22
22
  timeout: timeoutMs,
23
+ zyteApiKey,
23
24
  });
24
25
  return buildTextResponse({
25
26
  query: result.query,
@@ -62,7 +63,7 @@ const searchProductsOutputSchema = {
62
63
  status: z.string().optional().describe('외부 API 차단 등으로 결과가 제한된 경우 degraded'),
63
64
  message: z.string().optional().describe('degraded 상태의 원인 메시지'),
64
65
  };
65
- export function createSearchProductsTool() {
66
+ export function createSearchProductsTool(zyteApiKey) {
66
67
  return {
67
68
  name: 'seveneleven_search_products',
68
69
  metadata: {
@@ -81,6 +82,6 @@ export function createSearchProductsTool() {
81
82
  },
82
83
  outputSchema: searchProductsOutputSchema,
83
84
  },
84
- handler: searchProducts,
85
+ handler: (args) => searchProducts(args, zyteApiKey),
85
86
  };
86
87
  }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import * as z from 'zod';
5
5
  import { fetchSevenElevenStoresByKeyword } from '../client.js';
6
- async function searchStores(args) {
6
+ async function searchStores(args, zyteApiKey) {
7
7
  const { keyword, limit = 20, timeoutMs = 15000 } = args;
8
8
  if (!keyword || keyword.trim().length === 0) {
9
9
  throw new Error('매장 검색어(keyword)를 입력해주세요.');
@@ -13,6 +13,7 @@ async function searchStores(args) {
13
13
  limit,
14
14
  }, {
15
15
  timeout: timeoutMs,
16
+ zyteApiKey,
16
17
  });
17
18
  return {
18
19
  content: [
@@ -28,7 +29,7 @@ async function searchStores(args) {
28
29
  ],
29
30
  };
30
31
  }
31
- export function createSearchStoresTool() {
32
+ export function createSearchStoresTool(zyteApiKey) {
32
33
  return {
33
34
  name: 'seveneleven_search_stores',
34
35
  metadata: {
@@ -40,6 +41,6 @@ export function createSearchStoresTool() {
40
41
  timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
41
42
  },
42
43
  },
43
- handler: searchStores,
44
+ handler: (args) => searchStores(args, zyteApiKey),
44
45
  };
45
46
  }
@@ -24,8 +24,15 @@ export function formatTime(raw) {
24
24
  return raw;
25
25
  }
26
26
  export function toYyyymmdd(value = new Date()) {
27
- const year = value.getFullYear();
28
- const month = `${value.getMonth() + 1}`.padStart(2, '0');
29
- const day = `${value.getDate()}`.padStart(2, '0');
27
+ const parts = new Intl.DateTimeFormat('en-CA', {
28
+ timeZone: 'Asia/Seoul',
29
+ year: 'numeric',
30
+ month: '2-digit',
31
+ day: '2-digit',
32
+ }).formatToParts(value);
33
+ const byType = Object.fromEntries(parts.map((part) => [part.type, part.value]));
34
+ const year = byType.year;
35
+ const month = byType.month;
36
+ const day = byType.day;
30
37
  return `${year}${month}${day}`;
31
38
  }
@@ -15,6 +15,17 @@ export class HttpError extends Error {
15
15
  this.bodyText = bodyText;
16
16
  }
17
17
  }
18
+ /** HTML 원문을 노출하지 않고 예상하지 못한 응답 형식을 구분합니다. */
19
+ export class UnexpectedHtmlResponseError extends Error {
20
+ status;
21
+ contentType;
22
+ constructor(status, contentType) {
23
+ super(`JSON 대신 HTML 응답을 받았습니다 (HTTP ${status}).`);
24
+ this.status = status;
25
+ this.contentType = contentType;
26
+ this.name = 'UnexpectedHtmlResponseError';
27
+ }
28
+ }
18
29
  export function createTimeoutController(timeout) {
19
30
  const controller = new AbortController();
20
31
  const timeoutId = setTimeout(() => controller.abort(), timeout);
@@ -54,19 +65,22 @@ function parseRetryAfterDelayMs(response) {
54
65
  }
55
66
  return null;
56
67
  }
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;
68
+ async function requestWithTimeout(url, options, readResponse) {
69
+ const { timeout = 10000, retries = 0, retryDelayMs = 250, retryStatusCodes = DEFAULT_RETRY_STATUS_CODES, retryUnsafeMethods = false, retryMethods = DEFAULT_RETRY_METHODS, onRetry, expectedStatus: _expectedStatus, ...restOptions } = options;
59
70
  const maxAttempts = Math.max(1, Math.trunc(retries) + 1);
60
71
  const method = normalizeMethod(restOptions.method);
61
72
  const retryAllowed = canRetryMethod(method, retryMethods, retryUnsafeMethods);
62
73
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
63
74
  const { controller, timeoutId } = createTimeoutController(timeout);
75
+ let readingResponse = false;
64
76
  try {
65
77
  const response = await fetch(url, {
66
78
  ...restOptions,
67
79
  signal: controller.signal,
68
80
  });
69
- if (retryAllowed && attempt < maxAttempts && isRetryableStatus(response.status, retryStatusCodes)) {
81
+ if (retryAllowed &&
82
+ attempt < maxAttempts &&
83
+ isRetryableStatus(response.status, retryStatusCodes)) {
70
84
  const delayMs = parseRetryAfterDelayMs(response) ?? retryDelayMs;
71
85
  onRetry?.({
72
86
  attempt,
@@ -81,10 +95,11 @@ export async function fetchWithTimeout(url, options = {}) {
81
95
  await wait(delayMs);
82
96
  continue;
83
97
  }
84
- return response;
98
+ readingResponse = true;
99
+ return await readResponse(response);
85
100
  }
86
101
  catch (error) {
87
- if (!retryAllowed || attempt >= maxAttempts) {
102
+ if (readingResponse || !retryAllowed || attempt >= maxAttempts) {
88
103
  throw error;
89
104
  }
90
105
  onRetry?.({
@@ -105,17 +120,30 @@ export async function fetchWithTimeout(url, options = {}) {
105
120
  /* v8 ignore next */
106
121
  throw new Error('API 요청 재시도 처리 중 알 수 없는 오류가 발생했습니다.');
107
122
  }
123
+ export async function fetchWithTimeout(url, options = {}) {
124
+ return requestWithTimeout(url, options, async (response) => response);
125
+ }
126
+ async function readTextResponse(url, options) {
127
+ // 헤더 수신뿐 아니라 본문 읽기에도 같은 요청 제한 시간을 적용합니다.
128
+ return requestWithTimeout(url, options, async (response) => ({
129
+ response,
130
+ body: await response.text(),
131
+ }));
132
+ }
108
133
  export async function fetchJson(url, options = {}) {
109
- const response = await fetchWithTimeout(url, options);
110
- if (!response.ok) {
111
- throw new HttpError(response.status, response.statusText, await response.text());
134
+ const { response, body } = await readTextResponse(url, options);
135
+ if (!response.ok || (options.expectedStatus !== undefined && response.status !== options.expectedStatus)) {
136
+ throw new HttpError(response.status, response.statusText, body);
137
+ }
138
+ if (/^\s*(?:<!doctype\s+html\b|<html\b)/i.test(body)) {
139
+ throw new UnexpectedHtmlResponseError(response.status, response.headers.get('content-type') || '');
112
140
  }
113
- return response.json();
141
+ return JSON.parse(body);
114
142
  }
115
143
  export async function fetchText(url, options = {}) {
116
- const response = await fetchWithTimeout(url, options);
144
+ const { response, body } = await readTextResponse(url, options);
117
145
  if (!response.ok) {
118
- throw new HttpError(response.status, response.statusText, await response.text());
146
+ throw new HttpError(response.status, response.statusText, body);
119
147
  }
120
- return response.text();
148
+ return body;
121
149
  }
@@ -1,29 +1,7 @@
1
1
  /**
2
2
  * Zyte Extract API 공통 유틸리티
3
3
  */
4
- import { createTimeoutController } from './http.js';
5
- export function resolveZyteApiKey(apiKey) {
6
- if (apiKey && apiKey.trim().length > 0) {
7
- return apiKey;
8
- }
9
- /* c8 ignore start */
10
- if (typeof process !== 'undefined' && process.env?.ZYTE_API_KEY) {
11
- return process.env.ZYTE_API_KEY;
12
- }
13
- /* c8 ignore end */
14
- throw new Error('ZYTE_API_KEY가 설정되지 않았습니다. .env 또는 Cloudflare Worker Secret을 확인해주세요.');
15
- }
16
- export function encodeBasicAuth(apiKey) {
17
- if (typeof btoa === 'function') {
18
- return btoa(`${apiKey}:`);
19
- }
20
- /* c8 ignore start */
21
- if (typeof Buffer !== 'undefined') {
22
- return Buffer.from(`${apiKey}:`).toString('base64');
23
- }
24
- /* c8 ignore end */
25
- throw new Error('Basic 인증 인코딩을 지원하지 않는 런타임입니다.');
26
- }
4
+ import { ZYTE_COST_POLICY_MESSAGE } from '../core/errors.js';
27
5
  export function decodeBase64(value) {
28
6
  if (typeof atob === 'function') {
29
7
  const binary = atob(value);
@@ -37,84 +15,9 @@ export function decodeBase64(value) {
37
15
  /* c8 ignore end */
38
16
  throw new Error('Base64 디코딩을 지원하지 않는 런타임입니다.');
39
17
  }
40
- function isAbortError(error) {
41
- return error instanceof Error && error.name === 'AbortError';
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
- }
54
- function wait(ms) {
55
- if (ms <= 0) {
56
- return Promise.resolve();
57
- }
58
- return new Promise((resolve) => {
59
- setTimeout(resolve, ms);
60
- });
61
- }
62
- export async function requestByZyte(options) {
63
- const { timeout = 15000, retries = 1, retryDelayMs = 250, url, method = 'GET', headers = [], bodyText, apiKey, } = options;
64
- const auth = encodeBasicAuth(resolveZyteApiKey(apiKey));
65
- const maxAttempts = Math.max(1, Math.trunc(retries) + 1);
66
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
67
- const { controller, timeoutId } = createTimeoutController(timeout);
68
- try {
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
- }
94
- const result = (await response.json());
95
- if (!response.ok) {
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}`);
104
- }
105
- return result;
106
- }
107
- catch (error) {
108
- if (!isRetryableError(error) || attempt >= maxAttempts) {
109
- throw error;
110
- }
111
- await wait(retryDelayMs);
112
- }
113
- finally {
114
- clearTimeout(timeoutId);
115
- }
116
- }
117
- throw new Error('Zyte API 호출 실패');
18
+ /** 키와 재시도 설정에 관계없이 유료 네트워크 경로를 차단합니다. */
19
+ export async function requestByZyte(_options) {
20
+ throw new Error(ZYTE_COST_POLICY_MESSAGE);
118
21
  }
119
22
  export function decodeZyteHttpBody(result) {
120
23
  if (!result.httpResponseBody) {