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
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,21 @@
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
+ > Zyte 유료 호출을 중지했습니다. 무료 원본 조회를 우선하며 일부 서비스는 별도 실행 환경이 필요합니다. [비용 정책과 서비스별 상태](docs/zyte-free-access.md)를 확인하세요.
87
98
 
88
- <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-07-14 01:10 KST</sub>
99
+ > [!IMPORTANT]
100
+ > 최근 공개 서버 사용량이 크게 증가하여 2026년 7월 18일부터 올리브영·CGV·CU·GS25·롯데마트의 검색을 포함한 공개 GET API에 IP당 하루 합산 3,000회(KST 기준)의 호출 제한을 적용합니다. 한도를 초과하는 사용이 필요하다면 Daiso MCP는 오픈 소스이므로 이 저장소를 직접 배포해 이용해 주세요.
101
+
102
+ <div align="center">
89
103
 
90
104
  <!-- WORKERS_INVOCATIONS_CHART:END -->
91
105
 
@@ -105,7 +119,7 @@
105
119
 
106
120
  ## AI 앱에서 MCP 연결하기
107
121
 
108
- ChatGPT, Claude, Grok 같은 AI 앱에서 바로 연결해 사용할 수 있습니다.
122
+ ChatGPT, Claude, Home Assistant, Grok 같은 AI 앱에서 바로 연결해 사용할 수 있습니다.
109
123
  아래 앱별 가이드에서 먼저 연동한 뒤 검색/재고/영화 조회를 요청하세요.
110
124
 
111
125
  <br>
@@ -197,6 +211,19 @@ claude mcp add daiso-mcp https://mcp.aka.page --transport http
197
211
 
198
212
  <br>
199
213
 
214
+ ### Home Assistant
215
+
216
+ > Home Assistant Core 2026.7.1에서 MCP 도구 40개를 모두 정상적으로 불러오는 것을 확인했습니다.
217
+
218
+ 1. Home Assistant에서 **Settings** → **Devices & services**로 이동
219
+ 2. **Add Integration**을 선택하고 **Model Context Protocol**을 검색
220
+ 3. 서버 URL 입력: `https://mcp.aka.page`
221
+ 4. 연동을 마친 뒤 사용할 대화 에이전트가 MCP 도구를 사용하도록 설정
222
+
223
+ 참고: [Home Assistant Model Context Protocol 통합 가이드](https://www.home-assistant.io/integrations/mcp)
224
+
225
+ <br>
226
+
200
227
  ### ![Grok](https://img.shields.io/badge/Grok-000000?logo=x&logoColor=white)
201
228
 
202
229
  > 웹 및 모바일 앱 모두 지원
@@ -443,6 +470,51 @@ curl -H "Authorization: Bearer $HEALTH_CHECK_SECRET" \
443
470
 
444
471
  Better Stack 같은 외부 모니터링에서는 `fail`을 장애 알림 기준으로 보고, `degraded`는 느린 외부 API나 응답 품질 저하를 추적하는 경고 신호로 봅니다.
445
472
 
473
+ ### 운영 통계
474
+
475
+ 일일 호출 제한으로 차단한 요청의 집계는 인증된 `GET /api/rate-limit/stats`에서 조회합니다. 헬스 체크와 같은 `HEALTH_CHECK_SECRET`을 사용하며, 다음 두 인증 헤더를 모두 지원합니다.
476
+
477
+ ```bash
478
+ curl -H "Authorization: Bearer $HEALTH_CHECK_SECRET" \
479
+ "https://mcp.aka.page/api/rate-limit/stats"
480
+
481
+ curl -H "x-health-check-key: $HEALTH_CHECK_SECRET" \
482
+ "https://mcp.aka.page/api/rate-limit/stats?service=cgv"
483
+ ```
484
+
485
+ 쿼리 필터는 `from`, `to`, `service`입니다. `from`과 `to`는 함께 지정하거나 둘 다 생략해야 하며 날짜 형식은 `YYYY-MM-DD`입니다. `service`에는 `oliveyoung`, `cgv`, `cu`, `gs25`, `lottemart`만 사용할 수 있습니다. 날짜를 생략하면 현재 KST 일자를 포함한 최근 7일을 조회합니다. 조회 가능한 보관 범위는 현재 KST 일자와 그 이전 29일이며, 한 번에 KST 달력 날짜 기준 최대 30일을 요청할 수 있습니다.
486
+
487
+ 성공 응답은 전체 합계와 일별·서비스별 차단 요청 수와 고유 차단 주체 수를 제공합니다.
488
+
489
+ ```json
490
+ {
491
+ "success": true,
492
+ "data": {
493
+ "totals": {
494
+ "blockedRequests": 3,
495
+ "uniqueIdentities": 2
496
+ },
497
+ "daily": [
498
+ {
499
+ "day": "2026-07-22",
500
+ "blockedRequests": 3,
501
+ "uniqueIdentities": 2
502
+ }
503
+ ],
504
+ "services": [
505
+ {
506
+ "day": "2026-07-22",
507
+ "service": "cgv",
508
+ "blockedRequests": 3,
509
+ "uniqueIdentities": 2
510
+ }
511
+ ]
512
+ }
513
+ }
514
+ ```
515
+
516
+ 데이터는 30일 동안 보관하며 집계 응답은 원본 호출 주체나 IP를 노출하지 않습니다. Worker가 생성한 `DAILY_RATE_LIMIT_EXCEEDED` 결정 중 원장 커밋에 성공한 경우만 정확한 집계 범위에 포함됩니다. Cloudflare 또는 네트워크 계층의 429와 클라이언트 전송 결과, 연결 종료 결과는 이 범위에 포함되지 않습니다. 원장 쓰기에 실패하면 요청을 fail-open 처리하고 애플리케이션 429를 반환하지 않습니다. 통계는 이 기능의 배포 시점부터 수집하며 이전 429는 소급 집계하지 않습니다.
517
+
446
518
  배포 전 로컬에서 CLI 모드까지 확인할 때는 아래 명령을 사용합니다.
447
519
 
448
520
  ```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,8 @@ export function buildConfigStatus(bindings) {
9
9
  },
10
10
  zyteApiKey: {
11
11
  configured: isConfigured(bindings?.ZYTE_API_KEY),
12
- usedBy: ['oliveyoung', 'gs25', 'lottemart', 'cgv'],
12
+ enabled: false,
13
+ usedBy: [],
13
14
  },
14
15
  naverLocalSearch: {
15
16
  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
  }
@@ -44,7 +44,8 @@ export async function handleOliveyoungSearchProducts(c) {
44
44
  sort,
45
45
  includeSoldOut,
46
46
  }, {
47
- apiKey: c.env?.ZYTE_API_KEY,
47
+ relayUrl: c.env?.OY_RELAY_URL,
48
+ relayToken: c.env?.OY_RELAY_TOKEN,
48
49
  timeout: timeoutMs,
49
50
  });
50
51
  return successResponse(c, {
@@ -79,7 +80,8 @@ export async function handleOliveyoungFindStores(c) {
79
80
  pageIdx,
80
81
  searchWords: keyword,
81
82
  }, {
82
- apiKey: c.env?.ZYTE_API_KEY,
83
+ relayUrl: c.env?.OY_RELAY_URL,
84
+ relayToken: c.env?.OY_RELAY_TOKEN,
83
85
  timeout: timeoutMs,
84
86
  });
85
87
  return successResponse(c, {
@@ -120,7 +122,8 @@ export async function handleOliveyoungCheckInventory(c) {
120
122
  pageIdx: 1,
121
123
  searchWords: storeKeyword,
122
124
  }, {
123
- apiKey: c.env.ZYTE_API_KEY,
125
+ relayUrl: c.env?.OY_RELAY_URL,
126
+ relayToken: c.env?.OY_RELAY_TOKEN,
124
127
  timeout: timeoutMs,
125
128
  }),
126
129
  fetchOliveyoungProducts({
@@ -130,7 +133,8 @@ export async function handleOliveyoungCheckInventory(c) {
130
133
  sort,
131
134
  includeSoldOut,
132
135
  }, {
133
- apiKey: c.env.ZYTE_API_KEY,
136
+ relayUrl: c.env?.OY_RELAY_URL,
137
+ relayToken: c.env?.OY_RELAY_TOKEN,
134
138
  timeout: timeoutMs,
135
139
  }),
136
140
  ]);
@@ -140,7 +144,8 @@ export async function handleOliveyoungCheckInventory(c) {
140
144
  storeKeyword,
141
145
  maxProducts: Math.min(productResult.products.length, stockCheckLimit),
142
146
  }, {
143
- apiKey: c.env.ZYTE_API_KEY,
147
+ relayUrl: c.env?.OY_RELAY_URL,
148
+ relayToken: c.env?.OY_RELAY_TOKEN,
144
149
  timeout: timeoutMs,
145
150
  });
146
151
  const inStockCount = enrichedInventory.products.filter((product) => product.inStock).length;
@@ -229,6 +234,7 @@ export async function handleCuCheckInventory(c) {
229
234
  searchSort,
230
235
  }, {
231
236
  timeout: 15000,
237
+ apiKey: c.env?.ZYTE_API_KEY,
232
238
  });
233
239
  const firstStockItem = stockResult.items.find((item) => item.itemCode.trim().length > 0) || null;
234
240
  const hasInputLocation = typeof lat === 'number' && typeof lng === 'number';
@@ -300,6 +306,8 @@ export async function handleCuCheckInventory(c) {
300
306
  stores: storeResult.stores.slice(0, storeLimit),
301
307
  },
302
308
  inventory: {
309
+ available: stockResult.available,
310
+ unavailableReason: stockResult.unavailableReason,
303
311
  totalCount: stockResult.totalCount,
304
312
  spellModifyYn: stockResult.spellModifyYn,
305
313
  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
  ];