daiso 1.2.4 → 1.2.6
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 +3 -3
- package/dist/api/configStatus.js +24 -0
- package/dist/api/dtryxHandlers.js +10 -4
- package/dist/api/routes/oliveyoungRoutes.js +7 -6
- package/dist/core/registry.js +4 -0
- package/dist/index.js +5 -2
- package/dist/middleware/diagnostics.js +17 -0
- package/dist/services/dtryx/client.js +30 -19
- package/dist/services/dtryx/index.js +11 -3
- package/dist/services/dtryx/tools/getRemainingSeats.js +4 -4
- package/dist/services/dtryx/tools/listNowShowing.js +5 -5
- package/dist/services/dtryx/transport.js +91 -0
- package/dist/services/oliveyoung/transport.js +2 -0
- package/dist/utils/diagnostics.js +76 -0
- package/dist/utils/http.js +6 -3
- package/dist/utils/oliveyoungCacheKey.js +28 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,11 +85,11 @@
|
|
|
85
85
|
**[실시간 서비스 상태 보기](https://aka-page.betteruptime.com/)**
|
|
86
86
|
|
|
87
87
|
<!-- WORKERS_INVOCATIONS_CHART:START -->
|
|
88
|
-
<h3>Cloudflare 요청 수 (2026-08-
|
|
88
|
+
<h3>Cloudflare 요청 수 (2026-08-30 ~ 2026-09-28, 30일)</h3>
|
|
89
89
|
|
|
90
|
-
<img src="./assets/analytics/workers-invocations.png?v=2026-09-
|
|
90
|
+
<img src="./assets/analytics/workers-invocations.png?v=2026-09-28T21:15:27.313Z" alt="Cloudflare 요청 수 그래프 (2026-08-30 ~ 2026-09-28)" width="100%">
|
|
91
91
|
|
|
92
|
-
<sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-09-
|
|
92
|
+
<sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-09-29 06:15 KST</sub>
|
|
93
93
|
<br><sub>집계: Worker 실행 + 루트 GET 리디렉션 요청 · 사용자 수와 다릅니다.</sub>
|
|
94
94
|
|
|
95
95
|
</div>
|
package/dist/api/configStatus.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isValidDtryxRelayUrl } from '../services/dtryx/transport.js';
|
|
1
2
|
import { isValidOliveyoungRelayUrl } from '../services/oliveyoung/transport.js';
|
|
2
3
|
function isConfigured(value) {
|
|
3
4
|
return typeof value === 'string' && value.trim().length > 0;
|
|
@@ -11,6 +12,18 @@ export function buildConfigStatus(bindings) {
|
|
|
11
12
|
const accessPairValid = (bindings?.OY_ACCESS_CLIENT_ID === undefined &&
|
|
12
13
|
bindings?.OY_ACCESS_CLIENT_SECRET === undefined) ||
|
|
13
14
|
accessConfigured;
|
|
15
|
+
const dtryxId = isConfigured(bindings?.DTRYX_ACCESS_CLIENT_ID);
|
|
16
|
+
const dtryxSecret = isConfigured(bindings?.DTRYX_ACCESS_CLIENT_SECRET);
|
|
17
|
+
const dtryx = {
|
|
18
|
+
urlValid: isValidDtryxRelayUrl(bindings?.DTRYX_RELAY_URL),
|
|
19
|
+
tokenConfigured: isConfigured(bindings?.DTRYX_RELAY_TOKEN),
|
|
20
|
+
accessClientIdConfigured: dtryxId,
|
|
21
|
+
accessClientSecretConfigured: dtryxSecret,
|
|
22
|
+
accessConfigured: dtryxId && dtryxSecret,
|
|
23
|
+
accessPairValid: (bindings?.DTRYX_ACCESS_CLIENT_ID === undefined &&
|
|
24
|
+
bindings?.DTRYX_ACCESS_CLIENT_SECRET === undefined) ||
|
|
25
|
+
(dtryxId && dtryxSecret),
|
|
26
|
+
};
|
|
14
27
|
return {
|
|
15
28
|
oliveyoungRelay: {
|
|
16
29
|
configured: urlValid && tokenConfigured && accessPairValid,
|
|
@@ -23,6 +36,17 @@ export function buildConfigStatus(bindings) {
|
|
|
23
36
|
accessPairValid,
|
|
24
37
|
usedBy: ['oliveyoung'],
|
|
25
38
|
},
|
|
39
|
+
dtryxRelay: {
|
|
40
|
+
configured: dtryx.urlValid && dtryx.tokenConfigured && dtryx.accessPairValid,
|
|
41
|
+
urlConfigured: isConfigured(bindings?.DTRYX_RELAY_URL),
|
|
42
|
+
urlValid: dtryx.urlValid,
|
|
43
|
+
tokenConfigured: dtryx.tokenConfigured,
|
|
44
|
+
accessClientIdConfigured: dtryx.accessClientIdConfigured,
|
|
45
|
+
accessClientSecretConfigured: dtryx.accessClientSecretConfigured,
|
|
46
|
+
accessConfigured: dtryx.accessConfigured,
|
|
47
|
+
accessPairValid: dtryx.accessPairValid,
|
|
48
|
+
usedBy: ['dtryx'],
|
|
49
|
+
},
|
|
26
50
|
googleMapsApiKey: {
|
|
27
51
|
configured: isConfigured(bindings?.GOOGLE_MAPS_API_KEY),
|
|
28
52
|
usedBy: ['gs25', 'cu', 'lottemart', 'megabox', 'lottecinema', 'cgv'],
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 디트릭스 GET API 핸들러
|
|
3
3
|
*/
|
|
4
|
+
import { ServiceError } from '../core/errors.js';
|
|
5
|
+
import { dtryxTransportFromBindings } from '../services/dtryx/transport.js';
|
|
4
6
|
import { resolveDtryxCinemas } from '../services/dtryx/location.js';
|
|
5
7
|
import { DTRYX_INVALID_INPUT, hasInvalidDtryxOptions } from '../services/dtryx/validation.js';
|
|
6
8
|
import { fetchDtryxNowShowing, fetchDtryxPlayDates, fetchDtryxTimetable, toYyyymmdd, } from '../services/dtryx/client.js';
|
|
7
|
-
import { errorResponse, successResponse } from './response.js';
|
|
9
|
+
import { errorResponse, serviceErrorResponse, successResponse, } from './response.js';
|
|
8
10
|
export async function handleDtryxListCinemas(c) {
|
|
9
11
|
const keyword = c.req.query('keyword') || undefined;
|
|
10
12
|
const region = c.req.query('region') || undefined;
|
|
@@ -45,8 +47,10 @@ export async function handleDtryxListNowShowing(c) {
|
|
|
45
47
|
timeout: timeoutMs,
|
|
46
48
|
};
|
|
47
49
|
const [movies, playDates] = await Promise.all([
|
|
48
|
-
fetchDtryxNowShowing(request),
|
|
49
|
-
includePlayDates
|
|
50
|
+
fetchDtryxNowShowing(request, dtryxTransportFromBindings(c.env)),
|
|
51
|
+
includePlayDates
|
|
52
|
+
? fetchDtryxPlayDates(request, dtryxTransportFromBindings(c.env))
|
|
53
|
+
: Promise.resolve([]),
|
|
50
54
|
]);
|
|
51
55
|
return successResponse(c, {
|
|
52
56
|
cinema,
|
|
@@ -56,6 +60,8 @@ export async function handleDtryxListNowShowing(c) {
|
|
|
56
60
|
}, { total: movies.length });
|
|
57
61
|
}
|
|
58
62
|
catch (error) {
|
|
63
|
+
if (error instanceof ServiceError)
|
|
64
|
+
return serviceErrorResponse(c, error, 'now_showing');
|
|
59
65
|
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
|
60
66
|
return errorResponse(c, 'DTRYX_NOW_SHOWING_FAILED', message, 500);
|
|
61
67
|
}
|
|
@@ -82,7 +88,7 @@ export async function handleDtryxGetRemainingSeats(c) {
|
|
|
82
88
|
cinemaCode: cinema.cinemaCode,
|
|
83
89
|
playDate,
|
|
84
90
|
timeout: timeoutMs,
|
|
85
|
-
})));
|
|
91
|
+
}, dtryxTransportFromBindings(c.env))));
|
|
86
92
|
const failedCinemas = cinemas
|
|
87
93
|
.filter((_, index) => settled[index]?.status === 'rejected')
|
|
88
94
|
.map((cinema) => cinema.cinemaName || cinema.cinemaCode);
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 올리브영 GET API 라우트 등록
|
|
3
3
|
*/
|
|
4
|
+
import { oliveyoungCacheKey } from '../../utils/oliveyoungCacheKey.js';
|
|
4
5
|
import { withEdgeCache } from '../../utils/cache.js';
|
|
5
6
|
import { handleOliveyoungFindStores, handleOliveyoungCheckInventory, handleOliveyoungSearchProducts, } from '../handlers.js';
|
|
6
7
|
export function registerOliveyoungRoutes(app) {
|
|
7
|
-
app.get('/api/oliveyoung/products', async (c) => withEdgeCache(c.req.url, {
|
|
8
|
+
app.get('/api/oliveyoung/products', async (c) => withEdgeCache(oliveyoungCacheKey(c.req.url), {
|
|
8
9
|
ttlSeconds: 60 * 5,
|
|
9
10
|
staleWhileRevalidateSeconds: 60,
|
|
10
|
-
keyPrefix: 'oliveyoung-products-
|
|
11
|
+
keyPrefix: 'oliveyoung-products-v2',
|
|
11
12
|
}, () => handleOliveyoungSearchProducts(c)));
|
|
12
|
-
app.get('/api/oliveyoung/stores', async (c) => withEdgeCache(c.req.url, {
|
|
13
|
+
app.get('/api/oliveyoung/stores', async (c) => withEdgeCache(oliveyoungCacheKey(c.req.url), {
|
|
13
14
|
ttlSeconds: 60 * 60 * 24,
|
|
14
15
|
staleWhileRevalidateSeconds: 60 * 5,
|
|
15
|
-
keyPrefix: 'oliveyoung-stores-
|
|
16
|
+
keyPrefix: 'oliveyoung-stores-v2',
|
|
16
17
|
}, () => handleOliveyoungFindStores(c)));
|
|
17
|
-
app.get('/api/oliveyoung/inventory', async (c) => withEdgeCache(c.req.url, {
|
|
18
|
+
app.get('/api/oliveyoung/inventory', async (c) => withEdgeCache(oliveyoungCacheKey(c.req.url), {
|
|
18
19
|
ttlSeconds: 60 * 10,
|
|
19
20
|
staleWhileRevalidateSeconds: 60,
|
|
20
|
-
keyPrefix: 'oliveyoung-inventory-
|
|
21
|
+
keyPrefix: 'oliveyoung-inventory-v2',
|
|
21
22
|
}, () => handleOliveyoungCheckInventory(c)));
|
|
22
23
|
}
|
package/dist/core/registry.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 서비스 프로바이더를 등록하고 MCP 서버에 도구를 연결합니다.
|
|
5
5
|
*/
|
|
6
|
+
import { diagnosticEvent } from '../utils/diagnostics.js';
|
|
6
7
|
import { getErrorMessage, ServiceError, toServiceErrorDiagnostics } from './errors.js';
|
|
7
8
|
import { createToolOutputSchema } from './outputSchema.js';
|
|
8
9
|
function parseJsonText(text) {
|
|
@@ -172,11 +173,13 @@ export class ServiceRegistry {
|
|
|
172
173
|
outputSchema: createToolOutputSchema(tool.name, tool.metadata.outputSchema),
|
|
173
174
|
};
|
|
174
175
|
server.registerTool(tool.name, metadata, async (args) => {
|
|
176
|
+
const started = Date.now();
|
|
175
177
|
let result;
|
|
176
178
|
try {
|
|
177
179
|
result = await tool.handler(args);
|
|
178
180
|
}
|
|
179
181
|
catch (error) {
|
|
182
|
+
diagnosticEvent({ stage: 'tool', operation: tool.name, outcome: 'error', durationMs: Date.now() - started });
|
|
180
183
|
const message = error instanceof ServiceError
|
|
181
184
|
? JSON.stringify({
|
|
182
185
|
error: { code: error.code, message: error.message },
|
|
@@ -188,6 +191,7 @@ export class ServiceRegistry {
|
|
|
188
191
|
content: [{ type: 'text', text: message }],
|
|
189
192
|
};
|
|
190
193
|
}
|
|
194
|
+
diagnosticEvent({ stage: 'tool', operation: tool.name, outcome: result.isError ? 'error' : 'ok', durationMs: Date.now() - started });
|
|
191
195
|
const content = result.content.map((item) => ({
|
|
192
196
|
type: item.type,
|
|
193
197
|
text: item.text,
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Cloudflare Workers에서 실행되는 플러그인 기반 MCP 서버입니다.
|
|
5
5
|
* 다이소, 편의점, 백화점 등 다양한 서비스를 확장할 수 있습니다.
|
|
6
6
|
*/
|
|
7
|
+
import { diagnosticsMiddleware } from './middleware/diagnostics.js';
|
|
8
|
+
import { dtryxTransportFromBindings } from './services/dtryx/transport.js';
|
|
7
9
|
import { Hono } from 'hono';
|
|
8
10
|
import { cors } from 'hono/cors';
|
|
9
11
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -76,7 +78,7 @@ const createRegistry = (bindings) => {
|
|
|
76
78
|
zyteApiKey: bindings?.ZYTE_API_KEY,
|
|
77
79
|
}),
|
|
78
80
|
createMegaboxService,
|
|
79
|
-
createDtryxService,
|
|
81
|
+
() => createDtryxService(dtryxTransportFromBindings(bindings)),
|
|
80
82
|
() => createLotteCinemaService({
|
|
81
83
|
googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
|
|
82
84
|
}),
|
|
@@ -244,6 +246,7 @@ const handleRootMcpRequest = async (c) => {
|
|
|
244
246
|
};
|
|
245
247
|
// Hono 앱 생성
|
|
246
248
|
const app = new Hono();
|
|
249
|
+
app.use('*', diagnosticsMiddleware);
|
|
247
250
|
// CORS 설정
|
|
248
251
|
app.use('*', cors({
|
|
249
252
|
origin: '*',
|
|
@@ -256,7 +259,7 @@ app.use('*', cors({
|
|
|
256
259
|
'Last-Event-ID',
|
|
257
260
|
'mcp-protocol-version',
|
|
258
261
|
],
|
|
259
|
-
exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'],
|
|
262
|
+
exposeHeaders: ['mcp-session-id', 'mcp-protocol-version', 'x-request-id'],
|
|
260
263
|
}));
|
|
261
264
|
// Zyte 연동 공개 GET API의 일일 호출량을 IP별로 제한합니다.
|
|
262
265
|
app.use('/api/*', createDailyRateLimitMiddleware());
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { withDiagnostics, diagnosticEvent, diagnosticHeaders, diagnosticOperation, } from '../utils/diagnostics.js';
|
|
2
|
+
export const diagnosticsMiddleware = async (c, next) => withDiagnostics(async () => {
|
|
3
|
+
const start = performance.now();
|
|
4
|
+
try {
|
|
5
|
+
await next();
|
|
6
|
+
}
|
|
7
|
+
finally {
|
|
8
|
+
c.header('x-request-id', diagnosticHeaders()['x-request-id']);
|
|
9
|
+
diagnosticEvent({
|
|
10
|
+
stage: 'request',
|
|
11
|
+
operation: diagnosticOperation(c.req.url),
|
|
12
|
+
outcome: c.error || c.res.status >= 400 ? 'error' : 'ok',
|
|
13
|
+
status: c.res.status,
|
|
14
|
+
durationMs: performance.now() - start,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { formatTime, toNumber, toYyyymmdd } from '../../utils/format.js';
|
|
5
5
|
import { createTimeoutController } from '../../utils/http.js';
|
|
6
|
+
import { hasDtryxRelayOptions, requestDtryxRelay, } from './transport.js';
|
|
6
7
|
import { DTRYX_API } from './api.js';
|
|
7
8
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
8
9
|
/**
|
|
@@ -65,14 +66,20 @@ function buildScheduleId(cinemaCode, playDate, screenCode, showSeq) {
|
|
|
65
66
|
return `${playDate}-${cinemaCode}-${screenCode}-${showSeq}`;
|
|
66
67
|
}
|
|
67
68
|
/** 상영 회차와 좌석 현황을 조회합니다. */
|
|
68
|
-
export async function fetchDtryxTimetable(params) {
|
|
69
|
+
export async function fetchDtryxTimetable(params, options = {}) {
|
|
69
70
|
const playDate = params.playDate || toYyyymmdd();
|
|
70
|
-
const response =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
const response = hasDtryxRelayOptions(options)
|
|
72
|
+
? await requestDtryxRelay('timetable', {
|
|
73
|
+
brandCode: params.brandCode,
|
|
74
|
+
cinemaCode: params.cinemaCode,
|
|
75
|
+
playDate: toCompactDate(playDate),
|
|
76
|
+
}, options, params.timeout ?? DEFAULT_TIMEOUT_MS)
|
|
77
|
+
: await requestDtryx(DTRYX_API.ENDPOINTS.TIMETABLE_LIST, DTRYX_API.WORK_GUIDS.TIMETABLE_LIST, {
|
|
78
|
+
BrandCd: params.brandCode,
|
|
79
|
+
CinemaCd: params.cinemaCode,
|
|
80
|
+
PlaySDT: toDashedDate(playDate),
|
|
81
|
+
ImgSize: 'small',
|
|
82
|
+
}, params.timeout);
|
|
76
83
|
return response.Recordset.filter((item) => item.CinemaCd && item.MovieCd && item.ScreenCd).map((item) => {
|
|
77
84
|
const totalSeats = toNumber(item.TotalSeatCnt);
|
|
78
85
|
const remainingSeats = toNumber(item.RemainSeatCnt);
|
|
@@ -102,12 +109,14 @@ export async function fetchDtryxTimetable(params) {
|
|
|
102
109
|
});
|
|
103
110
|
}
|
|
104
111
|
/** 예매 가능한 상영 날짜를 조회합니다. */
|
|
105
|
-
export async function fetchDtryxPlayDates(params) {
|
|
106
|
-
const response =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
export async function fetchDtryxPlayDates(params, options = {}) {
|
|
113
|
+
const response = hasDtryxRelayOptions(options)
|
|
114
|
+
? await requestDtryxRelay('play-dates', { brandCode: params.brandCode, cinemaCode: params.cinemaCode }, options, params.timeout ?? DEFAULT_TIMEOUT_MS)
|
|
115
|
+
: await requestDtryx(DTRYX_API.ENDPOINTS.PLAY_DATE_LIST, DTRYX_API.WORK_GUIDS.PLAY_DATE_LIST, {
|
|
116
|
+
BrandCd: params.brandCode,
|
|
117
|
+
CinemaCd: params.cinemaCode,
|
|
118
|
+
MovieCd: '',
|
|
119
|
+
}, params.timeout);
|
|
111
120
|
return response.Recordset.filter((item) => item.PlaySDT).map((item) => ({
|
|
112
121
|
playDate: toCompactDate(item.PlaySDT),
|
|
113
122
|
hidden: item.HiddenYn === 'Y',
|
|
@@ -115,12 +124,14 @@ export async function fetchDtryxPlayDates(params) {
|
|
|
115
124
|
}));
|
|
116
125
|
}
|
|
117
126
|
/** 현재 상영작 목록을 조회합니다. */
|
|
118
|
-
export async function fetchDtryxNowShowing(params) {
|
|
119
|
-
const response =
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
export async function fetchDtryxNowShowing(params, options = {}) {
|
|
128
|
+
const response = hasDtryxRelayOptions(options)
|
|
129
|
+
? await requestDtryxRelay('movies', { brandCode: params.brandCode, cinemaCode: params.cinemaCode }, options, params.timeout ?? DEFAULT_TIMEOUT_MS)
|
|
130
|
+
: await requestDtryx(DTRYX_API.ENDPOINTS.MOVIE_NOW, DTRYX_API.WORK_GUIDS.MOVIE_NOW, {
|
|
131
|
+
BrandCd: params.brandCode,
|
|
132
|
+
CinemaCd: params.cinemaCode,
|
|
133
|
+
ImgSize: 'small',
|
|
134
|
+
}, params.timeout);
|
|
124
135
|
return response.Recordset.filter((item) => item.MovieCd && item.MovieNm).map((item) => ({
|
|
125
136
|
movieCode: String(item.MovieCd),
|
|
126
137
|
movieName: item.MovieNm,
|
|
@@ -13,12 +13,20 @@ const DTRYX_METADATA = {
|
|
|
13
13
|
description: '디트릭스 예매 플랫폼 기반 독립·예술영화관 목록, 상영작, 상영시간표·잔여 좌석 조회 서비스',
|
|
14
14
|
};
|
|
15
15
|
class DtryxService {
|
|
16
|
+
options;
|
|
16
17
|
metadata = DTRYX_METADATA;
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
}
|
|
17
21
|
getTools() {
|
|
18
|
-
return [
|
|
22
|
+
return [
|
|
23
|
+
createListCinemasTool(),
|
|
24
|
+
createListNowShowingTool(this.options),
|
|
25
|
+
createGetRemainingSeatsTool(this.options),
|
|
26
|
+
];
|
|
19
27
|
}
|
|
20
28
|
}
|
|
21
|
-
export function createDtryxService() {
|
|
22
|
-
return new DtryxService();
|
|
29
|
+
export function createDtryxService(options = {}) {
|
|
30
|
+
return new DtryxService(options);
|
|
23
31
|
}
|
|
24
32
|
export * from './types.js';
|
|
@@ -12,7 +12,7 @@ function matchesMovie(showtime, movieName) {
|
|
|
12
12
|
const normalize = (value) => value.replace(/\s+/g, '').toLowerCase();
|
|
13
13
|
return normalize(showtime.movieName).includes(normalize(movieName));
|
|
14
14
|
}
|
|
15
|
-
async function getRemainingSeats(args) {
|
|
15
|
+
async function getRemainingSeats(args, options) {
|
|
16
16
|
if (hasInvalidDtryxOptions(args)) {
|
|
17
17
|
return { isError: true, content: [{ type: 'text', text: DTRYX_INVALID_INPUT }] };
|
|
18
18
|
}
|
|
@@ -37,7 +37,7 @@ async function getRemainingSeats(args) {
|
|
|
37
37
|
cinemaCode: cinema.cinemaCode,
|
|
38
38
|
playDate,
|
|
39
39
|
timeout: timeoutMs,
|
|
40
|
-
})));
|
|
40
|
+
}, options)));
|
|
41
41
|
const failedCinemas = cinemas
|
|
42
42
|
.filter((_, index) => settled[index]?.status === 'rejected')
|
|
43
43
|
.map((cinema) => cinema.cinemaName || cinema.cinemaCode);
|
|
@@ -84,7 +84,7 @@ async function getRemainingSeats(args) {
|
|
|
84
84
|
],
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
-
export function createGetRemainingSeatsTool() {
|
|
87
|
+
export function createGetRemainingSeatsTool(options = {}) {
|
|
88
88
|
return {
|
|
89
89
|
name: 'dtryx_get_remaining_seats',
|
|
90
90
|
metadata: {
|
|
@@ -112,6 +112,6 @@ export function createGetRemainingSeatsTool() {
|
|
|
112
112
|
.describe('요청 제한 시간(ms, 기본값: 15000)'),
|
|
113
113
|
},
|
|
114
114
|
},
|
|
115
|
-
handler: ((args) => getRemainingSeats(args)),
|
|
115
|
+
handler: ((args) => getRemainingSeats(args, options)),
|
|
116
116
|
};
|
|
117
117
|
}
|
|
@@ -5,7 +5,7 @@ import * as z from 'zod';
|
|
|
5
5
|
import { DTRYX_INVALID_INPUT, hasInvalidDtryxOptions, dtryxTimeoutSchema } from '../validation.js';
|
|
6
6
|
import { resolveDtryxCinemas } from '../location.js';
|
|
7
7
|
import { fetchDtryxNowShowing, fetchDtryxPlayDates } from '../client.js';
|
|
8
|
-
async function listNowShowing(args) {
|
|
8
|
+
async function listNowShowing(args, options) {
|
|
9
9
|
if (hasInvalidDtryxOptions(args)) {
|
|
10
10
|
return { isError: true, content: [{ type: 'text', text: DTRYX_INVALID_INPUT }] };
|
|
11
11
|
}
|
|
@@ -31,8 +31,8 @@ async function listNowShowing(args) {
|
|
|
31
31
|
timeout: timeoutMs,
|
|
32
32
|
};
|
|
33
33
|
const [movies, playDates] = await Promise.all([
|
|
34
|
-
fetchDtryxNowShowing(request),
|
|
35
|
-
includePlayDates ? fetchDtryxPlayDates(request) : Promise.resolve([]),
|
|
34
|
+
fetchDtryxNowShowing(request, options),
|
|
35
|
+
includePlayDates ? fetchDtryxPlayDates(request, options) : Promise.resolve([]),
|
|
36
36
|
]);
|
|
37
37
|
return {
|
|
38
38
|
content: [
|
|
@@ -48,7 +48,7 @@ async function listNowShowing(args) {
|
|
|
48
48
|
],
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
|
-
export function createListNowShowingTool() {
|
|
51
|
+
export function createListNowShowingTool(options = {}) {
|
|
52
52
|
return {
|
|
53
53
|
name: 'dtryx_list_now_showing',
|
|
54
54
|
metadata: {
|
|
@@ -72,6 +72,6 @@ export function createListNowShowingTool() {
|
|
|
72
72
|
.describe('요청 제한 시간(ms, 기본값: 15000)'),
|
|
73
73
|
},
|
|
74
74
|
},
|
|
75
|
-
handler: ((args) => listNowShowing(args)),
|
|
75
|
+
handler: ((args) => listNowShowing(args, options)),
|
|
76
76
|
};
|
|
77
77
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { diagnosticHeaders } from '../../utils/diagnostics.js';
|
|
2
|
+
/** 디트릭스 전용 중계 설정과 인증 전송입니다. */
|
|
3
|
+
import { ServiceError } from '../../core/errors.js';
|
|
4
|
+
import { createTimeoutController } from '../../utils/http.js';
|
|
5
|
+
export function isValidDtryxRelayUrl(value) {
|
|
6
|
+
if (!value?.trim())
|
|
7
|
+
return false;
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
return (!url.username &&
|
|
11
|
+
!url.password &&
|
|
12
|
+
!url.search &&
|
|
13
|
+
!url.hash &&
|
|
14
|
+
(url.protocol === 'https:' ||
|
|
15
|
+
(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** 바인딩은 요청마다 변환하며 프로세스 전역에 저장하지 않습니다. */
|
|
22
|
+
export function dtryxTransportFromBindings(bindings) {
|
|
23
|
+
return {
|
|
24
|
+
relayUrl: bindings?.DTRYX_RELAY_URL,
|
|
25
|
+
relayToken: bindings?.DTRYX_RELAY_TOKEN,
|
|
26
|
+
accessClientId: bindings?.DTRYX_ACCESS_CLIENT_ID,
|
|
27
|
+
accessClientSecret: bindings?.DTRYX_ACCESS_CLIENT_SECRET,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function hasDtryxRelayOptions(options) {
|
|
31
|
+
return Object.values(options).some((value) => value !== undefined);
|
|
32
|
+
}
|
|
33
|
+
export async function requestDtryxRelay(path, body, options, timeout) {
|
|
34
|
+
const { relayUrl, relayToken, accessClientId, accessClientSecret } = options;
|
|
35
|
+
const accessPresent = accessClientId !== undefined || accessClientSecret !== undefined;
|
|
36
|
+
if (!isValidDtryxRelayUrl(relayUrl) ||
|
|
37
|
+
!relayToken?.trim() ||
|
|
38
|
+
(accessPresent && (!accessClientId?.trim() || !accessClientSecret?.trim()))) {
|
|
39
|
+
throw new ServiceError('DTRYX_RELAY_CONFIG_ERROR', '디트릭스 릴레이 URL, 토큰 및 Access 설정을 확인하세요.', 503, false);
|
|
40
|
+
}
|
|
41
|
+
const headers = {
|
|
42
|
+
...diagnosticHeaders(),
|
|
43
|
+
'Content-Type': 'application/json',
|
|
44
|
+
Accept: 'application/json',
|
|
45
|
+
Authorization: `Bearer ${relayToken}`,
|
|
46
|
+
};
|
|
47
|
+
if (accessPresent) {
|
|
48
|
+
headers['CF-Access-Client-Id'] = accessClientId;
|
|
49
|
+
headers['CF-Access-Client-Secret'] = accessClientSecret;
|
|
50
|
+
}
|
|
51
|
+
const { controller, timeoutId } = createTimeoutController(timeout);
|
|
52
|
+
try {
|
|
53
|
+
// 리다이렉트를 따라 인증 정보를 다른 호스트에 전달하지 않습니다.
|
|
54
|
+
const response = await fetch(`${relayUrl.replace(/\/$/, '')}/v1/dtryx/${path}`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers,
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
redirect: 'manual',
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
});
|
|
61
|
+
if (response.status !== 200) {
|
|
62
|
+
void response.body?.cancel().catch(() => { });
|
|
63
|
+
const status = response.status;
|
|
64
|
+
const mapped = status === 429 || status === 503 || status === 504 ? status : 502;
|
|
65
|
+
throw new ServiceError('DTRYX_RELAY_FAILED', '디트릭스 릴레이 요청에 실패했습니다.', mapped, status === 429 || status >= 500, status);
|
|
66
|
+
}
|
|
67
|
+
const result = await response.json();
|
|
68
|
+
if (typeof result !== 'object' ||
|
|
69
|
+
result === null ||
|
|
70
|
+
!('RetCode' in result) ||
|
|
71
|
+
result.RetCode !== 'success' ||
|
|
72
|
+
!('Recordset' in result) ||
|
|
73
|
+
!Array.isArray(result.Recordset) ||
|
|
74
|
+
!result.Recordset.every((item) => typeof item === 'object' && item !== null && !Array.isArray(item))) {
|
|
75
|
+
throw new Error('응답 형식 오류');
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof ServiceError)
|
|
81
|
+
throw error;
|
|
82
|
+
if (controller.signal.aborted) {
|
|
83
|
+
throw new ServiceError('DTRYX_RELAY_TIMEOUT', '디트릭스 릴레이 요청 시간이 초과되었습니다.', 504, true);
|
|
84
|
+
}
|
|
85
|
+
throw new ServiceError('DTRYX_RELAY_FAILED', '디트릭스 릴레이 요청에 실패했습니다.', 502, true);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
clearTimeout(timeoutId);
|
|
89
|
+
controller.abort();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { diagnosticHeaders } from '../../utils/diagnostics.js';
|
|
1
2
|
/** 올리브영 무료 직접 요청 및 운영자가 설정한 브라우저 릴레이 전송. */
|
|
2
3
|
import { fetchJson } from '../../utils/http.js';
|
|
3
4
|
import { toOliveyoungRelayError } from './errors.js';
|
|
@@ -35,6 +36,7 @@ export async function requestOliveyoung(path, body, options = {}) {
|
|
|
35
36
|
if (!relayToken?.trim())
|
|
36
37
|
throw new Error('OY_RELAY_TOKEN이 필요합니다.');
|
|
37
38
|
url = `${relayUrl.replace(/\/$/, '')}/v1/oliveyoung/${path.split('/').pop()}`;
|
|
39
|
+
Object.assign(headers, diagnosticHeaders());
|
|
38
40
|
headers.Authorization = `Bearer ${relayToken}`;
|
|
39
41
|
const { accessClientId, accessClientSecret } = options;
|
|
40
42
|
if (accessClientId !== undefined || accessClientSecret !== undefined) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** 비밀값과 원문 없이 요청 경계를 연결하는 진단 컨텍스트입니다. */
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
const context = new AsyncLocalStorage();
|
|
4
|
+
export function withDiagnostics(work) {
|
|
5
|
+
if (context.getStore())
|
|
6
|
+
return work();
|
|
7
|
+
return context.run({ requestId: crypto.randomUUID(), sampled: Math.random() < 0.01, events: 0 }, work);
|
|
8
|
+
}
|
|
9
|
+
export function diagnosticHeaders() {
|
|
10
|
+
const current = context.getStore();
|
|
11
|
+
return current ? { 'x-request-id': current.requestId } : {};
|
|
12
|
+
}
|
|
13
|
+
export function diagnosticEvent(event) {
|
|
14
|
+
const current = context.getStore();
|
|
15
|
+
// 요청당 상한으로 재시도/팬아웃 로그가 무한히 늘어나지 않게 합니다.
|
|
16
|
+
if (!current || (event.outcome === 'ok' && !current.sampled) || current.events >= 32)
|
|
17
|
+
return;
|
|
18
|
+
current.events++;
|
|
19
|
+
console.error(JSON.stringify({
|
|
20
|
+
event: 'diagnostic',
|
|
21
|
+
time: new Date().toISOString(),
|
|
22
|
+
requestId: current.requestId,
|
|
23
|
+
stage: event.stage,
|
|
24
|
+
outcome: event.outcome,
|
|
25
|
+
operation: event.operation,
|
|
26
|
+
status: event.status,
|
|
27
|
+
durationMs: event.durationMs,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
const services = new Set([
|
|
31
|
+
'daiso',
|
|
32
|
+
'oliveyoung',
|
|
33
|
+
'dtryx',
|
|
34
|
+
'gs25',
|
|
35
|
+
'cu',
|
|
36
|
+
'seveneleven',
|
|
37
|
+
'emart24',
|
|
38
|
+
'lottemart',
|
|
39
|
+
'cgv',
|
|
40
|
+
'megabox',
|
|
41
|
+
'lottecinema',
|
|
42
|
+
'opinet',
|
|
43
|
+
'places',
|
|
44
|
+
'compare',
|
|
45
|
+
'feedback',
|
|
46
|
+
'health',
|
|
47
|
+
'rate-limit',
|
|
48
|
+
'actions',
|
|
49
|
+
]);
|
|
50
|
+
const operations = new Set([
|
|
51
|
+
'products',
|
|
52
|
+
'stores',
|
|
53
|
+
'inventory',
|
|
54
|
+
'movies',
|
|
55
|
+
'theaters',
|
|
56
|
+
'timetable',
|
|
57
|
+
'play-dates',
|
|
58
|
+
'seats',
|
|
59
|
+
'remaining-seats',
|
|
60
|
+
'average',
|
|
61
|
+
'lowest',
|
|
62
|
+
'requests',
|
|
63
|
+
'checks',
|
|
64
|
+
'stats',
|
|
65
|
+
'query',
|
|
66
|
+
'popwords',
|
|
67
|
+
'price',
|
|
68
|
+
]);
|
|
69
|
+
export function diagnosticOperation(url) {
|
|
70
|
+
const parts = new URL(url).pathname.split('/');
|
|
71
|
+
if (parts[1] === 'mcp')
|
|
72
|
+
return 'mcp';
|
|
73
|
+
if (parts[1] === 'api' && services.has(parts[2]) && operations.has(parts[3]))
|
|
74
|
+
return `${parts[2]}.${parts[3]}`;
|
|
75
|
+
return 'other';
|
|
76
|
+
}
|
package/dist/utils/http.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* 공용 HTTP 유틸리티
|
|
3
|
-
*/
|
|
1
|
+
import { diagnosticEvent } from './diagnostics.js';
|
|
4
2
|
export class HttpError extends Error {
|
|
5
3
|
status;
|
|
6
4
|
statusText;
|
|
@@ -73,11 +71,15 @@ async function requestWithTimeout(url, options, readResponse) {
|
|
|
73
71
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
74
72
|
const { controller, timeoutId } = createTimeoutController(timeout);
|
|
75
73
|
let readingResponse = false;
|
|
74
|
+
const started = Date.now();
|
|
75
|
+
let responseStatus;
|
|
76
76
|
try {
|
|
77
77
|
const response = await fetch(url, {
|
|
78
78
|
...restOptions,
|
|
79
79
|
signal: controller.signal,
|
|
80
80
|
});
|
|
81
|
+
responseStatus = response.status;
|
|
82
|
+
diagnosticEvent({ stage: 'http', outcome: response.ok ? 'ok' : 'error', status: response.status, durationMs: Date.now() - started });
|
|
81
83
|
if (retryAllowed &&
|
|
82
84
|
attempt < maxAttempts &&
|
|
83
85
|
isRetryableStatus(response.status, retryStatusCodes)) {
|
|
@@ -99,6 +101,7 @@ async function requestWithTimeout(url, options, readResponse) {
|
|
|
99
101
|
return await readResponse(response);
|
|
100
102
|
}
|
|
101
103
|
catch (error) {
|
|
104
|
+
diagnosticEvent({ stage: 'http', outcome: 'error', status: responseStatus, durationMs: Date.now() - started });
|
|
102
105
|
if (readingResponse || !retryAllowed || attempt >= maxAttempts) {
|
|
103
106
|
throw error;
|
|
104
107
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** 핸들러가 읽는 query만 캐시 키에 포함하고 동일한 기본값을 합칩니다. */
|
|
2
|
+
const product = { keyword: '', page: '1', size: '20', sort: '01', includeSoldOut: 'false' };
|
|
3
|
+
const location = { lat: '37.5665', lng: '126.978' };
|
|
4
|
+
const defaults = {
|
|
5
|
+
'/api/oliveyoung/products': product,
|
|
6
|
+
'/api/oliveyoung/stores': { keyword: '', ...location, pageIdx: '1', limit: '20' },
|
|
7
|
+
'/api/oliveyoung/inventory': {
|
|
8
|
+
...product,
|
|
9
|
+
...location,
|
|
10
|
+
storeKeyword: '',
|
|
11
|
+
storeLimit: '10',
|
|
12
|
+
stockCheckLimit: '5',
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
export function oliveyoungCacheKey(input) {
|
|
16
|
+
const url = new URL(input);
|
|
17
|
+
const values = defaults[url.pathname];
|
|
18
|
+
if (!values)
|
|
19
|
+
return input;
|
|
20
|
+
const key = new URL(url.origin + url.pathname);
|
|
21
|
+
for (const [name, fallback] of Object.entries({ ...values, timeoutMs: '15000' })) {
|
|
22
|
+
key.searchParams.set(name, url.searchParams.get(name) || fallback);
|
|
23
|
+
}
|
|
24
|
+
const fresh = url.searchParams.get('_healthCheck');
|
|
25
|
+
if (fresh !== null)
|
|
26
|
+
key.searchParams.set('_healthCheck', fresh);
|
|
27
|
+
return key.toString();
|
|
28
|
+
}
|
package/package.json
CHANGED