daiso 1.0.9 → 1.1.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 (86) hide show
  1. package/README.md +88 -9
  2. package/dist/api/actionsProxy.js +3 -0
  3. package/dist/api/cgvHandlers.js +15 -3
  4. package/dist/api/configStatus.js +1 -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 +3 -0
  9. package/dist/api/healthCheckDefinitions.js +56 -8
  10. package/dist/api/healthCheckShape.js +24 -8
  11. package/dist/api/healthChecks.js +48 -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/commands/convenience.js +2 -2
  21. package/dist/cli.js +7 -1
  22. package/dist/cliHelp.js +3 -337
  23. package/dist/cliHelpDefinitions.js +369 -0
  24. package/dist/cliInteractive.js +8 -5
  25. package/dist/core/errors.js +17 -2
  26. package/dist/core/registry.js +13 -15
  27. package/dist/durableObjects/dailyRateLimiter.js +185 -0
  28. package/dist/durableObjects/rateLimitMetricsStore.js +144 -0
  29. package/dist/index.js +16 -30
  30. package/dist/middleware/dailyRateLimit.js +176 -0
  31. package/dist/pages/openapiFullSpec.js +2 -0
  32. package/dist/pages/openapiSpecActionParameters.js +429 -0
  33. package/dist/pages/openapiSpecActions.js +1 -391
  34. package/dist/pages/openapiSpecComponentsLottecinema.js +2 -2
  35. package/dist/pages/openapiSpecPathsDtryx.js +148 -0
  36. package/dist/pages/openapiSpecPathsLotteMart.js +10 -2
  37. package/dist/pages/promptRetailCinemaText.js +9 -1
  38. package/dist/services/cgv/client.js +7 -4
  39. package/dist/services/cgv/errors.js +12 -0
  40. package/dist/services/cgv/transport.js +19 -2
  41. package/dist/services/cu/client.js +42 -12
  42. package/dist/services/cu/index.js +13 -3
  43. package/dist/services/cu/tools/checkInventory.js +65 -45
  44. package/dist/services/cu/tools/findNearbyStores.js +4 -3
  45. package/dist/services/cu/upstreamError.js +15 -0
  46. package/dist/services/dtryx/api.js +25 -0
  47. package/dist/services/dtryx/client.js +133 -0
  48. package/dist/services/dtryx/index.js +24 -0
  49. package/dist/services/dtryx/location.js +216 -0
  50. package/dist/services/dtryx/tools/getRemainingSeats.js +117 -0
  51. package/dist/services/dtryx/tools/listCinemas.js +46 -0
  52. package/dist/services/dtryx/tools/listNowShowing.js +77 -0
  53. package/dist/services/dtryx/types.js +4 -0
  54. package/dist/services/dtryx/validation.js +21 -0
  55. package/dist/services/emart24/tools/searchProducts.js +37 -19
  56. package/dist/services/feedback/tools/submitDeveloperRequest.js +15 -1
  57. package/dist/services/gs25/api.js +2 -2
  58. package/dist/services/gs25/client.js +16 -123
  59. package/dist/services/gs25/errors.js +12 -0
  60. package/dist/services/gs25/index.js +2 -2
  61. package/dist/services/gs25/productSearch.js +1 -0
  62. package/dist/services/gs25/storeStockTransport.js +55 -0
  63. package/dist/services/gs25/tools/checkInventory.js +7 -2
  64. package/dist/services/gs25/tools/findNearbyStores.js +20 -5
  65. package/dist/services/lottecinema/client.js +4 -2
  66. package/dist/services/lottemart/config.js +1 -1
  67. package/dist/services/lottemart/session.js +29 -160
  68. package/dist/services/lottemart/socketTransport.js +149 -0
  69. package/dist/services/oliveyoung/client.js +1 -0
  70. package/dist/services/places/client.js +29 -8
  71. package/dist/services/seveneleven/client.js +18 -8
  72. package/dist/services/seveneleven/index.js +11 -7
  73. package/dist/services/seveneleven/inventory.js +5 -2
  74. package/dist/services/seveneleven/productKeyword.js +3 -2
  75. package/dist/services/seveneleven/tools/checkInventory.js +4 -3
  76. package/dist/services/seveneleven/tools/getCatalogSnapshot.js +4 -3
  77. package/dist/services/seveneleven/tools/getSearchPopwords.js +4 -3
  78. package/dist/services/seveneleven/tools/searchProducts.js +40 -20
  79. package/dist/services/seveneleven/tools/searchStores.js +4 -3
  80. package/dist/utils/format.js +10 -3
  81. package/dist/utils/http.js +38 -10
  82. package/dist/utils/zyte.js +13 -2
  83. package/dist/utils/zyteJsonFallback.js +67 -0
  84. package/openapi.json +51 -3
  85. package/openapi.yaml +36 -3
  86. package/package.json +28 -24
@@ -2,10 +2,10 @@
2
2
  * GS25 API 클라이언트
3
3
  */
4
4
  /* c8 ignore start */
5
- import { fetchJson, fetchWithTimeout, HttpError } from '../../utils/http.js';
6
- import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js';
5
+ import { fetchJson } from '../../utils/http.js';
7
6
  import { GS25_API } from './api.js';
8
7
  import { normalizeStore, toNumber } from './storeUtils.js';
8
+ import { fetchGs25StoreStockResponse } from './storeStockTransport.js';
9
9
  export { fetchGs25NormalizedKeyword, fetchGs25SearchProducts, } from './productSearch.js';
10
10
  export { attachDistanceToGs25Stores, calculateDistanceM, extractGs25ProductCandidates, filterGs25StoresByKeyword, selectGs25StoresForKeyword, sortGs25Stores, } from './storeUtils.js';
11
11
  const GS25_DEFAULT_HEADERS = {
@@ -21,31 +21,15 @@ const GS25_DEFAULT_FETCH_OPTIONS = {
21
21
  };
22
22
  const GS25_STORES_CACHE_TTL_MS = 60 * 5 * 1000;
23
23
  const gs25StoresCache = new Map();
24
- function extractGs25WebCsrfToken(html) {
25
- return (html.match(/name="CSRFToken"\s+value="([^"]+)"/)?.[1] ||
26
- html.match(/ACC\.config\.CSRFToken\s*=\s*"([^"]+)"/)?.[1] ||
27
- null);
28
- }
29
- function extractGs25WebCookieHeader(headers) {
30
- const setCookie = headers.get('set-cookie') || '';
31
- const jsessionId = setCookie.match(/JSESSIONID=[^;,\s]+/)?.[0];
32
- return jsessionId;
33
- }
34
- function parseGs25WebLocationResponse(body) {
35
- if (typeof body === 'string') {
36
- return JSON.parse(body);
37
- }
38
- return body;
39
- }
40
24
  function normalizeGs25WebStore(raw) {
41
- const propertyNames = (raw.offeringService || []).filter((item) => item.trim().length > 0);
25
+ const propertyNames = [];
42
26
  return {
43
27
  storeCode: raw.shopCode || '',
44
28
  storeName: raw.shopName || '',
45
29
  address: raw.address || '',
46
30
  phone: '',
47
- latitude: toNumber(raw.longs),
48
- longitude: toNumber(raw.lat),
31
+ latitude: toNumber(raw.posY),
32
+ longitude: toNumber(raw.posX),
49
33
  serviceCode: '01',
50
34
  realStockQuantity: 0,
51
35
  pickupStockQuantity: 0,
@@ -63,31 +47,6 @@ function normalizeGs25WebStore(raw) {
63
47
  distanceM: null,
64
48
  };
65
49
  }
66
- async function fetchGs25StoreStock(url, options) {
67
- try {
68
- return await fetchJson(url, {
69
- ...GS25_DEFAULT_FETCH_OPTIONS,
70
- method: 'GET',
71
- timeout: options.timeout,
72
- headers: GS25_DEFAULT_HEADERS,
73
- });
74
- }
75
- catch (error) {
76
- const zyteApiKey = options.zyteApiKey?.trim();
77
- if (!(error instanceof HttpError) || error.status !== 403 || !zyteApiKey) {
78
- throw error;
79
- }
80
- const result = await requestByZyte({
81
- apiKey: zyteApiKey,
82
- url,
83
- method: 'GET',
84
- timeout: options.timeout,
85
- retries: 1,
86
- headers: Object.entries(GS25_DEFAULT_HEADERS).map(([name, value]) => ({ name, value })),
87
- });
88
- return decodeZyteHttpBody(result);
89
- }
90
- }
91
50
  function buildCacheKey(params) {
92
51
  return [
93
52
  params.serviceCode,
@@ -138,85 +97,18 @@ export async function fetchGs25WebStores(keyword, options = {}) {
138
97
  return { totalCount: 0, stores: [] };
139
98
  }
140
99
  const { timeout = 20000 } = options;
141
- const locationsUrl = new URL(GS25_API.WEB_LOCATIONS_PATH, GS25_API.WEB_BASE_URL);
142
- const locationsResponse = await fetchWithTimeout(locationsUrl.toString(), {
100
+ const endpoint = new URL(GS25_API.WEB_LOCATION_LIST_PATH, GS25_API.WEB_BASE_URL);
101
+ endpoint.searchParams.set('shopName', query);
102
+ const body = await fetchJson(endpoint.toString(), {
143
103
  ...GS25_DEFAULT_FETCH_OPTIONS,
144
- method: 'GET',
145
104
  timeout,
146
- headers: {
147
- Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
148
- 'Accept-Language': GS25_DEFAULT_HEADERS['Accept-Language'],
149
- 'User-Agent': GS25_DEFAULT_HEADERS['User-Agent'],
150
- },
105
+ headers: { Accept: 'application/json' },
151
106
  });
152
- if (!locationsResponse.ok) {
153
- throw new HttpError(locationsResponse.status, locationsResponse.statusText, await locationsResponse.text());
154
- }
155
- const html = await locationsResponse.text();
156
- const csrfToken = extractGs25WebCsrfToken(html);
157
- if (!csrfToken) {
158
- throw new Error('GS25 웹 매장 검색 CSRF 토큰을 찾을 수 없습니다.');
107
+ if (!Array.isArray(body)) {
108
+ throw new Error('GS25 매장 응답 형식이 올바르지 않습니다.');
159
109
  }
160
- const form = new URLSearchParams({
161
- pageNum: '1',
162
- pageSize: '50',
163
- searchShopName: query,
164
- searchSido: '',
165
- searchGugun: '',
166
- searchDong: '',
167
- searchType: '',
168
- searchTypeService: '0',
169
- searchTypeToto: '0',
170
- searchTypeCafe25: '0',
171
- searchTypeInstant: '0',
172
- searchTypeDrug: '0',
173
- searchTypeSelf25: '0',
174
- searchTypePost: '0',
175
- searchTypeATM: '0',
176
- searchTypeWithdrawal: '0',
177
- searchTypeTaxrefund: '0',
178
- searchTypeSmartAtm: '0',
179
- searchTypeSelfCookingUtensils: '0',
180
- searchTypeDeliveryService: '0',
181
- searchTypeParcelService: '0',
182
- searchTypePotatoes: '0',
183
- searchTypeCardiacDefi: '0',
184
- searchTypeFishShapedBun: '0',
185
- searchTypeWine25: '0',
186
- searchTypeGoPizza: '0',
187
- searchTypeSpiritWine: '0',
188
- searchTypeFreshGanghw: '0',
189
- searchTypeMusinsa: '0',
190
- searchTypePosa: '0',
191
- });
192
- const locationListUrl = new URL(GS25_API.WEB_LOCATION_LIST_PATH, GS25_API.WEB_BASE_URL);
193
- locationListUrl.searchParams.set('CSRFToken', csrfToken);
194
- const cookieHeader = extractGs25WebCookieHeader(locationsResponse.headers);
195
- const body = parseGs25WebLocationResponse(await fetchJson(locationListUrl.toString(), {
196
- ...GS25_DEFAULT_FETCH_OPTIONS,
197
- method: 'POST',
198
- retryUnsafeMethods: true,
199
- timeout,
200
- headers: {
201
- Accept: 'application/json, text/javascript, */*; q=0.01',
202
- 'Accept-Language': GS25_DEFAULT_HEADERS['Accept-Language'],
203
- 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
204
- Referer: locationsUrl.toString(),
205
- 'User-Agent': GS25_DEFAULT_HEADERS['User-Agent'],
206
- 'X-Requested-With': 'XMLHttpRequest',
207
- ...(cookieHeader ? { Cookie: cookieHeader } : {}),
208
- },
209
- body: form.toString(),
210
- }));
211
- const stores = (body.results || [])
212
- .map(normalizeGs25WebStore)
213
- .filter((store) => store.storeCode.length > 0);
214
- return {
215
- totalCount: typeof body.pagination?.totalNumberOfResults === 'number'
216
- ? body.pagination.totalNumberOfResults
217
- : stores.length,
218
- stores,
219
- };
110
+ const stores = body.map(normalizeGs25WebStore).filter((store) => store.storeCode.length > 0);
111
+ return { totalCount: stores.length, stores };
220
112
  }
221
113
  export async function fetchGs25Stores(params = {}, options = {}) {
222
114
  const { serviceCode = '01', keyword = '', itemCode = '', storeCode = '', realTimeStockYn = 'Y', latitude, longitude, pageNumber, pageCount, useCache = true, } = params;
@@ -276,10 +168,11 @@ export async function fetchGs25Stores(params = {}, options = {}) {
276
168
  endpoint.searchParams.set('isSuperDlvyStoreSelected', 'N');
277
169
  endpoint.searchParams.set('isGs25DlvyStoreSelected', 'N');
278
170
  }
279
- const body = await fetchGs25StoreStock(endpoint.toString(), {
171
+ const body = await fetchGs25StoreStockResponse(endpoint.toString(), {
280
172
  timeout,
281
173
  zyteApiKey: options.zyteApiKey,
282
- });
174
+ apiKey: options.apiKey,
175
+ }, GS25_DEFAULT_HEADERS);
283
176
  const stores = (body.stores || [])
284
177
  .map(normalizeStore)
285
178
  .filter((store) => store.storeCode.length > 0);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * GS25 원본 서비스 장애 오류
3
+ */
4
+ export class Gs25UpstreamUnavailableError extends Error {
5
+ constructor() {
6
+ super('GS25 재고 서비스 인증을 사용할 수 없습니다. 운영자는 GS25_API_KEY 설정을 확인하세요.');
7
+ this.name = 'Gs25UpstreamUnavailableError';
8
+ }
9
+ }
10
+ export function isGs25UpstreamUnavailableError(error) {
11
+ return error instanceof Gs25UpstreamUnavailableError;
12
+ }
@@ -18,9 +18,9 @@ class Gs25Service {
18
18
  }
19
19
  getTools() {
20
20
  return [
21
- createFindNearbyStoresTool(this.options.googleMapsApiKey, this.options.zyteApiKey),
21
+ createFindNearbyStoresTool(this.options.googleMapsApiKey, this.options.zyteApiKey, this.options.apiKey),
22
22
  createSearchProductsTool(this.options.zyteApiKey),
23
- createCheckInventoryTool(this.options.googleMapsApiKey, this.options.zyteApiKey),
23
+ createCheckInventoryTool(this.options.googleMapsApiKey, this.options.zyteApiKey, this.options.apiKey),
24
24
  ];
25
25
  }
26
26
  }
@@ -48,6 +48,7 @@ async function fetchGs25TotalSearchResponse(query, options) {
48
48
  value,
49
49
  })),
50
50
  bodyText,
51
+ tags: { service: 'gs25' },
51
52
  });
52
53
  return decodeZyteHttpBody(result);
53
54
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * GS25 재고 원본 요청과 Zyte 대체 경로
3
+ */
4
+ import { fetchJson, HttpError } from '../../utils/http.js';
5
+ import { decodeZyteHttpBody, requestByZyte } from '../../utils/zyte.js';
6
+ import { Gs25UpstreamUnavailableError } from './errors.js';
7
+ function withApiKey(headers, apiKey) {
8
+ const normalizedApiKey = apiKey?.trim();
9
+ return normalizedApiKey ? { ...headers, 'Api-Key': normalizedApiKey } : headers;
10
+ }
11
+ function isAuthenticationStatus(status) {
12
+ return status === 401 || status === 403;
13
+ }
14
+ export async function fetchGs25StoreStockResponse(url, options, headers) {
15
+ const requestHeaders = withApiKey(headers, options.apiKey);
16
+ try {
17
+ return await fetchJson(url, {
18
+ method: 'GET',
19
+ timeout: options.timeout,
20
+ retries: 1,
21
+ retryDelayMs: 250,
22
+ headers: requestHeaders,
23
+ });
24
+ }
25
+ catch (error) {
26
+ if (!(error instanceof HttpError) || !isAuthenticationStatus(error.status)) {
27
+ throw error;
28
+ }
29
+ const zyteApiKey = options.zyteApiKey?.trim();
30
+ if (error.status === 401 || !zyteApiKey) {
31
+ throw new Gs25UpstreamUnavailableError();
32
+ }
33
+ try {
34
+ const result = await requestByZyte({
35
+ apiKey: zyteApiKey,
36
+ url,
37
+ method: 'GET',
38
+ timeout: options.timeout,
39
+ retries: 1,
40
+ headers: Object.entries(requestHeaders).map(([name, value]) => ({ name, value })),
41
+ tags: { service: 'gs25' },
42
+ });
43
+ if (isAuthenticationStatus(result.statusCode)) {
44
+ throw new Gs25UpstreamUnavailableError();
45
+ }
46
+ return decodeZyteHttpBody(result);
47
+ }
48
+ catch (fallbackError) {
49
+ if (fallbackError instanceof Gs25UpstreamUnavailableError) {
50
+ throw fallbackError;
51
+ }
52
+ throw new Gs25UpstreamUnavailableError();
53
+ }
54
+ }
55
+ }
@@ -8,7 +8,7 @@ function getProcessEnvValue(name) {
8
8
  return typeof process !== 'undefined' ? process.env[name] : undefined;
9
9
  }
10
10
  async function checkInventory(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;
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'), apiKey = getProcessEnvValue('GS25_API_KEY'), } = args;
12
12
  if (!keyword || keyword.trim().length === 0) {
13
13
  throw new Error('상품 검색어(keyword)를 입력해주세요.');
14
14
  }
@@ -25,6 +25,7 @@ async function checkInventory(args) {
25
25
  }, {
26
26
  timeout: timeoutMs,
27
27
  zyteApiKey,
28
+ apiKey,
28
29
  });
29
30
  const firstAddress = filterGs25StoresByKeyword(baseStores.stores, storeKeyword).find((store) => store.address.trim().length > 0)?.address || '';
30
31
  if (firstAddress.length > 0) {
@@ -61,6 +62,7 @@ async function checkInventory(args) {
61
62
  }, {
62
63
  timeout: timeoutMs,
63
64
  zyteApiKey,
65
+ apiKey,
64
66
  });
65
67
  }
66
68
  else {
@@ -83,6 +85,7 @@ async function checkInventory(args) {
83
85
  }, {
84
86
  timeout: timeoutMs,
85
87
  zyteApiKey,
88
+ apiKey,
86
89
  });
87
90
  }
88
91
  else {
@@ -97,6 +100,7 @@ async function checkInventory(args) {
97
100
  }, {
98
101
  timeout: timeoutMs,
99
102
  zyteApiKey,
103
+ apiKey,
100
104
  });
101
105
  }
102
106
  }
@@ -147,7 +151,7 @@ async function checkInventory(args) {
147
151
  ],
148
152
  };
149
153
  }
150
- export function createCheckInventoryTool(googleMapsApiKey, zyteApiKey) {
154
+ export function createCheckInventoryTool(googleMapsApiKey, zyteApiKey, apiKey) {
151
155
  return {
152
156
  name: 'gs25_check_inventory',
153
157
  metadata: {
@@ -184,6 +188,7 @@ export function createCheckInventoryTool(googleMapsApiKey, zyteApiKey) {
184
188
  ...args,
185
189
  googleMapsApiKey: args.googleMapsApiKey || googleMapsApiKey,
186
190
  zyteApiKey: args.zyteApiKey || zyteApiKey,
191
+ apiKey: args.apiKey || apiKey,
187
192
  })),
188
193
  };
189
194
  }
@@ -2,14 +2,15 @@
2
2
  * GS25 주변 매장 탐색 도구
3
3
  */
4
4
  import * as z from 'zod';
5
- import { attachDistanceToGs25Stores, fetchGs25Stores, geocodeGs25Address, selectGs25StoresForKeyword, sortGs25Stores, } from '../client.js';
5
+ import { isGs25UpstreamUnavailableError } from '../errors.js';
6
+ import { attachDistanceToGs25Stores, fetchGs25Stores, fetchGs25WebStores, geocodeGs25Address, selectGs25StoresForKeyword, sortGs25Stores, } from '../client.js';
6
7
  const FALLBACK_STORE_LOOKUP_ITEM_CODE = '8801117752804';
7
8
  function getProcessEnvValue(name) {
8
9
  /* c8 ignore next -- Node 테스트와 CLI 런타임은 process를 제공하며, Worker에는 API에서 키를 주입합니다. */
9
10
  return typeof process !== 'undefined' ? process.env[name] : undefined;
10
11
  }
11
12
  async function findNearbyStores(args) {
12
- const { latitude, longitude, keyword = '', serviceCode = '01', limit = 20, timeoutMs = 20000, googleMapsApiKey = getProcessEnvValue('GOOGLE_MAPS_API_KEY'), zyteApiKey = getProcessEnvValue('ZYTE_API_KEY'), } = args;
13
+ const { latitude, longitude, keyword = '', serviceCode = '01', limit = 20, timeoutMs = 20000, googleMapsApiKey = getProcessEnvValue('GOOGLE_MAPS_API_KEY'), zyteApiKey = getProcessEnvValue('ZYTE_API_KEY'), apiKey = getProcessEnvValue('GS25_API_KEY'), } = args;
13
14
  let resolvedLatitude = latitude;
14
15
  let resolvedLongitude = longitude;
15
16
  let geocodeUsed = false;
@@ -25,6 +26,7 @@ async function findNearbyStores(args) {
25
26
  geocodeUsed = true;
26
27
  }
27
28
  }
29
+ let fallbackUsed = false;
28
30
  let result = await fetchGs25Stores({
29
31
  serviceCode,
30
32
  latitude: resolvedLatitude,
@@ -32,9 +34,15 @@ async function findNearbyStores(args) {
32
34
  }, {
33
35
  timeout: timeoutMs,
34
36
  zyteApiKey,
37
+ apiKey,
38
+ }).catch(async (error) => {
39
+ if (!isGs25UpstreamUnavailableError(error) || keyword.trim().length === 0)
40
+ throw error;
41
+ const webResult = await fetchGs25WebStores(keyword, { timeout: timeoutMs });
42
+ fallbackUsed = true;
43
+ return { ...webResult, cacheHit: false };
35
44
  });
36
- let fallbackUsed = false;
37
- if (result.stores.length === 0 &&
45
+ if (!fallbackUsed && result.stores.length === 0 &&
38
46
  typeof resolvedLatitude === 'number' &&
39
47
  typeof resolvedLongitude === 'number') {
40
48
  try {
@@ -48,6 +56,7 @@ async function findNearbyStores(args) {
48
56
  }, {
49
57
  timeout: timeoutMs,
50
58
  zyteApiKey,
59
+ apiKey,
51
60
  });
52
61
  if (fallbackResult.stores.length > 0) {
53
62
  result = fallbackResult;
@@ -58,6 +67,11 @@ async function findNearbyStores(args) {
58
67
  fallbackUsed = false;
59
68
  }
60
69
  }
70
+ if (!fallbackUsed && result.stores.length === 0 && keyword.trim().length > 0) {
71
+ const webResult = await fetchGs25WebStores(keyword, { timeout: timeoutMs });
72
+ result = { ...webResult, cacheHit: false };
73
+ fallbackUsed = true;
74
+ }
61
75
  const selected = selectGs25StoresForKeyword(result.stores, keyword, {
62
76
  relaxWhenEmpty: typeof resolvedLatitude === 'number' && typeof resolvedLongitude === 'number',
63
77
  });
@@ -89,7 +103,7 @@ async function findNearbyStores(args) {
89
103
  ],
90
104
  };
91
105
  }
92
- export function createFindNearbyStoresTool(googleMapsApiKey, zyteApiKey) {
106
+ export function createFindNearbyStoresTool(googleMapsApiKey, zyteApiKey, apiKey) {
93
107
  return {
94
108
  name: 'gs25_find_nearby_stores',
95
109
  metadata: {
@@ -112,6 +126,7 @@ export function createFindNearbyStoresTool(googleMapsApiKey, zyteApiKey) {
112
126
  ...args,
113
127
  googleMapsApiKey: args.googleMapsApiKey || googleMapsApiKey,
114
128
  zyteApiKey: args.zyteApiKey || zyteApiKey,
129
+ apiKey: args.apiKey || apiKey,
115
130
  })),
116
131
  };
117
132
  }
@@ -105,7 +105,9 @@ async function fetchPlaySequenceByPair(playDate, theater, movie, timeout = 15000
105
105
  .filter((item) => item.CinemaID && item.RepresentationMovieCode && item.ScreenID && item.PlaySequence)
106
106
  .map((item) => {
107
107
  const totalSeats = toNumber(item.TotalSeatCount);
108
- const bookedSeats = toNumber(item.BookingSeatCount);
108
+ // 공식 BookingSeatCount는 예매된 수가 아니라 예매 가능(잔여) 좌석 수다.
109
+ const remainingSeats = toNumber(item.BookingSeatCount);
110
+ const bookedSeats = Math.max(totalSeats - remainingSeats, 0);
109
111
  const normalizedPlayDate = normalizeDateForOutput(item.PlayDt || playDate);
110
112
  const theaterId = String(item.CinemaID);
111
113
  const screenId = String(item.ScreenID);
@@ -123,7 +125,7 @@ async function fetchPlaySequenceByPair(playDate, theater, movie, timeout = 15000
123
125
  endTime: formatTime(item.EndTime),
124
126
  totalSeats,
125
127
  bookedSeats,
126
- remainingSeats: Math.max(totalSeats - bookedSeats, 0),
128
+ remainingSeats,
127
129
  };
128
130
  });
129
131
  }
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * 롯데마트 서비스 공통 설정
3
3
  */
4
- export const DEFAULT_LOTTEMART_TIMEOUT_MS = 45000;
4
+ export const DEFAULT_LOTTEMART_TIMEOUT_MS = 15000;