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.
- package/README.md +75 -5
- package/dist/api/actionsProxy.js +3 -0
- package/dist/api/cgvHandlers.js +15 -3
- package/dist/api/configStatus.js +1 -1
- package/dist/api/dtryxHandlers.js +113 -0
- package/dist/api/emart24Handlers.js +6 -2
- package/dist/api/gs25Handlers.js +22 -19
- package/dist/api/handlers.js +3 -0
- package/dist/api/healthCheckDefinitions.js +46 -16
- package/dist/api/healthCheckShape.js +24 -8
- package/dist/api/healthChecks.js +39 -20
- package/dist/api/operationalAuth.js +32 -0
- package/dist/api/routes/dtryxRoutes.js +22 -0
- package/dist/api/routes/healthRoutes.js +19 -11
- package/dist/api/routes/rateLimitStatsRoutes.js +274 -0
- package/dist/api/routes/registerServiceRoutes.js +37 -0
- package/dist/api/sevenelevenHandlers.js +7 -3
- package/dist/cli/commandOptions.js +24 -0
- package/dist/cli/commands/cinema.js +27 -0
- package/dist/cli.js +7 -1
- package/dist/cliHelp.js +3 -337
- package/dist/cliHelpDefinitions.js +369 -0
- package/dist/cliInteractive.js +8 -5
- package/dist/core/errors.js +17 -2
- package/dist/core/registry.js +13 -15
- package/dist/durableObjects/dailyRateLimiter.js +185 -0
- package/dist/durableObjects/rateLimitMetricsStore.js +144 -0
- package/dist/index.js +16 -30
- package/dist/middleware/dailyRateLimit.js +176 -0
- package/dist/pages/openapiFullSpec.js +2 -0
- package/dist/pages/openapiSpecActionParameters.js +429 -0
- package/dist/pages/openapiSpecActions.js +1 -391
- package/dist/pages/openapiSpecComponentsLottecinema.js +2 -2
- package/dist/pages/openapiSpecPathsDtryx.js +148 -0
- package/dist/pages/openapiSpecPathsLotteMart.js +10 -2
- package/dist/pages/promptRetailCinemaText.js +9 -1
- package/dist/services/cgv/errors.js +12 -0
- package/dist/services/cgv/transport.js +19 -2
- package/dist/services/cu/client.js +42 -12
- package/dist/services/cu/index.js +13 -3
- package/dist/services/cu/tools/checkInventory.js +65 -45
- package/dist/services/cu/tools/findNearbyStores.js +4 -3
- package/dist/services/cu/upstreamError.js +15 -0
- package/dist/services/dtryx/api.js +25 -0
- package/dist/services/dtryx/client.js +133 -0
- package/dist/services/dtryx/index.js +24 -0
- package/dist/services/dtryx/location.js +216 -0
- package/dist/services/dtryx/tools/getRemainingSeats.js +117 -0
- package/dist/services/dtryx/tools/listCinemas.js +46 -0
- package/dist/services/dtryx/tools/listNowShowing.js +77 -0
- package/dist/services/dtryx/types.js +4 -0
- package/dist/services/dtryx/validation.js +21 -0
- package/dist/services/feedback/tools/submitDeveloperRequest.js +15 -1
- package/dist/services/gs25/api.js +2 -2
- package/dist/services/gs25/client.js +16 -123
- package/dist/services/gs25/errors.js +12 -0
- package/dist/services/gs25/index.js +2 -2
- package/dist/services/gs25/productSearch.js +1 -0
- package/dist/services/gs25/storeStockTransport.js +55 -0
- package/dist/services/gs25/tools/checkInventory.js +7 -2
- package/dist/services/gs25/tools/findNearbyStores.js +20 -5
- package/dist/services/lottecinema/client.js +4 -2
- package/dist/services/lottemart/config.js +1 -1
- package/dist/services/lottemart/session.js +29 -160
- package/dist/services/lottemart/socketTransport.js +149 -0
- package/dist/services/oliveyoung/client.js +1 -0
- package/dist/services/seveneleven/client.js +18 -8
- package/dist/services/seveneleven/index.js +11 -7
- package/dist/services/seveneleven/inventory.js +5 -2
- package/dist/services/seveneleven/productKeyword.js +3 -2
- package/dist/services/seveneleven/tools/checkInventory.js +4 -3
- package/dist/services/seveneleven/tools/getCatalogSnapshot.js +4 -3
- package/dist/services/seveneleven/tools/getSearchPopwords.js +4 -3
- package/dist/services/seveneleven/tools/searchProducts.js +4 -3
- package/dist/services/seveneleven/tools/searchStores.js +4 -3
- package/dist/utils/format.js +10 -3
- package/dist/utils/http.js +38 -10
- package/dist/utils/zyte.js +13 -2
- package/dist/utils/zyteJsonFallback.js +67 -0
- package/openapi.json +51 -3
- package/openapi.yaml +36 -3
- package/package.json +28 -27
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
[](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
|
|
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
|
-
|
|
97
|
+
> [!IMPORTANT]
|
|
98
|
+
> 최근 공개 서버 사용량이 크게 증가하여 2026년 7월 18일부터 올리브영·CGV·CU·GS25·롯데마트의 검색을 포함한 공개 GET API에 IP당 하루 합산 3,000회(KST 기준)의 호출 제한을 적용합니다. 한도를 초과하는 사용이 필요하다면 Daiso MCP는 오픈 소스이므로 이 저장소를 직접 배포해 이용해 주세요.
|
|
87
99
|
|
|
88
|
-
<
|
|
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
|
### 
|
|
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
|
package/dist/api/actionsProxy.js
CHANGED
|
@@ -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',
|
package/dist/api/cgvHandlers.js
CHANGED
|
@@ -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 &&
|
|
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 &&
|
|
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
|
}
|
package/dist/api/configStatus.js
CHANGED
|
@@ -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:
|
|
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:
|
|
90
|
+
timeout: timeoutMs,
|
|
87
91
|
});
|
|
88
92
|
return successResponse(c, {
|
|
89
93
|
keyword,
|
package/dist/api/gs25Handlers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
}
|
package/dist/api/handlers.js
CHANGED
|
@@ -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
|
-
|
|
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: '
|
|
151
|
-
requiredFields: ['
|
|
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: '
|
|
161
|
-
requiredFields: ['
|
|
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: '
|
|
175
|
-
requiredFields: ['
|
|
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
|
];
|