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
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
 
@@ -15,7 +19,8 @@
15
19
 
16
20
  **ClawHub:** [clawhub.ai/hmmhmmhm/daiso-cli](https://clawhub.ai/hmmhmmhm/daiso-cli)
17
21
 
18
- 한국 로컬 리테일과 영화관 조회를 MCP, CLI, Codex Skill로 연결하는 도구입니다.
22
+ 한국 로컬 리테일, 생활 정보, 영화관 조회를 MCP, CLI, Codex Skill로 연결하는 도구입니다.
23
+ 사용자는 별도 API 키를 준비하지 않고 바로 사용할 수 있습니다.
19
24
 
20
25
  <br>
21
26
 
@@ -35,10 +40,15 @@
35
40
  <td>네이버 지역 검색</td>
36
41
  <td>음식점, 카페, 디저트, 주변 장소</td>
37
42
  </tr>
43
+ <tr>
44
+ <td>교통</td>
45
+ <td>오피넷</td>
46
+ <td>전국 평균 유가, 최저가 주유소, 위치 기반 주유소, 주유소 상세정보</td>
47
+ </tr>
38
48
  <tr>
39
49
  <td>비교</td>
40
50
  <td>다이소, GS25, 세븐일레븐, 이마트24</td>
41
- <td>새 외부 키 없는 상품 가격 후보 비교</td>
51
+ <td>같은 상품의 판매처별 가격 후보 비교</td>
42
52
  </tr>
43
53
  <tr>
44
54
  <td>운영</td>
@@ -75,11 +85,19 @@
75
85
  **[실시간 서비스 상태 보기](https://aka-page.betteruptime.com/)**
76
86
 
77
87
  <!-- WORKERS_INVOCATIONS_CHART:START -->
78
- <h3>Cloudflare Workers 호출량 (2026-03-01 ~ 2026-05-27, 88일)</h3>
88
+ <h3>Cloudflare 요청 수 (2026-08-16 ~ 2026-09-14, 30일)</h3>
79
89
 
80
- <img src="./assets/analytics/workers-invocations.png?v=2026-05-27T22:46:35.568Z" alt="Cloudflare Workers 호출량 그래프 (2026-03-01 ~ 2026-05-27)" width="100%">
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>
81
96
 
82
- <sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-05-28 07:46 KST</sub>
97
+ > [!IMPORTANT]
98
+ > 최근 공개 서버 사용량이 크게 증가하여 2026년 7월 18일부터 올리브영·CGV·CU·GS25·롯데마트의 검색을 포함한 공개 GET API에 IP당 하루 합산 3,000회(KST 기준)의 호출 제한을 적용합니다. 한도를 초과하는 사용이 필요하다면 Daiso MCP는 오픈 소스이므로 이 저장소를 직접 배포해 이용해 주세요.
99
+
100
+ <div align="center">
83
101
 
84
102
  <!-- WORKERS_INVOCATIONS_CHART:END -->
85
103
 
@@ -99,7 +117,7 @@
99
117
 
100
118
  ## AI 앱에서 MCP 연결하기
101
119
 
102
- ChatGPT, Claude, Grok 같은 AI 앱에서 바로 연결해 사용할 수 있습니다.
120
+ ChatGPT, Claude, Home Assistant, Grok 같은 AI 앱에서 바로 연결해 사용할 수 있습니다.
103
121
  아래 앱별 가이드에서 먼저 연동한 뒤 검색/재고/영화 조회를 요청하세요.
104
122
 
105
123
  <br>
@@ -127,6 +145,8 @@ GS25 mcp로 강남 근처 매장과 오감자 재고 알려줘
127
145
  세븐일레븐 mcp로 인기 검색어와 카탈로그 요약 알려줘
128
146
  강남역 근처 카페 찾아줘
129
147
  성수동 근처 브런치 음식점 찾아줘
148
+ 강남역 근처 제일 싼 주유소 찾아줘
149
+ 오늘 전국 평균 휘발유 가격 알려줘
130
150
  메가박스 mcp로 강남점 영화와 잔여 좌석 알려줘
131
151
  롯데시네마 mcp로 월드타워 근처 지점과 상영 영화 알려줘
132
152
  롯데시네마 mcp로 월드타워 잔여 좌석 알려줘
@@ -137,6 +157,7 @@ CGV mcp로 강남 상영 영화와 시간표 알려줘
137
157
 
138
158
  - 가격 비교: `콜라 어디가 싸?` → `compare_products`
139
159
  - 주변 장소: `강남역 근처 카페 찾아줘` → `places_search_nearby`
160
+ - 주유소/유가: `강남역 근처 제일 싼 주유소 찾아줘` → `opinet_search_stations_around`
140
161
  - 브랜드 명시 재고: `다이소 핫식스 재고 찾아줘` → 먼저 다이소에서 검색 후 결과 없을 때만 대안 제안
141
162
  - 편의점 재고: `GS25 강남 오감자 재고` → 상품 후보 확인 후 재고 조회
142
163
  - 영화 시간표: `오늘 강남 CGV 시간표` → KST 오늘 날짜로 극장 검색 후 시간표 조회
@@ -188,6 +209,19 @@ claude mcp add daiso-mcp https://mcp.aka.page --transport http
188
209
 
189
210
  <br>
190
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
+
191
225
  ### ![Grok](https://img.shields.io/badge/Grok-000000?logo=x&logoColor=white)
192
226
 
193
227
  > 웹 및 모바일 앱 모두 지원
@@ -330,9 +364,9 @@ MCP 도구 응답은 각 서비스의 원본 필드를 유지하면서, 클라
330
364
  - `standard.theaters`: 영화관 목록을 `code`, `name`, `address`, `distanceMeters`, `raw` 형태로 정규화합니다.
331
365
  - `standard.places`: 음식점/카페 같은 장소 목록을 `name`, `address`, `raw` 중심으로 정규화합니다.
332
366
 
333
- ### 키 없는 통합 가격 후보 비교
367
+ ### 통합 상품 가격 비교
334
368
 
335
- 새 외부 API 키 없이 기존 상품 검색을 묶어 가격 후보를 비교합니다.
369
+ 여러 리테일/편의점 상품 검색 결과를 묶어 같은 상품의 판매처별 가격 후보를 비교합니다.
336
370
 
337
371
  ```bash
338
372
  npx daiso compare 콜라 --limit 3 --json
@@ -434,6 +468,51 @@ curl -H "Authorization: Bearer $HEALTH_CHECK_SECRET" \
434
468
 
435
469
  Better Stack 같은 외부 모니터링에서는 `fail`을 장애 알림 기준으로 보고, `degraded`는 느린 외부 API나 응답 품질 저하를 추적하는 경고 신호로 봅니다.
436
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
+
437
516
  배포 전 로컬에서 CLI 모드까지 확인할 때는 아래 명령을 사용합니다.
438
517
 
439
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,7 +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 = ['403 Forbidden', '<title>403 Forbidden</title>'];
10
+ export const CU_UPSTREAM_BLOCK_PATTERNS = ['400 Bad Request', 'Request Blocked'];
11
+ export const SEVENELEVEN_UPSTREAM_403_PATTERNS = [
12
+ '403 Forbidden',
13
+ '_Incapsula_Resource',
14
+ 'NOINDEX, NOFOLLOW',
15
+ ];
9
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
+ },
10
26
  {
11
27
  id: 'cli.contract',
12
28
  service: 'cli',
@@ -41,6 +57,17 @@ export const HEALTH_CHECKS = [
41
57
  path: '/api/emart24/products?keyword=%EC%BB%A4%ED%94%BC&pageSize=1',
42
58
  collectionKey: 'products',
43
59
  requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
60
+ degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS,
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,
44
71
  },
45
72
  {
46
73
  id: 'gs25.products',
@@ -69,6 +96,26 @@ export const HEALTH_CHECKS = [
69
96
  path: '/api/seveneleven/products?query=%EC%BB%A4%ED%94%BC&size=1',
70
97
  collectionKey: 'products',
71
98
  requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
99
+ degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS,
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,
72
119
  },
73
120
  {
74
121
  id: 'lottemart.products',
@@ -87,7 +134,6 @@ export const HEALTH_CHECKS = [
87
134
  path: '/api/oliveyoung/products?keyword=%EB%A6%BD%EB%B0%A4&size=1',
88
135
  collectionKey: 'products',
89
136
  requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
90
- timeoutMs: 5000,
91
137
  },
92
138
  {
93
139
  id: 'megabox.theaters',
@@ -124,6 +170,7 @@ export const HEALTH_CHECKS = [
124
170
  path: '/api/cu/inventory?keyword=%EC%BB%A4%ED%94%BC&size=1&storeLimit=0&storeCheck=false',
125
171
  collectionKey: 'inventoryItems',
126
172
  requiredFields: ['itemCode', 'itemName', 'name'],
173
+ degradedFailurePatterns: CU_UPSTREAM_BLOCK_PATTERNS,
127
174
  },
128
175
  {
129
176
  id: 'emart24.inventory',
@@ -131,8 +178,9 @@ export const HEALTH_CHECKS = [
131
178
  target: 'inventory',
132
179
  mode: 'deep',
133
180
  path: '/api/emart24/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
134
- collectionKey: 'inventoryItems',
135
- requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
181
+ collectionKey: 'stores',
182
+ requiredFields: ['storeCode', 'storeName', 'name'],
183
+ degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS,
136
184
  },
137
185
  {
138
186
  id: 'gs25.inventory',
@@ -140,8 +188,8 @@ export const HEALTH_CHECKS = [
140
188
  target: 'inventory',
141
189
  mode: 'deep',
142
190
  path: '/api/gs25/inventory?keyword=%EC%BD%9C%EB%9D%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
143
- collectionKey: 'inventoryItems',
144
- requiredFields: ['itemCode', 'itemName', 'name'],
191
+ collectionKey: 'inventoryStores',
192
+ requiredFields: ['storeCode', 'storeName', 'name'],
145
193
  degradedFailurePatterns: [
146
194
  '401 Unauthorized',
147
195
  '인증키가 제공되지 않음',
@@ -154,8 +202,9 @@ export const HEALTH_CHECKS = [
154
202
  target: 'inventory',
155
203
  mode: 'deep',
156
204
  path: '/api/seveneleven/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&size=1',
157
- collectionKey: 'inventoryItems',
158
- requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
205
+ collectionKey: 'inventoryStores',
206
+ requiredFields: ['storeCode', 'storeName', 'name'],
207
+ degradedFailurePatterns: SEVENELEVEN_UPSTREAM_403_PATTERNS,
159
208
  },
160
209
  {
161
210
  id: 'oliveyoung.inventory',
@@ -165,6 +214,5 @@ export const HEALTH_CHECKS = [
165
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',
166
215
  collectionKey: 'inventoryProducts',
167
216
  requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
168
- timeoutMs: 5000,
169
217
  },
170
218
  ];