daiso 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +101 -7
  2. package/dist/api/actionsProxy.js +111 -21
  3. package/dist/api/configStatus.js +23 -0
  4. package/dist/api/gs25Handlers.js +68 -22
  5. package/dist/api/handlers.js +32 -3
  6. package/dist/api/healthChecks.js +233 -20
  7. package/dist/api/lottemartHandlers.js +17 -1
  8. package/dist/api/placesHandlers.js +44 -0
  9. package/dist/api/response.js +2 -0
  10. package/dist/api/routes/healthRoutes.js +20 -3
  11. package/dist/api/routes/placesRoutes.js +9 -0
  12. package/dist/bin.js +0 -0
  13. package/dist/cli/args.js +21 -0
  14. package/dist/cli/commandOptions.js +96 -0
  15. package/dist/cli/commands/convenience.js +73 -1
  16. package/dist/cli/commands/daiso.js +18 -2
  17. package/dist/cli/http.js +58 -0
  18. package/dist/cli/interactiveItemSearch.js +4 -2
  19. package/dist/cli/interactivePrompt.js +3 -2
  20. package/dist/cli.js +25 -1
  21. package/dist/cliHelp.js +20 -6
  22. package/dist/cliInteractive.js +75 -6
  23. package/dist/cliPicker.js +1 -0
  24. package/dist/cliRenderer.js +93 -3
  25. package/dist/core/errors.js +66 -0
  26. package/dist/core/registry.js +246 -3
  27. package/dist/index.js +19 -4
  28. package/dist/pages/openapiFullSpec.js +3 -0
  29. package/dist/pages/openapiSpecActions.js +308 -42
  30. package/dist/pages/openapiSpecPathsCu.js +7 -0
  31. package/dist/pages/openapiSpecPathsLotteMart.js +14 -0
  32. package/dist/pages/openapiSpecPathsOliveyoung.js +35 -0
  33. package/dist/pages/openapiSpecPathsPlaces.js +85 -0
  34. package/dist/pages/promptReferenceText.js +16 -7
  35. package/dist/pages/promptRetailCinemaText.js +24 -2
  36. package/dist/services/daiso/client.js +8 -0
  37. package/dist/services/daiso/index.js +2 -0
  38. package/dist/services/daiso/tools/checkInventory.js +4 -2
  39. package/dist/services/daiso/tools/findInventoryByName.js +222 -0
  40. package/dist/services/daiso/tools/getDisplayLocation.js +2 -2
  41. package/dist/services/emart24/client.js +9 -0
  42. package/dist/services/emart24/tools/searchProducts.js +27 -17
  43. package/dist/services/gs25/api.js +3 -0
  44. package/dist/services/gs25/client.js +216 -30
  45. package/dist/services/gs25/index.js +11 -3
  46. package/dist/services/gs25/tools/checkInventory.js +39 -12
  47. package/dist/services/gs25/tools/findNearbyStores.js +19 -5
  48. package/dist/services/gs25/tools/searchProducts.js +39 -25
  49. package/dist/services/lottemart/client.js +10 -1
  50. package/dist/services/lottemart/tools/searchProducts.js +8 -1
  51. package/dist/services/lottemart/zetta.js +1 -1
  52. package/dist/services/oliveyoung/client.js +206 -86
  53. package/dist/services/oliveyoung/tools/checkInventory.js +7 -2
  54. package/dist/services/places/client.js +116 -0
  55. package/dist/services/places/index.js +23 -0
  56. package/dist/services/places/tools/searchNearby.js +56 -0
  57. package/dist/services/places/types.js +1 -0
  58. package/dist/services/seveneleven/client.js +7 -0
  59. package/dist/services/seveneleven/tools/searchProducts.js +29 -18
  60. package/dist/utils/http.js +82 -10
  61. package/dist/utils/zyte.js +74 -26
  62. package/openapi.json +22 -3
  63. package/openapi.yaml +16 -3
  64. package/package.json +28 -9
@@ -2,8 +2,15 @@
2
2
  * 개별 서비스 헬스 체크 실행기
3
3
  */
4
4
  const HEALTH_CHECK_CACHE_TTL_MS = 60_000;
5
- const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 3000;
6
- const MAX_HEALTH_CHECK_TIMEOUT_MS = 10_000;
5
+ const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 7000;
6
+ const MAX_HEALTH_CHECK_TIMEOUT_MS = 20_000;
7
+ const DEFAULT_HEALTH_CHECK_CONCURRENCY = 1;
8
+ const DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS = 0;
9
+ const GS25_CLOUDFRONT_403_PATTERNS = [
10
+ '403 Forbidden',
11
+ 'The request could not be satisfied',
12
+ '403 ERROR',
13
+ ];
7
14
  const HEALTH_CHECKS = [
8
15
  {
9
16
  id: 'cli.contract',
@@ -19,6 +26,8 @@ const HEALTH_CHECKS = [
19
26
  target: 'products',
20
27
  mode: 'quick',
21
28
  path: '/api/daiso/products?q=%ED%85%8C%EC%9D%B4%ED%94%84&pageSize=1',
29
+ collectionKey: 'products',
30
+ requiredFields: ['id', 'name', 'productName', 'itemName', 'goodsName'],
22
31
  },
23
32
  {
24
33
  id: 'cu.stores',
@@ -26,6 +35,8 @@ const HEALTH_CHECKS = [
26
35
  target: 'stores',
27
36
  mode: 'quick',
28
37
  path: '/api/cu/stores?keyword=%EA%B0%95%EB%82%A8&limit=1',
38
+ collectionKey: 'stores',
39
+ requiredFields: ['storeCode', 'storeName', 'name'],
29
40
  },
30
41
  {
31
42
  id: 'emart24.products',
@@ -33,6 +44,8 @@ const HEALTH_CHECKS = [
33
44
  target: 'products',
34
45
  mode: 'quick',
35
46
  path: '/api/emart24/products?keyword=%EC%BB%A4%ED%94%BC&pageSize=1',
47
+ collectionKey: 'products',
48
+ requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
36
49
  },
37
50
  {
38
51
  id: 'gs25.products',
@@ -40,6 +53,9 @@ const HEALTH_CHECKS = [
40
53
  target: 'products',
41
54
  mode: 'quick',
42
55
  path: '/api/gs25/products?keyword=%EC%BD%9C%EB%9D%BC&limit=1',
56
+ collectionKey: 'products',
57
+ requiredFields: ['itemCode', 'itemName', 'name'],
58
+ degradedFailurePatterns: GS25_CLOUDFRONT_403_PATTERNS,
43
59
  },
44
60
  {
45
61
  id: 'gs25.stores',
@@ -47,6 +63,8 @@ const HEALTH_CHECKS = [
47
63
  target: 'stores',
48
64
  mode: 'quick',
49
65
  path: '/api/gs25/stores?keyword=%EA%B0%95%EB%82%A8&limit=1',
66
+ collectionKey: 'stores',
67
+ requiredFields: ['storeCode', 'storeName', 'name'],
50
68
  },
51
69
  {
52
70
  id: 'seveneleven.products',
@@ -54,20 +72,27 @@ const HEALTH_CHECKS = [
54
72
  target: 'products',
55
73
  mode: 'quick',
56
74
  path: '/api/seveneleven/products?query=%EC%BB%A4%ED%94%BC&size=1',
75
+ collectionKey: 'products',
76
+ requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
57
77
  },
58
78
  {
59
79
  id: 'lottemart.products',
60
80
  service: 'lottemart',
61
81
  target: 'products',
62
82
  mode: 'quick',
63
- path: '/api/lottemart/products?keyword=%EC%BD%9C%EB%9D%BC&storeCode=2301&area=%EC%84%9C%EC%9A%B8&pageLimit=1',
83
+ path: '/api/lottemart/products?keyword=%EC%BD%9C%EB%9D%BC&storeCode=2301&area=%EC%84%9C%EC%9A%B8&pageLimit=1&source=zetta',
84
+ collectionKey: 'products',
85
+ requiredFields: ['productCode', 'name', 'productName'],
64
86
  },
65
87
  {
66
88
  id: 'oliveyoung.products',
67
89
  service: 'oliveyoung',
68
90
  target: 'products',
69
91
  mode: 'quick',
70
- path: '/api/oliveyoung/products?keyword=%EC%84%A0%ED%81%AC%EB%A6%BC&size=1',
92
+ path: '/api/oliveyoung/products?keyword=%EB%A6%BD%EB%B0%A4&size=1',
93
+ collectionKey: 'products',
94
+ requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
95
+ timeoutMs: 5000,
71
96
  },
72
97
  {
73
98
  id: 'megabox.theaters',
@@ -75,6 +100,8 @@ const HEALTH_CHECKS = [
75
100
  target: 'theaters',
76
101
  mode: 'quick',
77
102
  path: '/api/megabox/theaters?keyword=%EA%B0%95%EB%82%A8&limit=1',
103
+ collectionKey: 'theaters',
104
+ requiredFields: ['theaterCode', 'theaterName', 'name'],
78
105
  },
79
106
  {
80
107
  id: 'lottecinema.theaters',
@@ -82,6 +109,8 @@ const HEALTH_CHECKS = [
82
109
  target: 'theaters',
83
110
  mode: 'quick',
84
111
  path: '/api/lottecinema/theaters?keyword=%EC%9E%A0%EC%8B%A4&limit=1',
112
+ collectionKey: 'theaters',
113
+ requiredFields: ['theaterCode', 'theaterName', 'name'],
85
114
  },
86
115
  {
87
116
  id: 'cgv.theaters',
@@ -89,6 +118,59 @@ const HEALTH_CHECKS = [
89
118
  target: 'theaters',
90
119
  mode: 'quick',
91
120
  path: '/api/cgv/theaters?keyword=%EA%B0%95%EB%82%A8&limit=1',
121
+ collectionKey: 'theaters',
122
+ requiredFields: ['theaterCode', 'theaterName', 'name'],
123
+ },
124
+ {
125
+ id: 'cu.inventory',
126
+ service: 'cu',
127
+ target: 'inventory',
128
+ mode: 'deep',
129
+ path: '/api/cu/inventory?keyword=%EC%BB%A4%ED%94%BC&size=1&storeLimit=0&storeCheck=false',
130
+ collectionKey: 'inventoryItems',
131
+ requiredFields: ['itemCode', 'itemName', 'name'],
132
+ },
133
+ {
134
+ id: 'emart24.inventory',
135
+ service: 'emart24',
136
+ target: 'inventory',
137
+ mode: 'deep',
138
+ path: '/api/emart24/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
139
+ collectionKey: 'inventoryItems',
140
+ requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
141
+ },
142
+ {
143
+ id: 'gs25.inventory',
144
+ service: 'gs25',
145
+ target: 'inventory',
146
+ mode: 'deep',
147
+ path: '/api/gs25/inventory?keyword=%EC%BD%9C%EB%9D%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
148
+ collectionKey: 'inventoryItems',
149
+ requiredFields: ['itemCode', 'itemName', 'name'],
150
+ degradedFailurePatterns: [
151
+ '401 Unauthorized',
152
+ '인증키가 제공되지 않음',
153
+ ...GS25_CLOUDFRONT_403_PATTERNS,
154
+ ],
155
+ },
156
+ {
157
+ id: 'seveneleven.inventory',
158
+ service: 'seveneleven',
159
+ target: 'inventory',
160
+ mode: 'deep',
161
+ path: '/api/seveneleven/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&size=1',
162
+ collectionKey: 'inventoryItems',
163
+ requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
164
+ },
165
+ {
166
+ id: 'oliveyoung.inventory',
167
+ service: 'oliveyoung',
168
+ target: 'inventory',
169
+ mode: 'deep',
170
+ path: '/api/oliveyoung/inventory?keyword=%EC%84%A0%ED%81%AC%EB%A6%BC&storeKeyword=%EB%AA%85%EB%8F%99&size=1&storeLimit=1&stockCheckLimit=0',
171
+ collectionKey: 'inventoryProducts',
172
+ requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
173
+ timeoutMs: 5000,
92
174
  },
93
175
  ];
94
176
  const healthCheckCache = new Map();
@@ -105,13 +187,15 @@ function createCacheKey(params) {
105
187
  params.check || '',
106
188
  params.mode,
107
189
  params.timeoutMs,
190
+ params.slowThresholdMs || 0,
191
+ params.cacheBust ? 'cache-bust' : 'cache',
108
192
  params.includeSamples ? 'samples' : 'no-samples',
109
193
  ].join('|');
110
194
  }
111
195
  function selectChecks(params) {
112
196
  const mode = params.mode || 'quick';
113
197
  return HEALTH_CHECKS.filter((check) => {
114
- if (check.mode !== mode) {
198
+ if (mode !== 'full' && check.mode !== mode) {
115
199
  return false;
116
200
  }
117
201
  if (params.service && check.service !== params.service) {
@@ -123,6 +207,19 @@ function selectChecks(params) {
123
207
  return true;
124
208
  });
125
209
  }
210
+ async function mapWithConcurrency(items, concurrency, mapper) {
211
+ const results = new Array(items.length);
212
+ let nextIndex = 0;
213
+ const workerCount = Math.min(Math.max(1, concurrency), items.length);
214
+ await Promise.all(Array.from({ length: workerCount }, async () => {
215
+ while (nextIndex < items.length) {
216
+ const currentIndex = nextIndex;
217
+ nextIndex += 1;
218
+ results[currentIndex] = await mapper(items[currentIndex]);
219
+ }
220
+ }));
221
+ return results;
222
+ }
126
223
  function toCount(data) {
127
224
  if (!data || typeof data !== 'object') {
128
225
  return null;
@@ -137,6 +234,15 @@ function toCount(data) {
137
234
  return value.length;
138
235
  }
139
236
  }
237
+ if (record.inventory && typeof record.inventory === 'object') {
238
+ const inventory = record.inventory;
239
+ for (const key of ['products', 'items']) {
240
+ const value = inventory[key];
241
+ if (Array.isArray(value)) {
242
+ return value.length;
243
+ }
244
+ }
245
+ }
140
246
  return null;
141
247
  }
142
248
  function toFirstName(data) {
@@ -156,8 +262,67 @@ function toFirstName(data) {
156
262
  }
157
263
  }
158
264
  }
265
+ if (record.inventory && typeof record.inventory === 'object') {
266
+ const inventory = record.inventory;
267
+ for (const key of ['products', 'items']) {
268
+ const value = inventory[key];
269
+ if (!Array.isArray(value) || !value[0] || typeof value[0] !== 'object') {
270
+ continue;
271
+ }
272
+ const item = value[0];
273
+ for (const nameKey of ['productName', 'itemName', 'goodsName', 'name']) {
274
+ if (typeof item[nameKey] === 'string' && item[nameKey].trim().length > 0) {
275
+ return item[nameKey].trim();
276
+ }
277
+ }
278
+ }
279
+ }
159
280
  return undefined;
160
281
  }
282
+ function getCollectionItems(data, collectionKey) {
283
+ if (!data || typeof data !== 'object') {
284
+ return [];
285
+ }
286
+ const record = data;
287
+ if (collectionKey === 'inventoryProducts' || collectionKey === 'inventoryItems') {
288
+ const inventory = record.inventory;
289
+ if (!inventory || typeof inventory !== 'object') {
290
+ return [];
291
+ }
292
+ const key = collectionKey === 'inventoryProducts' ? 'products' : 'items';
293
+ const value = inventory[key];
294
+ return Array.isArray(value) ? value : [];
295
+ }
296
+ if (collectionKey && Array.isArray(record[collectionKey])) {
297
+ return record[collectionKey];
298
+ }
299
+ for (const key of ['products', 'stores', 'theaters', 'movies', 'showtimes']) {
300
+ /* c8 ignore next -- 현재 정의된 체크는 collectionKey를 명시한다. */
301
+ if (Array.isArray(record[key])) {
302
+ return record[key];
303
+ }
304
+ }
305
+ return [];
306
+ }
307
+ function hasRequiredRepresentativeFields(data, collectionKey, requiredFields = []) {
308
+ /* c8 ignore next -- 현재 정의된 API 체크는 requiredFields를 명시한다. */
309
+ if (requiredFields.length === 0) {
310
+ return true;
311
+ }
312
+ const items = getCollectionItems(data, collectionKey);
313
+ if (items.length === 0) {
314
+ return true;
315
+ }
316
+ const first = items[0];
317
+ if (!first || typeof first !== 'object' || Array.isArray(first)) {
318
+ return false;
319
+ }
320
+ const record = first;
321
+ return requiredFields.some((field) => {
322
+ const value = record[field];
323
+ return typeof value === 'string' ? value.trim().length > 0 : value !== undefined && value !== null;
324
+ });
325
+ }
161
326
  function aggregateStatus(checks) {
162
327
  if (checks.length === 0) {
163
328
  return 'skipped';
@@ -170,9 +335,30 @@ function aggregateStatus(checks) {
170
335
  }
171
336
  return 'ok';
172
337
  }
173
- function buildCheckUrl(baseUrl, check, timeoutMs) {
338
+ function shouldDegradeFailedResponse(check, message) {
339
+ return check.degradedFailurePatterns?.some((pattern) => message.includes(pattern)) ?? false;
340
+ }
341
+ function shouldDegradeCliContractPath(path, message) {
342
+ return path.startsWith('/api/gs25/') && GS25_CLOUDFRONT_403_PATTERNS.some((pattern) => message.includes(pattern));
343
+ }
344
+ function resolveCheckTimeoutMs(check, timeoutMs) {
345
+ if (check.timeoutMs === undefined) {
346
+ return timeoutMs;
347
+ }
348
+ return Math.min(timeoutMs, Math.trunc(check.timeoutMs));
349
+ }
350
+ function resolveCliContractTimeoutMs(path, timeoutMs) {
351
+ if (path.startsWith('/api/oliveyoung/')) {
352
+ return Math.min(timeoutMs, 5000);
353
+ }
354
+ return timeoutMs;
355
+ }
356
+ function buildCheckUrl(baseUrl, check, timeoutMs, cacheBustValue) {
174
357
  const url = new URL(check.path, baseUrl);
175
358
  url.searchParams.set('timeoutMs', String(timeoutMs));
359
+ if (typeof cacheBustValue === 'number') {
360
+ url.searchParams.set('_healthCheck', String(cacheBustValue));
361
+ }
176
362
  return url.toString();
177
363
  }
178
364
  const CLI_CONTRACT_PATHS = [
@@ -201,14 +387,22 @@ function isCliCompatibleEnvelope(path, body) {
201
387
  }
202
388
  async function runCliContractCheck(check, params) {
203
389
  const startedAt = params.now();
390
+ const cacheBustValue = params.cacheBust ? startedAt : undefined;
391
+ const degradedMessages = [];
204
392
  for (const path of CLI_CONTRACT_PATHS) {
393
+ const checkTimeoutMs = resolveCliContractTimeoutMs(path, params.timeoutMs);
205
394
  const syntheticCheck = { ...check, path };
206
395
  try {
207
- const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, syntheticCheck, params.timeoutMs), {
208
- signal: AbortSignal.timeout(params.timeoutMs),
396
+ const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, syntheticCheck, checkTimeoutMs, cacheBustValue), {
397
+ signal: AbortSignal.timeout(checkTimeoutMs),
209
398
  });
210
399
  const body = (await response.json().catch(() => ({})));
211
400
  if (!response.ok || !isCliCompatibleEnvelope(path, body)) {
401
+ const message = body.error?.message || `${path} CLI 계약 응답이 올바르지 않습니다.`;
402
+ if (shouldDegradeCliContractPath(path, message)) {
403
+ degradedMessages.push(`${path}: ${message}`);
404
+ continue;
405
+ }
212
406
  return {
213
407
  id: check.id,
214
408
  service: check.service,
@@ -216,7 +410,7 @@ async function runCliContractCheck(check, params) {
216
410
  status: 'fail',
217
411
  durationMs: params.now() - startedAt,
218
412
  httpStatus: response.status,
219
- message: body.error?.message || `${path} CLI 계약 응답이 올바르지 않습니다.`,
413
+ message,
220
414
  };
221
415
  }
222
416
  }
@@ -235,37 +429,51 @@ async function runCliContractCheck(check, params) {
235
429
  id: check.id,
236
430
  service: check.service,
237
431
  target: check.target,
238
- status: 'ok',
432
+ status: degradedMessages.length > 0 ? 'degraded' : 'ok',
239
433
  durationMs: params.now() - startedAt,
240
- message: `${CLI_CONTRACT_PATHS.length} CLI contract endpoint(s) passed`,
434
+ message: degradedMessages.length > 0
435
+ ? `known upstream issue: ${degradedMessages[0]}`
436
+ : `${CLI_CONTRACT_PATHS.length} CLI contract endpoint(s) passed`,
241
437
  };
242
438
  }
243
439
  async function runSingleCheck(check, params) {
244
440
  if (check.kind === 'cli-contract') {
245
441
  return runCliContractCheck(check, params);
246
442
  }
247
- const timeoutMs = params.timeoutMs;
443
+ const timeoutMs = resolveCheckTimeoutMs(check, params.timeoutMs);
248
444
  const startedAt = params.now();
445
+ const cacheBustValue = params.cacheBust ? startedAt : undefined;
249
446
  try {
250
- const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, check, timeoutMs), {
447
+ const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, check, timeoutMs, cacheBustValue), {
251
448
  signal: AbortSignal.timeout(timeoutMs),
252
449
  });
253
450
  const body = (await response.json().catch(() => ({})));
254
451
  const durationMs = params.now() - startedAt;
255
452
  if (!response.ok || body.success === false) {
453
+ const message = body.error?.message || `HTTP ${response.status}`;
256
454
  return {
257
455
  id: check.id,
258
456
  service: check.service,
259
457
  target: check.target,
260
- status: 'fail',
458
+ status: shouldDegradeFailedResponse(check, message) ? 'degraded' : 'fail',
261
459
  durationMs,
262
460
  httpStatus: response.status,
263
- message: body.error?.message || `HTTP ${response.status}`,
461
+ message,
264
462
  };
265
463
  }
266
464
  const count = typeof body.meta?.total === 'number' ? body.meta.total : toCount(body.data);
267
- const status = count === 0 ? 'degraded' : 'ok';
465
+ const shapeOk = hasRequiredRepresentativeFields(body.data, check.collectionKey, check.requiredFields);
466
+ const slowThresholdMs = params.slowThresholdMs || DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
467
+ const slow = slowThresholdMs > 0 && durationMs > slowThresholdMs;
468
+ const status = count === 0 || !shapeOk || slow ? 'degraded' : 'ok';
268
469
  const first = params.includeSamples ? toFirstName(body.data) : undefined;
470
+ const message = slow
471
+ ? `slow response: ${durationMs}ms > ${slowThresholdMs}ms`
472
+ : !shapeOk && count !== 0
473
+ ? `response missing required fields: ${check.requiredFields.join(', ')}`
474
+ : count === null
475
+ ? 'response ok'
476
+ : `${count} item(s) returned`;
269
477
  return {
270
478
  id: check.id,
271
479
  service: check.service,
@@ -273,7 +481,7 @@ async function runSingleCheck(check, params) {
273
481
  status,
274
482
  durationMs,
275
483
  httpStatus: response.status,
276
- message: count === null ? 'response ok' : `${count} item(s) returned`,
484
+ message,
277
485
  ...(first ? { sample: { first } } : {}),
278
486
  };
279
487
  }
@@ -293,7 +501,10 @@ export async function runHealthChecks(params) {
293
501
  const fetchImpl = params.fetchImpl || ((input, init) => globalThis.fetch(input, init));
294
502
  const mode = params.mode || 'quick';
295
503
  const timeoutMs = clampTimeout(params.timeoutMs ?? DEFAULT_HEALTH_CHECK_TIMEOUT_MS);
296
- const cacheKey = createCacheKey({ ...params, mode, timeoutMs, baseUrl: params.baseUrl });
504
+ const slowThresholdMs = Number.isFinite(params.slowThresholdMs) && params.slowThresholdMs && params.slowThresholdMs > 0
505
+ ? Math.trunc(params.slowThresholdMs)
506
+ : DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
507
+ const cacheKey = createCacheKey({ ...params, mode, timeoutMs, slowThresholdMs, baseUrl: params.baseUrl });
297
508
  const cached = healthCheckCache.get(cacheKey);
298
509
  const startedAt = now();
299
510
  if (!params.fresh && cached && cached.expiresAt > startedAt) {
@@ -302,13 +513,15 @@ export async function runHealthChecks(params) {
302
513
  cached: true,
303
514
  };
304
515
  }
305
- const checks = await Promise.all(selectChecks(params).map((check) => runSingleCheck(check, {
516
+ const checks = await mapWithConcurrency(selectChecks(params), DEFAULT_HEALTH_CHECK_CONCURRENCY, (check) => runSingleCheck(check, {
306
517
  baseUrl: params.baseUrl,
307
518
  fetchImpl,
308
519
  now,
309
520
  timeoutMs,
521
+ slowThresholdMs,
310
522
  includeSamples: params.includeSamples,
311
- })));
523
+ cacheBust: params.cacheBust,
524
+ }));
312
525
  const summary = {
313
526
  status: aggregateStatus(checks),
314
527
  checkedAt: new Date(startedAt).toISOString(),
@@ -5,6 +5,15 @@ import { errorResponse, successResponse } from './response.js';
5
5
  import { fetchLotteMartStores, searchLotteMartProducts } from '../services/lottemart/client.js';
6
6
  import { DEFAULT_LOTTEMART_TIMEOUT_MS } from '../services/lottemart/config.js';
7
7
  import { probeLotteMartUpstream } from '../services/lottemart/debug.js';
8
+ function parseLotteMartProductSource(value) {
9
+ if (!value || value === 'auto') {
10
+ return 'auto';
11
+ }
12
+ if (value === 'legacy' || value === 'zetta') {
13
+ return value;
14
+ }
15
+ return null;
16
+ }
8
17
  /**
9
18
  * 롯데마트 매장 검색 API 핸들러
10
19
  * GET /api/lottemart/stores?keyword={키워드}&lat={위도}&lng={경도}
@@ -62,9 +71,15 @@ export async function handleLotteMartSearchProducts(c) {
62
71
  const storeCode = c.req.query('storeCode') || '';
63
72
  const storeName = c.req.query('storeName') || '';
64
73
  const pageLimit = parseInt(c.req.query('pageLimit') || '3', 10);
74
+ const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || String(DEFAULT_LOTTEMART_TIMEOUT_MS), 10);
75
+ const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : DEFAULT_LOTTEMART_TIMEOUT_MS;
76
+ const source = parseLotteMartProductSource(c.req.query('source'));
65
77
  if (keyword.trim().length === 0) {
66
78
  return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.');
67
79
  }
80
+ if (!source) {
81
+ return errorResponse(c, 'INVALID_LOTTEMART_SOURCE', 'source는 auto, legacy, zetta 중 하나여야 합니다.');
82
+ }
68
83
  if (storeCode.trim().length === 0 && storeName.trim().length === 0) {
69
84
  return errorResponse(c, 'MISSING_STORE', 'storeCode 또는 storeName 중 하나를 입력해주세요.');
70
85
  }
@@ -75,8 +90,9 @@ export async function handleLotteMartSearchProducts(c) {
75
90
  storeName,
76
91
  keyword,
77
92
  pageLimit,
93
+ source,
78
94
  }, {
79
- timeout: DEFAULT_LOTTEMART_TIMEOUT_MS,
95
+ timeout: timeoutMs,
80
96
  zyteApiKey: c.env?.ZYTE_API_KEY,
81
97
  });
82
98
  return successResponse(c, {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 장소 검색 GET API 핸들러
3
+ */
4
+ import { errorResponse, successResponse } from './response.js';
5
+ import { searchNaverLocalPlaces } from '../services/places/client.js';
6
+ import { getErrorMessage } from '../core/errors.js';
7
+ const PLACE_CATEGORIES = new Set(['restaurant', 'cafe', 'food', 'dessert', 'all']);
8
+ function parseCategory(value) {
9
+ if (value && PLACE_CATEGORIES.has(value)) {
10
+ return value;
11
+ }
12
+ return 'all';
13
+ }
14
+ export async function handlePlacesSearch(c) {
15
+ const location = c.req.query('location') || '';
16
+ const keyword = c.req.query('keyword') || '';
17
+ const limit = Number.parseInt(c.req.query('limit') || '5', 10);
18
+ const start = Number.parseInt(c.req.query('start') || '1', 10);
19
+ const sort = c.req.query('sort') === 'comment' ? 'comment' : 'random';
20
+ const category = parseCategory(c.req.query('category'));
21
+ if (location.trim().length === 0 && keyword.trim().length === 0) {
22
+ return errorResponse(c, 'MISSING_QUERY', '위치(location) 또는 검색어(keyword)를 입력해주세요.');
23
+ }
24
+ try {
25
+ const result = await searchNaverLocalPlaces({
26
+ naverClientId: c.env?.NAVER_CLIENT_ID,
27
+ naverClientSecret: c.env?.NAVER_CLIENT_SECRET,
28
+ location,
29
+ keyword,
30
+ category,
31
+ limit,
32
+ start,
33
+ sort,
34
+ });
35
+ return successResponse(c, result, {
36
+ total: result.totalCount,
37
+ page: start,
38
+ pageSize: result.count,
39
+ });
40
+ }
41
+ catch (error) {
42
+ return errorResponse(c, 'PLACES_SEARCH_FAILED', getErrorMessage(error), 500);
43
+ }
44
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * API 공통 응답 유틸리티
3
3
  */
4
+ import { toStandardErrorDiagnostics } from '../core/errors.js';
4
5
  /**
5
6
  * 성공 응답 생성
6
7
  */
@@ -18,5 +19,6 @@ export function errorResponse(c, code, message, status = 400) {
18
19
  return c.json({
19
20
  success: false,
20
21
  error: { code, message },
22
+ diagnostics: toStandardErrorDiagnostics(code, message, { status }),
21
23
  }, status);
22
24
  }
@@ -14,6 +14,15 @@ function readHealthCheckToken(headers) {
14
14
  }
15
15
  return (headers.get('x-health-check-key') || '').trim();
16
16
  }
17
+ function parseMode(value) {
18
+ if (!value || value === 'quick') {
19
+ return 'quick';
20
+ }
21
+ if (value === 'deep' || value === 'full') {
22
+ return value;
23
+ }
24
+ return null;
25
+ }
17
26
  export function registerHealthRoutes(app) {
18
27
  app.get('/api/health/checks', async (c) => {
19
28
  const secret = c.env?.HEALTH_CHECK_SECRET?.trim();
@@ -23,10 +32,16 @@ export function registerHealthRoutes(app) {
23
32
  if (readHealthCheckToken(c.req.raw.headers) !== secret) {
24
33
  return errorResponse(c, 'UNAUTHORIZED_HEALTH_CHECK', '유효한 헬스 체크 시크릿 키가 필요합니다.', 401);
25
34
  }
26
- const mode = c.req.query('mode') === 'deep' ? 'deep' : 'quick';
35
+ const mode = parseMode(c.req.query('mode'));
36
+ if (!mode) {
37
+ return errorResponse(c, 'INVALID_HEALTH_CHECK_MODE', 'mode는 quick, deep, full 중 하나여야 합니다.', 400);
38
+ }
27
39
  const timeoutMs = Number.parseInt(c.req.query('timeoutMs') || '3000', 10);
28
- const baseUrl = c.env?.HEALTH_CHECK_BASE_URL?.trim() || new URL(c.req.url).origin;
29
- const fetchImpl = c.env?.HEALTH_CHECK_BASE_URL && c.env?.HEALTH_CHECK_TRANSPORT !== 'network'
40
+ const slowThresholdMs = Number.parseInt(c.req.query('slowThresholdMs') || c.env?.HEALTH_CHECK_SLOW_THRESHOLD_MS || '0', 10);
41
+ const baseUrl = c.req.query('baseUrl')?.trim() || c.env?.HEALTH_CHECK_BASE_URL?.trim() || new URL(c.req.url).origin;
42
+ const transport = c.req.query('transport') || c.env?.HEALTH_CHECK_TRANSPORT || 'internal';
43
+ const cacheBust = parseBoolean(c.req.query('cacheBust')) || transport === 'network';
44
+ const fetchImpl = transport !== 'network'
30
45
  ? async (input, init) => app.fetch(new Request(input, init), c.env)
31
46
  : undefined;
32
47
  const result = await runHealthChecks({
@@ -35,8 +50,10 @@ export function registerHealthRoutes(app) {
35
50
  check: c.req.query('check') || undefined,
36
51
  mode,
37
52
  timeoutMs,
53
+ slowThresholdMs,
38
54
  includeSamples: parseBoolean(c.req.query('includeSamples')),
39
55
  fresh: parseBoolean(c.req.query('fresh')),
56
+ cacheBust,
40
57
  fetchImpl,
41
58
  });
42
59
  return c.json(result);
@@ -0,0 +1,9 @@
1
+ import { withEdgeCache } from '../../utils/cache.js';
2
+ import { handlePlacesSearch } from '../placesHandlers.js';
3
+ export function registerPlacesRoutes(app) {
4
+ app.get('/api/places/search', async (c) => withEdgeCache(c.req.url, {
5
+ ttlSeconds: 60 * 60,
6
+ staleWhileRevalidateSeconds: 60 * 10,
7
+ keyPrefix: 'places-search-v1',
8
+ }, () => handlePlacesSearch(c)));
9
+ }
package/dist/bin.js CHANGED
File without changes
package/dist/cli/args.js CHANGED
@@ -52,3 +52,24 @@ export function toQueryOptions(options) {
52
52
  }
53
53
  return queryOptions;
54
54
  }
55
+ export function findUnknownOption(options, allowedOptions) {
56
+ const allowed = new Set(allowedOptions);
57
+ for (const key of Object.keys(options)) {
58
+ if (!allowed.has(key)) {
59
+ return key;
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+ export function writeUnknownOptionError(options, allowedOptions, writeErr, hint) {
65
+ const unknownOption = findUnknownOption(options, allowedOptions);
66
+ if (!unknownOption) {
67
+ return false;
68
+ }
69
+ writeErr(`알 수 없는 옵션: --${unknownOption}`);
70
+ if (hint) {
71
+ writeErr(hint);
72
+ }
73
+ writeErr('도움말: daiso help');
74
+ return true;
75
+ }