daiso 1.0.10 → 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 (82) hide show
  1. package/README.md +75 -5
  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 +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 +17 -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 +16 -30
  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 +12 -0
  38. package/dist/services/cgv/transport.js +19 -2
  39. package/dist/services/cu/client.js +42 -12
  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 +15 -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 +1 -0
  59. package/dist/services/gs25/storeStockTransport.js +55 -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 +29 -160
  65. package/dist/services/lottemart/socketTransport.js +149 -0
  66. package/dist/services/oliveyoung/client.js +1 -0
  67. package/dist/services/seveneleven/client.js +18 -8
  68. package/dist/services/seveneleven/index.js +11 -7
  69. package/dist/services/seveneleven/inventory.js +5 -2
  70. package/dist/services/seveneleven/productKeyword.js +3 -2
  71. package/dist/services/seveneleven/tools/checkInventory.js +4 -3
  72. package/dist/services/seveneleven/tools/getCatalogSnapshot.js +4 -3
  73. package/dist/services/seveneleven/tools/getSearchPopwords.js +4 -3
  74. package/dist/services/seveneleven/tools/searchProducts.js +4 -3
  75. package/dist/services/seveneleven/tools/searchStores.js +4 -3
  76. package/dist/utils/format.js +10 -3
  77. package/dist/utils/http.js +38 -10
  78. package/dist/utils/zyte.js +13 -2
  79. package/dist/utils/zyteJsonFallback.js +67 -0
  80. package/openapi.json +51 -3
  81. package/openapi.yaml +36 -3
  82. package/package.json +28 -27
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ [![MCP Toplist](https://mcptoplist.com/badge/glama%2Fhmmhmmhm%2Fdaiso-mcp.svg)](https://mcptoplist.com/server/glama%2Fhmmhmmhm%2Fdaiso-mcp)
2
+
1
3
  <div align="center">
2
4
 
3
5
  <img src="./assets/logo.svg" alt="Daiso MCP" width="120" height="120">
@@ -7,7 +9,9 @@
7
9
 
8
10
  # Daiso MCP 및 Skill
9
11
 
10
- 다이소(제품/매장/재고), 상품 가격 비교, 주변 음식점/카페, 주유소/유가, 개발자 요청 제출, 롯데마트(매장/상품), GS25(매장/상품/재고), 세븐일레븐(상품/매장/재고/인기검색어/카탈로그), CU(매장/재고), 이마트24(매장/상품/재고), 올리브영(매장/재고), 메가박스(지점/영화/시간표/좌석), 롯데시네마(지점/영화/좌석), CGV(극장/영화/시간표) 조회 기능을 MCP, CLI, Codex Skill로 AI에 연결합니다.
12
+ 다이소(제품/매장/재고), 상품 가격 비교, 주변 음식점/카페, 주유소/유가, 개발자 요청 제출, 롯데마트(매장/상품), GS25(매장/상품/재고), 세븐일레븐(상품/매장/재고/인기검색어/카탈로그), CU(매장/재고), 이마트24(매장/상품/재고), 올리브영(매장/재고), 메가박스(지점/영화/시간표/좌석), 롯데시네마(지점/영화/좌석), CGV(극장/영화/시간표), 디트릭스(독립·예술영화관/상영작/잔여 좌석) 조회 기능을 MCP, CLI, Codex Skill로 AI에 연결합니다.
13
+
14
+ 디트릭스의 지원 범위와 CLI 예시는 [독립·예술영화관 조회 가이드](docs/dtryx.md)를 참고하세요.
11
15
 
12
16
  <br>
13
17
 
@@ -81,11 +85,19 @@
81
85
  **[실시간 서비스 상태 보기](https://aka-page.betteruptime.com/)**
82
86
 
83
87
  <!-- WORKERS_INVOCATIONS_CHART:START -->
84
- <h3>Cloudflare Workers 호출량 (2026-06-14 ~ 2026-07-13, 30일)</h3>
88
+ <h3>Cloudflare 요청 수 (2026-08-16 ~ 2026-09-14, 30일)</h3>
89
+
90
+ <img src="./assets/analytics/workers-invocations.png?v=2026-09-14T19:51:30.925Z" alt="Cloudflare 요청 수 그래프 (2026-08-16 ~ 2026-09-14)" width="100%">
91
+
92
+ <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-09-15 04:51 KST</sub>
93
+ <br><sub>집계: Worker 실행 + 루트 GET 리디렉션 요청 · 사용자 수와 다릅니다.</sub>
94
+
95
+ </div>
85
96
 
86
- <img src="./assets/analytics/workers-invocations.png?v=2026-07-13T16:10:36.883Z" alt="Cloudflare Workers 호출량 그래프 (2026-06-14 ~ 2026-07-13)" width="100%">
97
+ > [!IMPORTANT]
98
+ > 최근 공개 서버 사용량이 크게 증가하여 2026년 7월 18일부터 올리브영·CGV·CU·GS25·롯데마트의 검색을 포함한 공개 GET API에 IP당 하루 합산 3,000회(KST 기준)의 호출 제한을 적용합니다. 한도를 초과하는 사용이 필요하다면 Daiso MCP는 오픈 소스이므로 이 저장소를 직접 배포해 이용해 주세요.
87
99
 
88
- <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-07-14 01:10 KST</sub>
100
+ <div align="center">
89
101
 
90
102
  <!-- WORKERS_INVOCATIONS_CHART:END -->
91
103
 
@@ -105,7 +117,7 @@
105
117
 
106
118
  ## AI 앱에서 MCP 연결하기
107
119
 
108
- ChatGPT, Claude, Grok 같은 AI 앱에서 바로 연결해 사용할 수 있습니다.
120
+ ChatGPT, Claude, Home Assistant, Grok 같은 AI 앱에서 바로 연결해 사용할 수 있습니다.
109
121
  아래 앱별 가이드에서 먼저 연동한 뒤 검색/재고/영화 조회를 요청하세요.
110
122
 
111
123
  <br>
@@ -197,6 +209,19 @@ claude mcp add daiso-mcp https://mcp.aka.page --transport http
197
209
 
198
210
  <br>
199
211
 
212
+ ### Home Assistant
213
+
214
+ > Home Assistant Core 2026.7.1에서 MCP 도구 40개를 모두 정상적으로 불러오는 것을 확인했습니다.
215
+
216
+ 1. Home Assistant에서 **Settings** → **Devices & services**로 이동
217
+ 2. **Add Integration**을 선택하고 **Model Context Protocol**을 검색
218
+ 3. 서버 URL 입력: `https://mcp.aka.page`
219
+ 4. 연동을 마친 뒤 사용할 대화 에이전트가 MCP 도구를 사용하도록 설정
220
+
221
+ 참고: [Home Assistant Model Context Protocol 통합 가이드](https://www.home-assistant.io/integrations/mcp)
222
+
223
+ <br>
224
+
200
225
  ### ![Grok](https://img.shields.io/badge/Grok-000000?logo=x&logoColor=white)
201
226
 
202
227
  > 웹 및 모바일 앱 모두 지원
@@ -443,6 +468,51 @@ curl -H "Authorization: Bearer $HEALTH_CHECK_SECRET" \
443
468
 
444
469
  Better Stack 같은 외부 모니터링에서는 `fail`을 장애 알림 기준으로 보고, `degraded`는 느린 외부 API나 응답 품질 저하를 추적하는 경고 신호로 봅니다.
445
470
 
471
+ ### 운영 통계
472
+
473
+ 일일 호출 제한으로 차단한 요청의 집계는 인증된 `GET /api/rate-limit/stats`에서 조회합니다. 헬스 체크와 같은 `HEALTH_CHECK_SECRET`을 사용하며, 다음 두 인증 헤더를 모두 지원합니다.
474
+
475
+ ```bash
476
+ curl -H "Authorization: Bearer $HEALTH_CHECK_SECRET" \
477
+ "https://mcp.aka.page/api/rate-limit/stats"
478
+
479
+ curl -H "x-health-check-key: $HEALTH_CHECK_SECRET" \
480
+ "https://mcp.aka.page/api/rate-limit/stats?service=cgv"
481
+ ```
482
+
483
+ 쿼리 필터는 `from`, `to`, `service`입니다. `from`과 `to`는 함께 지정하거나 둘 다 생략해야 하며 날짜 형식은 `YYYY-MM-DD`입니다. `service`에는 `oliveyoung`, `cgv`, `cu`, `gs25`, `lottemart`만 사용할 수 있습니다. 날짜를 생략하면 현재 KST 일자를 포함한 최근 7일을 조회합니다. 조회 가능한 보관 범위는 현재 KST 일자와 그 이전 29일이며, 한 번에 KST 달력 날짜 기준 최대 30일을 요청할 수 있습니다.
484
+
485
+ 성공 응답은 전체 합계와 일별·서비스별 차단 요청 수와 고유 차단 주체 수를 제공합니다.
486
+
487
+ ```json
488
+ {
489
+ "success": true,
490
+ "data": {
491
+ "totals": {
492
+ "blockedRequests": 3,
493
+ "uniqueIdentities": 2
494
+ },
495
+ "daily": [
496
+ {
497
+ "day": "2026-07-22",
498
+ "blockedRequests": 3,
499
+ "uniqueIdentities": 2
500
+ }
501
+ ],
502
+ "services": [
503
+ {
504
+ "day": "2026-07-22",
505
+ "service": "cgv",
506
+ "blockedRequests": 3,
507
+ "uniqueIdentities": 2
508
+ }
509
+ ]
510
+ }
511
+ }
512
+ ```
513
+
514
+ 데이터는 30일 동안 보관하며 집계 응답은 원본 호출 주체나 IP를 노출하지 않습니다. Worker가 생성한 `DAILY_RATE_LIMIT_EXCEEDED` 결정 중 원장 커밋에 성공한 경우만 정확한 집계 범위에 포함됩니다. Cloudflare 또는 네트워크 계층의 429와 클라이언트 전송 결과, 연결 종료 결과는 이 범위에 포함되지 않습니다. 원장 쓰기에 실패하면 요청을 fail-open 처리하고 애플리케이션 429를 반환하지 않습니다. 통계는 이 기능의 배포 시점부터 수집하며 이전 429는 소급 집계하지 않습니다.
515
+
446
516
  배포 전 로컬에서 CLI 모드까지 확인할 때는 아래 명령을 사용합니다.
447
517
 
448
518
  ```bash
@@ -2,6 +2,9 @@
2
2
  * OpenAI Actions용 공통 GET 프록시 정의
3
3
  */
4
4
  export const ACTION_QUERY_DEFINITIONS = [
5
+ { action: 'dtryxListCinemas', summary: '디트릭스 극장 목록', targetPath: '/api/dtryx/cinemas' },
6
+ { action: 'dtryxListNowShowing', summary: '디트릭스 상영작', targetPath: '/api/dtryx/movies' },
7
+ { action: 'dtryxGetRemainingSeats', summary: '디트릭스 잔여 좌석', targetPath: '/api/dtryx/seats' },
5
8
  { action: 'daisoSearchProducts', summary: '다이소 제품 검색', targetPath: '/api/daiso/products' },
6
9
  {
7
10
  action: 'daisoGetProduct',
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * CGV GET API 핸들러
3
3
  */
4
- import { fetchCgvMovies, fetchCgvTheaters, fetchCgvTimetable, toYyyymmdd } from '../services/cgv/client.js';
4
+ import { fetchCgvMovies, fetchCgvTheaters, fetchCgvTimetable, toYyyymmdd, } from '../services/cgv/client.js';
5
+ import { isCgvUpstreamUnavailableError } from '../services/cgv/errors.js';
5
6
  import { fetchCgvNearbyTheaters, resolveCgvNearestTheater } from '../services/cgv/location.js';
6
7
  import { filterAndSortTimetable } from '../services/cgv/timetable.js';
7
8
  import { errorResponse, successResponse } from './response.js';
@@ -60,6 +61,9 @@ export async function handleCgvFindTheaters(c) {
60
61
  }, { total: sliced.length, pageSize: limit });
61
62
  }
62
63
  catch (error) {
64
+ if (isCgvUpstreamUnavailableError(error)) {
65
+ return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503);
66
+ }
63
67
  const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
64
68
  return errorResponse(c, 'CGV_THEATER_SEARCH_FAILED', message, 500);
65
69
  }
@@ -77,7 +81,8 @@ export async function handleCgvSearchMovies(c) {
77
81
  const timeoutMs = parseInt(c.req.query('timeoutMs') || '15000');
78
82
  try {
79
83
  let resolvedTheater = null;
80
- if (!theaterCode && (keyword || typeof latitude === 'number' || typeof longitude === 'number')) {
84
+ if (!theaterCode &&
85
+ (keyword || typeof latitude === 'number' || typeof longitude === 'number')) {
81
86
  const resolved = await resolveCgvNearestTheater({
82
87
  playDate,
83
88
  keyword,
@@ -114,6 +119,9 @@ export async function handleCgvSearchMovies(c) {
114
119
  }, { total: movies.length });
115
120
  }
116
121
  catch (error) {
122
+ if (isCgvUpstreamUnavailableError(error)) {
123
+ return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503);
124
+ }
117
125
  const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
118
126
  return errorResponse(c, 'CGV_MOVIE_SEARCH_FAILED', message, 500);
119
127
  }
@@ -133,7 +141,8 @@ export async function handleCgvGetTimetable(c) {
133
141
  const timeoutMs = parseInt(c.req.query('timeoutMs') || '15000');
134
142
  try {
135
143
  let resolvedTheater = null;
136
- if (!theaterCode && (keyword || typeof latitude === 'number' || typeof longitude === 'number')) {
144
+ if (!theaterCode &&
145
+ (keyword || typeof latitude === 'number' || typeof longitude === 'number')) {
137
146
  const resolved = await resolveCgvNearestTheater({
138
147
  playDate,
139
148
  keyword,
@@ -178,6 +187,9 @@ export async function handleCgvGetTimetable(c) {
178
187
  }, { total: filtered.length, pageSize: limit });
179
188
  }
180
189
  catch (error) {
190
+ if (isCgvUpstreamUnavailableError(error)) {
191
+ return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503);
192
+ }
181
193
  const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
182
194
  return errorResponse(c, 'CGV_TIMETABLE_FETCH_FAILED', message, 500);
183
195
  }
@@ -9,7 +9,7 @@ export function buildConfigStatus(bindings) {
9
9
  },
10
10
  zyteApiKey: {
11
11
  configured: isConfigured(bindings?.ZYTE_API_KEY),
12
- usedBy: ['oliveyoung', 'gs25', 'lottemart', 'cgv'],
12
+ usedBy: ['oliveyoung', 'gs25', 'cu', 'seveneleven', 'lottemart', 'cgv'],
13
13
  },
14
14
  naverLocalSearch: {
15
15
  configured: isConfigured(bindings?.NAVER_CLIENT_ID) && isConfigured(bindings?.NAVER_CLIENT_SECRET),
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 디트릭스 GET API 핸들러
3
+ */
4
+ import { resolveDtryxCinemas } from '../services/dtryx/location.js';
5
+ import { DTRYX_INVALID_INPUT, hasInvalidDtryxOptions } from '../services/dtryx/validation.js';
6
+ import { fetchDtryxNowShowing, fetchDtryxPlayDates, fetchDtryxTimetable, toYyyymmdd, } from '../services/dtryx/client.js';
7
+ import { errorResponse, successResponse } from './response.js';
8
+ export async function handleDtryxListCinemas(c) {
9
+ const keyword = c.req.query('keyword') || undefined;
10
+ const region = c.req.query('region') || undefined;
11
+ const brandCode = c.req.query('brandCode') || undefined;
12
+ const limit = Number(c.req.query('limit') ?? '50');
13
+ if (hasInvalidDtryxOptions({ limit })) {
14
+ return errorResponse(c, 'DTRYX_INVALID_INPUT', DTRYX_INVALID_INPUT, 400);
15
+ }
16
+ const cinemas = resolveDtryxCinemas({ keyword, region, brandCode }).slice(0, limit);
17
+ return successResponse(c, {
18
+ filters: {
19
+ keyword: keyword || null,
20
+ region: region || null,
21
+ brandCode: brandCode || null,
22
+ limit,
23
+ },
24
+ count: cinemas.length,
25
+ cinemas,
26
+ }, { total: cinemas.length, pageSize: limit });
27
+ }
28
+ export async function handleDtryxListNowShowing(c) {
29
+ const cinemaCode = c.req.query('cinemaCode') || undefined;
30
+ const keyword = c.req.query('keyword') || undefined;
31
+ const brandCode = c.req.query('brandCode') || undefined;
32
+ const includePlayDates = c.req.query('includePlayDates') === 'true';
33
+ const timeoutMs = Number(c.req.query('timeoutMs') ?? '15000');
34
+ if (hasInvalidDtryxOptions({ timeoutMs })) {
35
+ return errorResponse(c, 'DTRYX_INVALID_INPUT', DTRYX_INVALID_INPUT, 400);
36
+ }
37
+ const cinema = cinemaCode || keyword ? resolveDtryxCinemas({ cinemaCode, keyword, brandCode })[0] : undefined;
38
+ if (!cinema) {
39
+ return errorResponse(c, 'DTRYX_CINEMA_NOT_FOUND', '극장을 찾을 수 없습니다. cinemaCode 또는 keyword 를 확인하세요.', 404);
40
+ }
41
+ try {
42
+ const request = {
43
+ brandCode: cinema.brandCode,
44
+ cinemaCode: cinema.cinemaCode,
45
+ timeout: timeoutMs,
46
+ };
47
+ const [movies, playDates] = await Promise.all([
48
+ fetchDtryxNowShowing(request),
49
+ includePlayDates ? fetchDtryxPlayDates(request) : Promise.resolve([]),
50
+ ]);
51
+ return successResponse(c, {
52
+ cinema,
53
+ count: movies.length,
54
+ movies,
55
+ playDates: includePlayDates ? playDates : undefined,
56
+ }, { total: movies.length });
57
+ }
58
+ catch (error) {
59
+ const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
60
+ return errorResponse(c, 'DTRYX_NOW_SHOWING_FAILED', message, 500);
61
+ }
62
+ }
63
+ export async function handleDtryxGetRemainingSeats(c) {
64
+ const playDate = c.req.query('playDate') || toYyyymmdd();
65
+ const cinemaCode = c.req.query('cinemaCode') || undefined;
66
+ const keyword = c.req.query('keyword') || undefined;
67
+ const brandCode = c.req.query('brandCode') || undefined;
68
+ const movieName = c.req.query('movieName') || undefined;
69
+ const region = c.req.query('region') || undefined;
70
+ const limit = Number(c.req.query('limit') ?? '50');
71
+ const timeoutMs = Number(c.req.query('timeoutMs') ?? '15000');
72
+ if (hasInvalidDtryxOptions({ limit, timeoutMs, playDate })) {
73
+ return errorResponse(c, 'DTRYX_INVALID_INPUT', DTRYX_INVALID_INPUT, 400);
74
+ }
75
+ const cinemas = resolveDtryxCinemas({ cinemaCode, keyword, brandCode, region });
76
+ if (cinemas.length === 0) {
77
+ return errorResponse(c, 'DTRYX_CINEMA_NOT_FOUND', '극장을 찾을 수 없습니다. cinemaCode 또는 keyword 를 확인하세요.', 404);
78
+ }
79
+ // Promise.allSettled 로 극장별 실패를 흡수하므로 여기서 예외가 전파되지 않습니다.
80
+ const settled = await Promise.allSettled(cinemas.map((cinema) => fetchDtryxTimetable({
81
+ brandCode: cinema.brandCode,
82
+ cinemaCode: cinema.cinemaCode,
83
+ playDate,
84
+ timeout: timeoutMs,
85
+ })));
86
+ const failedCinemas = cinemas
87
+ .filter((_, index) => settled[index]?.status === 'rejected')
88
+ .map((cinema) => cinema.cinemaName || cinema.cinemaCode);
89
+ if (failedCinemas.length === cinemas.length) {
90
+ return errorResponse(c, 'DTRYX_SEATS_FAILED', `모든 극장 조회에 실패했습니다: ${failedCinemas.join(', ')}`, 503);
91
+ }
92
+ const normalize = (value) => value.replace(/\s+/g, '').toLowerCase();
93
+ const showtimes = settled
94
+ .flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
95
+ .filter((item) => (movieName ? normalize(item.movieName).includes(normalize(movieName)) : true))
96
+ .sort((a, b) => a.startTime === b.startTime
97
+ ? a.cinemaName.localeCompare(b.cinemaName)
98
+ : a.startTime.localeCompare(b.startTime))
99
+ .slice(0, limit);
100
+ return successResponse(c, {
101
+ playDate,
102
+ filters: {
103
+ cinemaCode: cinemaCode || null,
104
+ keyword: keyword || null,
105
+ region: region || null,
106
+ movieName: movieName || null,
107
+ },
108
+ searchedCinemaCount: cinemas.length,
109
+ failedCinemas,
110
+ count: showtimes.length,
111
+ showtimes,
112
+ }, { total: showtimes.length, pageSize: limit });
113
+ }
@@ -15,6 +15,8 @@ export async function handleEmart24FindStores(c) {
15
15
  const service24h = c.req.query('service24h') === 'true';
16
16
  const page = parseInt(c.req.query('page') || '1', 10);
17
17
  const limit = parseInt(c.req.query('limit') || '20', 10);
18
+ const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
19
+ const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000;
18
20
  const rawLat = c.req.query('lat');
19
21
  const rawLng = c.req.query('lng');
20
22
  const parsedLat = rawLat ? parseFloat(rawLat) : undefined;
@@ -29,7 +31,7 @@ export async function handleEmart24FindStores(c) {
29
31
  page,
30
32
  service24h,
31
33
  }, {
32
- timeout: 15000,
34
+ timeout: timeoutMs,
33
35
  });
34
36
  const stores = result.stores
35
37
  .map((store) => {
@@ -72,6 +74,8 @@ export async function handleEmart24SearchProducts(c) {
72
74
  const pageSize = parseInt(c.req.query('pageSize') || '10', 10);
73
75
  const sortType = c.req.query('sortType') || 'SALE';
74
76
  const saleProductYn = c.req.query('saleProductYn') || 'N';
77
+ const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
78
+ const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000;
75
79
  if (keyword.trim().length === 0) {
76
80
  return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.');
77
81
  }
@@ -83,7 +87,7 @@ export async function handleEmart24SearchProducts(c) {
83
87
  sortType,
84
88
  saleProductYn,
85
89
  }, {
86
- timeout: 15000,
90
+ timeout: timeoutMs,
87
91
  });
88
92
  return successResponse(c, {
89
93
  keyword,
@@ -4,6 +4,7 @@
4
4
  /* c8 ignore start */
5
5
  import { errorResponse, successResponse } from './response.js';
6
6
  import { attachDistanceToGs25Stores, fetchGs25SearchProducts, fetchGs25Stores, fetchGs25WebStores, filterGs25StoresByKeyword, geocodeGs25Address, selectGs25StoresForKeyword, sortGs25Stores, } from '../services/gs25/client.js';
7
+ import { isGs25UpstreamUnavailableError } from '../services/gs25/errors.js';
7
8
  const GS25_FALLBACK_STORE_LOOKUP_ITEM_CODE = '8801117752804';
8
9
  /**
9
10
  * GS25 매장 검색 API 핸들러
@@ -33,6 +34,7 @@ export async function handleGs25FindStores(c) {
33
34
  geocodeUsed = true;
34
35
  }
35
36
  }
37
+ let fallbackUsed = false;
36
38
  let storeResult = await fetchGs25Stores({
37
39
  serviceCode,
38
40
  latitude,
@@ -40,9 +42,15 @@ export async function handleGs25FindStores(c) {
40
42
  }, {
41
43
  timeout: 20000,
42
44
  zyteApiKey: c.env?.ZYTE_API_KEY,
45
+ apiKey: c.env?.GS25_API_KEY,
46
+ }).catch(async (error) => {
47
+ if (!isGs25UpstreamUnavailableError(error) || keyword.trim().length === 0)
48
+ throw error;
49
+ const webStoreResult = await fetchGs25WebStores(keyword, { timeout: 20000 });
50
+ fallbackUsed = true;
51
+ return { ...webStoreResult, cacheHit: false };
43
52
  });
44
- let fallbackUsed = false;
45
- if (storeResult.stores.length === 0 &&
53
+ if (!fallbackUsed && storeResult.stores.length === 0 &&
46
54
  typeof latitude === 'number' &&
47
55
  typeof longitude === 'number') {
48
56
  try {
@@ -56,6 +64,7 @@ export async function handleGs25FindStores(c) {
56
64
  }, {
57
65
  timeout: 20000,
58
66
  zyteApiKey: c.env?.ZYTE_API_KEY,
67
+ apiKey: c.env?.GS25_API_KEY,
59
68
  });
60
69
  if (fallbackResult.stores.length > 0) {
61
70
  storeResult = fallbackResult;
@@ -66,23 +75,10 @@ export async function handleGs25FindStores(c) {
66
75
  fallbackUsed = false;
67
76
  }
68
77
  }
69
- if (storeResult.stores.length === 0 && keyword.trim().length > 0) {
70
- try {
71
- const webStoreResult = await fetchGs25WebStores(keyword, {
72
- timeout: 20000,
73
- });
74
- if (webStoreResult.stores.length > 0) {
75
- storeResult = {
76
- totalCount: webStoreResult.totalCount,
77
- stores: webStoreResult.stores,
78
- cacheHit: false,
79
- };
80
- fallbackUsed = true;
81
- }
82
- }
83
- catch {
84
- fallbackUsed = false;
85
- }
78
+ if (!fallbackUsed && storeResult.stores.length === 0 && keyword.trim().length > 0) {
79
+ const webStoreResult = await fetchGs25WebStores(keyword, { timeout: 20000 });
80
+ storeResult = { ...webStoreResult, cacheHit: false };
81
+ fallbackUsed = true;
86
82
  }
87
83
  const selected = selectGs25StoresForKeyword(storeResult.stores, keyword, {
88
84
  relaxWhenEmpty: typeof latitude === 'number' && typeof longitude === 'number',
@@ -185,6 +181,7 @@ export async function handleGs25CheckInventory(c) {
185
181
  }, {
186
182
  timeout: 20000,
187
183
  zyteApiKey: c.env?.ZYTE_API_KEY,
184
+ apiKey: c.env?.GS25_API_KEY,
188
185
  });
189
186
  const firstAddress = filterGs25StoresByKeyword(baseStores.stores, storeKeyword).find((store) => store.address.trim().length > 0)?.address || '';
190
187
  if (firstAddress.length > 0) {
@@ -216,6 +213,7 @@ export async function handleGs25CheckInventory(c) {
216
213
  }, {
217
214
  timeout: 20000,
218
215
  zyteApiKey: c.env?.ZYTE_API_KEY,
216
+ apiKey: c.env?.GS25_API_KEY,
219
217
  });
220
218
  }
221
219
  else {
@@ -237,6 +235,7 @@ export async function handleGs25CheckInventory(c) {
237
235
  }, {
238
236
  timeout: 20000,
239
237
  zyteApiKey: c.env?.ZYTE_API_KEY,
238
+ apiKey: c.env?.GS25_API_KEY,
240
239
  });
241
240
  }
242
241
  else {
@@ -250,6 +249,7 @@ export async function handleGs25CheckInventory(c) {
250
249
  }, {
251
250
  timeout: 20000,
252
251
  zyteApiKey: c.env?.ZYTE_API_KEY,
252
+ apiKey: c.env?.GS25_API_KEY,
253
253
  });
254
254
  }
255
255
  }
@@ -295,6 +295,9 @@ export async function handleGs25CheckInventory(c) {
295
295
  });
296
296
  }
297
297
  catch (error) {
298
+ if (isGs25UpstreamUnavailableError(error)) {
299
+ return errorResponse(c, 'GS25_UPSTREAM_UNAVAILABLE', error.message, 503);
300
+ }
298
301
  const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
299
302
  return errorResponse(c, 'GS25_INVENTORY_CHECK_FAILED', message, 500);
300
303
  }
@@ -229,6 +229,7 @@ export async function handleCuCheckInventory(c) {
229
229
  searchSort,
230
230
  }, {
231
231
  timeout: 15000,
232
+ apiKey: c.env?.ZYTE_API_KEY,
232
233
  });
233
234
  const firstStockItem = stockResult.items.find((item) => item.itemCode.trim().length > 0) || null;
234
235
  const hasInputLocation = typeof lat === 'number' && typeof lng === 'number';
@@ -300,6 +301,8 @@ export async function handleCuCheckInventory(c) {
300
301
  stores: storeResult.stores.slice(0, storeLimit),
301
302
  },
302
303
  inventory: {
304
+ available: stockResult.available,
305
+ unavailableReason: stockResult.unavailableReason,
303
306
  totalCount: stockResult.totalCount,
304
307
  spellModifyYn: stockResult.spellModifyYn,
305
308
  items: stockResult.items,
@@ -6,20 +6,23 @@ export const GS25_CLOUDFRONT_403_PATTERNS = [
6
6
  'The request could not be satisfied',
7
7
  '403 ERROR',
8
8
  ];
9
- export const EMART24_UPSTREAM_403_PATTERNS = [
10
- '403 Forbidden',
11
- '<title>403 Forbidden</title>',
12
- ];
13
- export const CU_UPSTREAM_BLOCK_PATTERNS = [
14
- '400 Bad Request',
15
- 'Request Blocked',
16
- ];
9
+ export const EMART24_UPSTREAM_403_PATTERNS = ['403 Forbidden', '<title>403 Forbidden</title>'];
10
+ export const CU_UPSTREAM_BLOCK_PATTERNS = ['400 Bad Request', 'Request Blocked'];
17
11
  export const SEVENELEVEN_UPSTREAM_403_PATTERNS = [
18
12
  '403 Forbidden',
19
13
  '_Incapsula_Resource',
20
14
  'NOINDEX, NOFOLLOW',
21
15
  ];
22
16
  export const HEALTH_CHECKS = [
17
+ {
18
+ id: 'dtryx.movies',
19
+ service: 'dtryx',
20
+ target: 'movies',
21
+ mode: 'deep',
22
+ path: '/api/dtryx/movies?cinemaCode=000067',
23
+ collectionKey: 'movies',
24
+ requiredFields: ['movieCode', 'movieName'],
25
+ },
23
26
  {
24
27
  id: 'cli.contract',
25
28
  service: 'cli',
@@ -56,6 +59,16 @@ export const HEALTH_CHECKS = [
56
59
  requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
57
60
  degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS,
58
61
  },
62
+ {
63
+ id: 'emart24.stores',
64
+ service: 'emart24',
65
+ target: 'stores',
66
+ mode: 'quick',
67
+ path: '/api/emart24/stores?keyword=%EA%B0%95%EB%82%A8&limit=1',
68
+ collectionKey: 'stores',
69
+ requiredFields: ['storeCode', 'storeName', 'name'],
70
+ degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS,
71
+ },
59
72
  {
60
73
  id: 'gs25.products',
61
74
  service: 'gs25',
@@ -85,6 +98,25 @@ export const HEALTH_CHECKS = [
85
98
  requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
86
99
  degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS,
87
100
  },
101
+ {
102
+ id: 'seveneleven.stores',
103
+ service: 'seveneleven',
104
+ target: 'stores',
105
+ mode: 'quick',
106
+ path: '/api/seveneleven/stores?keyword=%EA%B0%95%EB%82%A8&limit=1',
107
+ collectionKey: 'stores',
108
+ requiredFields: ['storeCode', 'storeName', 'name'],
109
+ degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS,
110
+ },
111
+ {
112
+ id: 'seveneleven.popwords',
113
+ service: 'seveneleven',
114
+ target: 'popwords',
115
+ mode: 'quick',
116
+ path: '/api/seveneleven/popwords?label=home',
117
+ degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS,
118
+ allowEmpty: true,
119
+ },
88
120
  {
89
121
  id: 'lottemart.products',
90
122
  service: 'lottemart',
@@ -102,7 +134,6 @@ export const HEALTH_CHECKS = [
102
134
  path: '/api/oliveyoung/products?keyword=%EB%A6%BD%EB%B0%A4&size=1',
103
135
  collectionKey: 'products',
104
136
  requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
105
- timeoutMs: 5000,
106
137
  },
107
138
  {
108
139
  id: 'megabox.theaters',
@@ -147,8 +178,8 @@ export const HEALTH_CHECKS = [
147
178
  target: 'inventory',
148
179
  mode: 'deep',
149
180
  path: '/api/emart24/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
150
- collectionKey: 'inventoryItems',
151
- requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
181
+ collectionKey: 'stores',
182
+ requiredFields: ['storeCode', 'storeName', 'name'],
152
183
  degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS,
153
184
  },
154
185
  {
@@ -157,8 +188,8 @@ export const HEALTH_CHECKS = [
157
188
  target: 'inventory',
158
189
  mode: 'deep',
159
190
  path: '/api/gs25/inventory?keyword=%EC%BD%9C%EB%9D%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
160
- collectionKey: 'inventoryItems',
161
- requiredFields: ['itemCode', 'itemName', 'name'],
191
+ collectionKey: 'inventoryStores',
192
+ requiredFields: ['storeCode', 'storeName', 'name'],
162
193
  degradedFailurePatterns: [
163
194
  '401 Unauthorized',
164
195
  '인증키가 제공되지 않음',
@@ -171,8 +202,8 @@ export const HEALTH_CHECKS = [
171
202
  target: 'inventory',
172
203
  mode: 'deep',
173
204
  path: '/api/seveneleven/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&size=1',
174
- collectionKey: 'inventoryItems',
175
- requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
205
+ collectionKey: 'inventoryStores',
206
+ requiredFields: ['storeCode', 'storeName', 'name'],
176
207
  degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS,
177
208
  },
178
209
  {
@@ -183,6 +214,5 @@ export const HEALTH_CHECKS = [
183
214
  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',
184
215
  collectionKey: 'inventoryProducts',
185
216
  requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
186
- timeoutMs: 5000,
187
217
  },
188
218
  ];