daiso 1.0.10 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +77 -5
  2. package/dist/api/actionsProxy.js +3 -0
  3. package/dist/api/cgvHandlers.js +15 -3
  4. package/dist/api/configStatus.js +2 -1
  5. package/dist/api/dtryxHandlers.js +113 -0
  6. package/dist/api/emart24Handlers.js +6 -2
  7. package/dist/api/gs25Handlers.js +22 -19
  8. package/dist/api/handlers.js +13 -5
  9. package/dist/api/healthCheckDefinitions.js +46 -16
  10. package/dist/api/healthCheckShape.js +24 -8
  11. package/dist/api/healthChecks.js +39 -20
  12. package/dist/api/operationalAuth.js +32 -0
  13. package/dist/api/routes/dtryxRoutes.js +22 -0
  14. package/dist/api/routes/healthRoutes.js +19 -11
  15. package/dist/api/routes/rateLimitStatsRoutes.js +274 -0
  16. package/dist/api/routes/registerServiceRoutes.js +37 -0
  17. package/dist/api/sevenelevenHandlers.js +7 -3
  18. package/dist/cli/commandOptions.js +24 -0
  19. package/dist/cli/commands/cinema.js +27 -0
  20. package/dist/cli.js +7 -1
  21. package/dist/cliHelp.js +3 -337
  22. package/dist/cliHelpDefinitions.js +369 -0
  23. package/dist/cliInteractive.js +8 -5
  24. package/dist/core/errors.js +29 -2
  25. package/dist/core/registry.js +13 -15
  26. package/dist/durableObjects/dailyRateLimiter.js +185 -0
  27. package/dist/durableObjects/rateLimitMetricsStore.js +144 -0
  28. package/dist/index.js +18 -31
  29. package/dist/middleware/dailyRateLimit.js +176 -0
  30. package/dist/pages/openapiFullSpec.js +2 -0
  31. package/dist/pages/openapiSpecActionParameters.js +429 -0
  32. package/dist/pages/openapiSpecActions.js +1 -391
  33. package/dist/pages/openapiSpecComponentsLottecinema.js +2 -2
  34. package/dist/pages/openapiSpecPathsDtryx.js +148 -0
  35. package/dist/pages/openapiSpecPathsLotteMart.js +10 -2
  36. package/dist/pages/promptRetailCinemaText.js +9 -1
  37. package/dist/services/cgv/errors.js +13 -0
  38. package/dist/services/cgv/transport.js +5 -22
  39. package/dist/services/cu/client.js +44 -30
  40. package/dist/services/cu/index.js +13 -3
  41. package/dist/services/cu/tools/checkInventory.js +65 -45
  42. package/dist/services/cu/tools/findNearbyStores.js +4 -3
  43. package/dist/services/cu/upstreamError.js +11 -0
  44. package/dist/services/dtryx/api.js +25 -0
  45. package/dist/services/dtryx/client.js +133 -0
  46. package/dist/services/dtryx/index.js +24 -0
  47. package/dist/services/dtryx/location.js +216 -0
  48. package/dist/services/dtryx/tools/getRemainingSeats.js +117 -0
  49. package/dist/services/dtryx/tools/listCinemas.js +46 -0
  50. package/dist/services/dtryx/tools/listNowShowing.js +77 -0
  51. package/dist/services/dtryx/types.js +4 -0
  52. package/dist/services/dtryx/validation.js +21 -0
  53. package/dist/services/feedback/tools/submitDeveloperRequest.js +15 -1
  54. package/dist/services/gs25/api.js +2 -2
  55. package/dist/services/gs25/client.js +16 -123
  56. package/dist/services/gs25/errors.js +12 -0
  57. package/dist/services/gs25/index.js +2 -2
  58. package/dist/services/gs25/productSearch.js +2 -14
  59. package/dist/services/gs25/storeStockTransport.js +30 -0
  60. package/dist/services/gs25/tools/checkInventory.js +7 -2
  61. package/dist/services/gs25/tools/findNearbyStores.js +20 -5
  62. package/dist/services/lottecinema/client.js +4 -2
  63. package/dist/services/lottemart/config.js +1 -1
  64. package/dist/services/lottemart/session.js +33 -201
  65. package/dist/services/lottemart/socketTransport.js +149 -0
  66. package/dist/services/oliveyoung/client.js +7 -39
  67. package/dist/services/oliveyoung/index.js +4 -4
  68. package/dist/services/oliveyoung/tools/checkInventory.js +6 -3
  69. package/dist/services/oliveyoung/tools/findNearbyStores.js +4 -3
  70. package/dist/services/oliveyoung/tools/searchProducts.js +4 -3
  71. package/dist/services/oliveyoung/transport.js +46 -0
  72. package/dist/services/seveneleven/client.js +20 -9
  73. package/dist/services/seveneleven/index.js +11 -7
  74. package/dist/services/seveneleven/inventory.js +5 -2
  75. package/dist/services/seveneleven/productKeyword.js +3 -2
  76. package/dist/services/seveneleven/readCache.js +49 -0
  77. package/dist/services/seveneleven/tools/checkInventory.js +4 -3
  78. package/dist/services/seveneleven/tools/getCatalogSnapshot.js +4 -3
  79. package/dist/services/seveneleven/tools/getSearchPopwords.js +4 -3
  80. package/dist/services/seveneleven/tools/searchProducts.js +4 -3
  81. package/dist/services/seveneleven/tools/searchStores.js +4 -3
  82. package/dist/utils/format.js +10 -3
  83. package/dist/utils/http.js +40 -12
  84. package/dist/utils/zyte.js +4 -101
  85. package/dist/utils/zyteJsonFallback.js +28 -0
  86. package/openapi.json +51 -3
  87. package/openapi.yaml +36 -3
  88. package/package.json +28 -27
@@ -0,0 +1,185 @@
1
+ /**
2
+ * IP별 KST 일일 호출량을 저장하는 Durable Object
3
+ */
4
+ import { RATE_LIMIT_SERVICES, RateLimitMetricsStore, StaleRateLimitEventDayError, } from './rateLimitMetricsStore.js';
5
+ export const DAILY_RATE_LIMIT = 3000;
6
+ const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
7
+ const COUNTER_KEY = 'counter';
8
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
9
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10
+ // DurableObjectId.toString()이 반환하는 namespace-scoped 불투명 ID 형식입니다.
11
+ const DURABLE_OBJECT_ID_PATTERN = /^[0-9a-f]{64}$/;
12
+ const BLOCKED_EVENT_FIELDS = ['eventId', 'occurredAt', 'day', 'service', 'identityId'];
13
+ export function toKstDay(nowMs) {
14
+ return new Date(nowMs + KST_OFFSET_MS).toISOString().slice(0, 10);
15
+ }
16
+ export function nextKstMidnightEpochSeconds(nowMs) {
17
+ const shifted = new Date(nowMs + KST_OFFSET_MS);
18
+ const nextMidnightUtcMs = Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate() + 1) -
19
+ KST_OFFSET_MS;
20
+ return Math.floor(nextMidnightUtcMs / 1000);
21
+ }
22
+ function isRecord(value) {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
24
+ }
25
+ function isExactDate(value) {
26
+ if (typeof value !== 'string' || !DATE_PATTERN.test(value)) {
27
+ return false;
28
+ }
29
+ const timestamp = Date.parse(`${value}T00:00:00.000Z`);
30
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value;
31
+ }
32
+ function isRateLimitService(value) {
33
+ return typeof value === 'string' && RATE_LIMIT_SERVICES.includes(value);
34
+ }
35
+ function isBlockedRateLimitEvent(value) {
36
+ if (!isRecord(value) ||
37
+ Object.keys(value).length !== BLOCKED_EVENT_FIELDS.length ||
38
+ !BLOCKED_EVENT_FIELDS.every((field) => Object.hasOwn(value, field))) {
39
+ return false;
40
+ }
41
+ const occurredAt = value.occurredAt;
42
+ return (typeof value.eventId === 'string' &&
43
+ UUID_PATTERN.test(value.eventId) &&
44
+ typeof occurredAt === 'number' &&
45
+ Number.isSafeInteger(occurredAt) &&
46
+ occurredAt >= 0 &&
47
+ Number.isFinite(new Date(occurredAt).getTime()) &&
48
+ Number.isFinite(new Date(occurredAt + KST_OFFSET_MS).getTime()) &&
49
+ isExactDate(value.day) &&
50
+ value.day === toKstDay(occurredAt) &&
51
+ isRateLimitService(value.service) &&
52
+ typeof value.identityId === 'string' &&
53
+ DURABLE_OBJECT_ID_PATTERN.test(value.identityId));
54
+ }
55
+ async function parseBlockedEvent(request) {
56
+ let value;
57
+ try {
58
+ value = await request.json();
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ if (!isBlockedRateLimitEvent(value)) {
64
+ return undefined;
65
+ }
66
+ // 검증한 필드만 새 객체로 만들어 원장에 전달합니다.
67
+ return {
68
+ eventId: value.eventId,
69
+ occurredAt: value.occurredAt,
70
+ day: value.day,
71
+ service: value.service,
72
+ identityId: value.identityId,
73
+ };
74
+ }
75
+ function hasStrictStatsParameters(searchParams) {
76
+ let fromCount = 0;
77
+ let toCount = 0;
78
+ let asOfCount = 0;
79
+ let serviceCount = 0;
80
+ for (const [key] of searchParams) {
81
+ if (key === 'from') {
82
+ fromCount += 1;
83
+ }
84
+ else if (key === 'to') {
85
+ toCount += 1;
86
+ }
87
+ else if (key === 'asOf') {
88
+ asOfCount += 1;
89
+ }
90
+ else if (key === 'service') {
91
+ serviceCount += 1;
92
+ }
93
+ else {
94
+ return false;
95
+ }
96
+ }
97
+ return fromCount === 1 && toCount === 1 && asOfCount === 1 && serviceCount <= 1;
98
+ }
99
+ function parseStatsInput(url) {
100
+ if (!hasStrictStatsParameters(url.searchParams)) {
101
+ return undefined;
102
+ }
103
+ const from = url.searchParams.get('from');
104
+ const to = url.searchParams.get('to');
105
+ const asOfDay = url.searchParams.get('asOf');
106
+ const service = url.searchParams.get('service');
107
+ if (!isExactDate(from) || !isExactDate(to) || !isExactDate(asOfDay) || from > to) {
108
+ return undefined;
109
+ }
110
+ if (service !== null && !isRateLimitService(service)) {
111
+ return undefined;
112
+ }
113
+ return service === null ? { from, to, asOfDay } : { from, to, asOfDay, service };
114
+ }
115
+ function errorResponse(message, status) {
116
+ return Response.json({ error: message }, { status });
117
+ }
118
+ export class DailyRateLimiter {
119
+ state;
120
+ metricsStore;
121
+ constructor(state) {
122
+ this.state = state;
123
+ }
124
+ async consume(nowMs = Date.now()) {
125
+ const day = toKstDay(nowMs);
126
+ const stored = await this.state.storage.get(COUNTER_KEY);
127
+ const currentCount = stored?.day === day ? stored.count : 0;
128
+ const allowed = currentCount < DAILY_RATE_LIMIT;
129
+ const count = allowed ? currentCount + 1 : currentCount;
130
+ if (allowed) {
131
+ await this.state.storage.put(COUNTER_KEY, { day, count });
132
+ }
133
+ return {
134
+ allowed,
135
+ count,
136
+ remaining: Math.max(0, DAILY_RATE_LIMIT - count),
137
+ resetAt: nextKstMidnightEpochSeconds(nowMs),
138
+ day,
139
+ };
140
+ }
141
+ async fetch(request) {
142
+ const url = new URL(request.url);
143
+ if (request.method === 'POST' && url.pathname === '/consume') {
144
+ return Response.json(await this.consume());
145
+ }
146
+ if (request.method === 'POST' && url.pathname === '/blocked-events') {
147
+ const event = await parseBlockedEvent(request);
148
+ if (!event) {
149
+ return errorResponse('차단 이벤트 형식이 올바르지 않습니다.', 400);
150
+ }
151
+ const store = this.getMetricsStore();
152
+ try {
153
+ await store.record(event);
154
+ }
155
+ catch (error) {
156
+ if (error instanceof StaleRateLimitEventDayError) {
157
+ return errorResponse('차단 이벤트 날짜가 만료되었습니다.', 409);
158
+ }
159
+ throw error;
160
+ }
161
+ return new Response(null, { status: 204 });
162
+ }
163
+ if (request.method === 'GET' && url.pathname === '/stats') {
164
+ const input = parseStatsInput(url);
165
+ if (!input) {
166
+ return errorResponse('통계 조회 조건이 올바르지 않습니다.', 400);
167
+ }
168
+ if (input.asOfDay !== toKstDay(Date.now())) {
169
+ return errorResponse('통계 조회 기준 날짜가 만료되었습니다.', 409);
170
+ }
171
+ const store = this.getMetricsStore();
172
+ return Response.json(await store.query(input));
173
+ }
174
+ return errorResponse('요청한 작업을 찾을 수 없습니다.', 404);
175
+ }
176
+ async alarm() {
177
+ const store = this.getMetricsStore();
178
+ await store.cleanup();
179
+ await store.ensureAlarm();
180
+ }
181
+ getMetricsStore() {
182
+ this.metricsStore ??= new RateLimitMetricsStore(this.state);
183
+ return this.metricsStore;
184
+ }
185
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 차단된 일일 호출 제한 이벤트를 정확히 집계하는 SQLite 저장소
3
+ */
4
+ export const RATE_LIMIT_METRICS_RETENTION_DAYS = 30;
5
+ export const RATE_LIMIT_METRICS_LEDGER_NAME = '__blocked-ledger-v1__';
6
+ export const RATE_LIMIT_SERVICES = ['oliveyoung', 'cgv', 'cu', 'gs25', 'lottemart'];
7
+ export class StaleRateLimitEventDayError extends Error {
8
+ constructor() {
9
+ super('차단 이벤트 날짜가 만료되었습니다.');
10
+ this.name = 'StaleRateLimitEventDayError';
11
+ }
12
+ }
13
+ const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
14
+ const ALARM_DELAY_MS = 5 * 60 * 1000;
15
+ // 요청 경로의 SQLite 쓰기 증폭을 제한하고 나머지는 일일 alarm에 맡깁니다.
16
+ const OPPORTUNISTIC_CLEANUP_LIMIT = 100;
17
+ function toKstDay(nowMs) {
18
+ return new Date(nowMs + KST_OFFSET_MS).toISOString().slice(0, 10);
19
+ }
20
+ function retainedCutoffDay(asOfDay) {
21
+ const asOf = new Date(`${asOfDay}T00:00:00.000Z`);
22
+ return new Date(Date.UTC(asOf.getUTCFullYear(), asOf.getUTCMonth(), asOf.getUTCDate() - (RATE_LIMIT_METRICS_RETENTION_DAYS - 1)))
23
+ .toISOString()
24
+ .slice(0, 10);
25
+ }
26
+ function retentionCutoffDay(nowMs) {
27
+ return retainedCutoffDay(toKstDay(nowMs));
28
+ }
29
+ function nextAlarmTime(nowMs) {
30
+ const shifted = new Date(nowMs + KST_OFFSET_MS);
31
+ return (Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate() + 1) -
32
+ KST_OFFSET_MS +
33
+ ALARM_DELAY_MS);
34
+ }
35
+ function isRateLimitService(service) {
36
+ return RATE_LIMIT_SERVICES.includes(service);
37
+ }
38
+ function toAggregate(row) {
39
+ return {
40
+ blockedRequests: row.blocked_requests,
41
+ uniqueIdentities: row.unique_identities,
42
+ };
43
+ }
44
+ export class RateLimitMetricsStore {
45
+ state;
46
+ sql;
47
+ constructor(state) {
48
+ this.state = state;
49
+ this.sql = state.storage.sql;
50
+ this.initializeSchema();
51
+ }
52
+ async record(event) {
53
+ if (event.day !== toKstDay(event.occurredAt)) {
54
+ throw new TypeError('이벤트 날짜가 KST 발생 날짜와 일치하지 않습니다.');
55
+ }
56
+ await this.ensureAlarm();
57
+ const commitNowMs = Date.now();
58
+ if (event.day !== toKstDay(commitNowMs)) {
59
+ throw new StaleRateLimitEventDayError();
60
+ }
61
+ this.cleanupExpiredBatch(commitNowMs);
62
+ this.sql.exec(`INSERT OR IGNORE INTO blocked_events
63
+ (event_id, occurred_at, day, service, identity_id)
64
+ VALUES (?, ?, ?, ?, ?)`, event.eventId, event.occurredAt, event.day, event.service, event.identityId);
65
+ }
66
+ async query(input) {
67
+ if (input.service !== undefined && !isRateLimitService(input.service)) {
68
+ throw new TypeError('지원하지 않는 서비스입니다.');
69
+ }
70
+ const cutoffDay = retainedCutoffDay(input.asOfDay);
71
+ const serviceClause = input.service === undefined ? '' : ' AND service = ?';
72
+ const bindings = input.service === undefined
73
+ ? [input.from, input.to, cutoffDay]
74
+ : [input.from, input.to, cutoffDay, input.service];
75
+ const whereClause = `day >= ? AND day <= ? AND day >= ?${serviceClause}`;
76
+ const totals = this.sql
77
+ .exec(`SELECT COUNT(*) AS blocked_requests,
78
+ COUNT(DISTINCT identity_id) AS unique_identities
79
+ FROM blocked_events
80
+ WHERE ${whereClause}`, ...bindings)
81
+ .one();
82
+ const daily = this.sql
83
+ .exec(`SELECT day,
84
+ COUNT(*) AS blocked_requests,
85
+ COUNT(DISTINCT identity_id) AS unique_identities
86
+ FROM blocked_events
87
+ WHERE ${whereClause}
88
+ GROUP BY day
89
+ ORDER BY day ASC`, ...bindings)
90
+ .toArray();
91
+ const services = this.sql
92
+ .exec(`SELECT day,
93
+ service,
94
+ COUNT(*) AS blocked_requests,
95
+ COUNT(DISTINCT identity_id) AS unique_identities
96
+ FROM blocked_events
97
+ WHERE ${whereClause}
98
+ GROUP BY day, service
99
+ ORDER BY day ASC, service ASC`, ...bindings)
100
+ .toArray();
101
+ const stats = {
102
+ totals: toAggregate(totals),
103
+ daily: daily.map((row) => ({ day: row.day, ...toAggregate(row) })),
104
+ services: services.map((row) => ({
105
+ day: row.day,
106
+ service: row.service,
107
+ ...toAggregate(row),
108
+ })),
109
+ };
110
+ this.cleanupExpiredBatch(Date.now());
111
+ return stats;
112
+ }
113
+ async cleanup(nowMs = Date.now()) {
114
+ this.sql.exec('DELETE FROM blocked_events WHERE day < ?', retentionCutoffDay(nowMs));
115
+ }
116
+ async ensureAlarm(nowMs = Date.now()) {
117
+ if ((await this.state.storage.getAlarm()) !== null) {
118
+ return;
119
+ }
120
+ await this.state.storage.setAlarm(nextAlarmTime(nowMs));
121
+ }
122
+ initializeSchema() {
123
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS blocked_events (
124
+ event_id TEXT PRIMARY KEY,
125
+ occurred_at INTEGER NOT NULL,
126
+ day TEXT NOT NULL,
127
+ service TEXT NOT NULL,
128
+ identity_id TEXT NOT NULL
129
+ )`);
130
+ this.sql.exec('CREATE INDEX IF NOT EXISTS idx_blocked_events_day_service ON blocked_events(day, service)');
131
+ this.sql.exec('CREATE INDEX IF NOT EXISTS idx_blocked_events_day_identity ON blocked_events(day, identity_id)');
132
+ this.sql.exec('CREATE INDEX IF NOT EXISTS idx_blocked_events_day_service_identity ON blocked_events(day, service, identity_id)');
133
+ }
134
+ cleanupExpiredBatch(nowMs) {
135
+ this.sql.exec(`DELETE FROM blocked_events
136
+ WHERE event_id IN (
137
+ SELECT event_id
138
+ FROM blocked_events
139
+ WHERE day < ?
140
+ ORDER BY day ASC, event_id ASC
141
+ LIMIT ?
142
+ )`, retentionCutoffDay(nowMs), OPPORTUNISTIC_CLEANUP_LIMIT);
143
+ }
144
+ }
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/
11
11
  import { ServiceRegistry } from './core/registry.js';
12
12
  import { createDaisoService } from './services/daiso/index.js';
13
13
  import { createOliveyoungService } from './services/oliveyoung/index.js';
14
+ import { createDtryxService } from './services/dtryx/index.js';
14
15
  import { createMegaboxService } from './services/megabox/index.js';
15
16
  import { createCgvService } from './services/cgv/index.js';
16
17
  import { createLotteCinemaService } from './services/lottecinema/index.js';
@@ -27,22 +28,12 @@ import { createPromptResponse } from './pages/prompt.js';
27
28
  import { createFullOpenApiJsonResponse, createFullOpenApiYamlResponse, createOpenApiJsonResponse, createOpenApiYamlResponse, } from './pages/openapi.js';
28
29
  import { createPrivacyResponse } from './pages/privacy.js';
29
30
  import { buildActionQueryTargetUrl } from './api/actionsProxy.js';
30
- import { registerDaisoRoutes } from './api/routes/daisoRoutes.js';
31
- import { registerOliveyoungRoutes } from './api/routes/oliveyoungRoutes.js';
32
- import { registerMegaboxRoutes } from './api/routes/megaboxRoutes.js';
33
- import { registerCgvRoutes } from './api/routes/cgvRoutes.js';
34
- import { registerLotteCinemaRoutes } from './api/routes/lottecinemaRoutes.js';
35
- import { registerLotteMartRoutes } from './api/routes/lottemartRoutes.js';
36
- import { registerCuRoutes } from './api/routes/cuRoutes.js';
37
- import { registerEmart24Routes } from './api/routes/emart24Routes.js';
38
- import { registerGs25Routes } from './api/routes/gs25Routes.js';
39
- import { registerSevenElevenRoutes } from './api/routes/sevenelevenRoutes.js';
40
- import { registerPlacesRoutes } from './api/routes/placesRoutes.js';
41
- import { registerOpinetRoutes } from './api/routes/opinetRoutes.js';
42
- import { registerCompareRoutes } from './api/routes/compareRoutes.js';
43
- import { registerFeedbackRoutes } from './api/routes/feedbackRoutes.js';
44
31
  import { registerHealthRoutes } from './api/routes/healthRoutes.js';
32
+ import { registerServiceRoutes } from './api/routes/registerServiceRoutes.js';
33
+ import { registerRateLimitStatsRoutes } from './api/routes/rateLimitStatsRoutes.js';
45
34
  import { buildConfigStatus } from './api/configStatus.js';
35
+ import { createDailyRateLimitMiddleware } from './middleware/dailyRateLimit.js';
36
+ export { DailyRateLimiter } from './durableObjects/dailyRateLimiter.js';
46
37
  // 서버 메타데이터
47
38
  const SERVER_NAME = 'multi-service-mcp';
48
39
  const SERVER_VERSION = '1.0.0';
@@ -59,8 +50,9 @@ const createRegistry = (bindings) => {
59
50
  () => createGs25Service({
60
51
  googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
61
52
  zyteApiKey: bindings?.ZYTE_API_KEY,
53
+ apiKey: bindings?.GS25_API_KEY,
62
54
  }),
63
- createSevenElevenService,
55
+ () => createSevenElevenService({ zyteApiKey: bindings?.ZYTE_API_KEY }),
64
56
  createCompareService,
65
57
  () => createFeedbackService({
66
58
  supabaseUrl: bindings?.SUPABASE_URL,
@@ -74,13 +66,17 @@ const createRegistry = (bindings) => {
74
66
  apiKey: bindings?.OPINET_API_KEY,
75
67
  googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
76
68
  }),
77
- createCuService,
69
+ () => createCuService({
70
+ zyteApiKey: bindings?.ZYTE_API_KEY,
71
+ googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
72
+ }),
78
73
  createEmart24Service,
79
74
  () => createLotteMartService({
80
75
  googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
81
76
  zyteApiKey: bindings?.ZYTE_API_KEY,
82
77
  }),
83
78
  createMegaboxService,
79
+ createDtryxService,
84
80
  () => createLotteCinemaService({
85
81
  googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
86
82
  }),
@@ -89,7 +85,8 @@ const createRegistry = (bindings) => {
89
85
  googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
90
86
  }),
91
87
  () => createOliveyoungService({
92
- zyteApiKey: bindings?.ZYTE_API_KEY,
88
+ relayUrl: bindings?.OY_RELAY_URL,
89
+ relayToken: bindings?.OY_RELAY_TOKEN,
93
90
  }),
94
91
  ]);
95
92
  return registry;
@@ -259,6 +256,8 @@ app.use('*', cors({
259
256
  ],
260
257
  exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'],
261
258
  }));
259
+ // Zyte 연동 공개 GET API의 일일 호출량을 IP별로 제한합니다.
260
+ app.use('/api/*', createDailyRateLimitMiddleware());
262
261
  // 기본 정보 엔드포인트 (GET 요청만)
263
262
  app.get('/', (c) => {
264
263
  return c.body(ROOT_INFO_JSON, 200, ROOT_INFO_HEADERS);
@@ -268,6 +267,8 @@ app.on(['POST', 'DELETE'], '/', handleRootMcpRequest);
268
267
  // 헬스 체크 엔드포인트
269
268
  app.get('/health', (c) => c.json({ status: 'ok', config: buildConfigStatus(c.env) }));
270
269
  registerHealthRoutes(app);
270
+ registerRateLimitStatsRoutes(app);
271
+ registerServiceRoutes(app);
271
272
  // 프롬프트 페이지 (MCP 미지원 에이전트용)
272
273
  app.get('/prompt', (c) => {
273
274
  const baseUrl = new URL(c.req.url).origin;
@@ -296,20 +297,6 @@ app.get('/privacy', (c) => {
296
297
  return createPrivacyResponse(baseUrl);
297
298
  });
298
299
  // GET API 엔드포인트 (MCP 미지원 에이전트용)
299
- registerDaisoRoutes(app);
300
- registerGs25Routes(app);
301
- registerSevenElevenRoutes(app);
302
- registerCompareRoutes(app);
303
- registerFeedbackRoutes(app);
304
- registerPlacesRoutes(app);
305
- registerOpinetRoutes(app);
306
- registerCuRoutes(app);
307
- registerEmart24Routes(app);
308
- registerLotteMartRoutes(app);
309
- registerOliveyoungRoutes(app);
310
- registerMegaboxRoutes(app);
311
- registerLotteCinemaRoutes(app);
312
- registerCgvRoutes(app);
313
300
  app.get('/api/actions/query', async (c) => {
314
301
  try {
315
302
  const targetUrl = buildActionQueryTargetUrl(c.req.url);
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Zyte 연동 공개 GET API의 클라이언트별 일일 호출 제한 미들웨어
3
+ */
4
+ import { errorResponse } from '../api/response.js';
5
+ import { DAILY_RATE_LIMIT, nextKstMidnightEpochSeconds, toKstDay, } from '../durableObjects/dailyRateLimiter.js';
6
+ import { RATE_LIMIT_METRICS_LEDGER_NAME, } from '../durableObjects/rateLimitMetricsStore.js';
7
+ const PROTECTED_SERVICES = [
8
+ { prefix: '/api/oliveyoung/', service: 'oliveyoung' },
9
+ { prefix: '/api/cgv/', service: 'cgv' },
10
+ { prefix: '/api/cu/', service: 'cu' },
11
+ { prefix: '/api/gs25/', service: 'gs25' },
12
+ { prefix: '/api/lottemart/', service: 'lottemart' },
13
+ ];
14
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
15
+ // Cloudflare가 교차 존 Worker subrequest에 사용하는 공통 client IP
16
+ const CROSS_ZONE_WORKER_CLIENT_IP = '2a06:98c0:3600::103';
17
+ function resolveRateLimitService(request) {
18
+ if (request.method !== 'GET') {
19
+ return null;
20
+ }
21
+ const pathname = new URL(request.url).pathname;
22
+ return PROTECTED_SERVICES.find(({ prefix }) => pathname.startsWith(prefix))?.service ?? null;
23
+ }
24
+ export function isDailyRateLimitedRequest(request) {
25
+ return resolveRateLimitService(request) !== null;
26
+ }
27
+ function isHealthCheckBypass(request, env) {
28
+ const secret = env?.HEALTH_CHECK_SECRET?.trim();
29
+ if (!secret) {
30
+ return false;
31
+ }
32
+ return request.headers.get('x-health-check-key')?.trim() === secret;
33
+ }
34
+ export async function hashRateLimitIdentity(ip) {
35
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip));
36
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
37
+ }
38
+ function resolveRateLimitIdentity(request) {
39
+ const ip = request.headers.get('CF-Connecting-IP')?.trim();
40
+ if (!ip || ip !== CROSS_ZONE_WORKER_CLIENT_IP) {
41
+ return ip || null;
42
+ }
43
+ const workerZone = request.headers.get('CF-Worker')?.trim().toLowerCase();
44
+ return workerZone ? `worker-zone:${workerZone}` : ip;
45
+ }
46
+ function isRecord(value) {
47
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
48
+ }
49
+ function isExactDate(value) {
50
+ if (typeof value !== 'string' || !DATE_PATTERN.test(value)) {
51
+ return false;
52
+ }
53
+ const timestamp = Date.parse(`${value}T00:00:00.000Z`);
54
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value;
55
+ }
56
+ function isSafeIntegerInRange(value, minimum, maximum) {
57
+ return (typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum && value <= maximum);
58
+ }
59
+ function isDailyRateLimitResult(value) {
60
+ if (!isRecord(value) || typeof value.allowed !== 'boolean') {
61
+ return false;
62
+ }
63
+ const { allowed, count, remaining, resetAt, day } = value;
64
+ if (!isSafeIntegerInRange(count, 0, DAILY_RATE_LIMIT) ||
65
+ !isSafeIntegerInRange(remaining, 0, DAILY_RATE_LIMIT) ||
66
+ !isSafeIntegerInRange(resetAt, 1, Number.MAX_SAFE_INTEGER) ||
67
+ !isExactDate(day)) {
68
+ return false;
69
+ }
70
+ const countersMatch = count + remaining === DAILY_RATE_LIMIT;
71
+ return allowed ? countersMatch && count > 0 : count === DAILY_RATE_LIMIT && remaining === 0;
72
+ }
73
+ async function consumeDailyRateLimitDecision(request, env) {
74
+ const service = resolveRateLimitService(request);
75
+ if (!service || isHealthCheckBypass(request, env)) {
76
+ return null;
77
+ }
78
+ const identity = resolveRateLimitIdentity(request);
79
+ const namespace = env?.DAILY_RATE_LIMITER;
80
+ if (!identity || !namespace) {
81
+ return null;
82
+ }
83
+ try {
84
+ const identityId = namespace.idFromName(await hashRateLimitIdentity(identity));
85
+ const stub = namespace.get(identityId);
86
+ const response = await stub.fetch('https://daily-rate-limit/consume', { method: 'POST' });
87
+ if (!response.ok) {
88
+ console.error('일일 호출 제한 확인 실패', `Durable Object HTTP ${response.status}`);
89
+ return null;
90
+ }
91
+ const result = await response.json();
92
+ if (!isDailyRateLimitResult(result)) {
93
+ console.error('일일 호출 제한 응답 검증 실패');
94
+ return null;
95
+ }
96
+ const nowMs = Date.now();
97
+ if (result.day !== toKstDay(nowMs)) {
98
+ console.error('일일 호출 제한 날짜 전환 감지');
99
+ return null;
100
+ }
101
+ if (result.resetAt !== nextKstMidnightEpochSeconds(nowMs)) {
102
+ console.error('일일 호출 제한 초기화 시각 검증 실패');
103
+ return null;
104
+ }
105
+ return {
106
+ result,
107
+ service,
108
+ identityId,
109
+ namespace,
110
+ nowMs,
111
+ };
112
+ }
113
+ catch {
114
+ console.error('일일 호출 제한 확인 실패');
115
+ return null;
116
+ }
117
+ }
118
+ export async function consumeDailyRateLimit(request, env) {
119
+ return (await consumeDailyRateLimitDecision(request, env))?.result ?? null;
120
+ }
121
+ async function recordBlockedRateLimitEvent(decision) {
122
+ const event = {
123
+ eventId: crypto.randomUUID(),
124
+ occurredAt: decision.nowMs,
125
+ day: decision.result.day,
126
+ service: decision.service,
127
+ identityId: decision.identityId.toString(),
128
+ };
129
+ try {
130
+ const ledgerId = decision.namespace.idFromName(RATE_LIMIT_METRICS_LEDGER_NAME);
131
+ const ledger = decision.namespace.get(ledgerId);
132
+ const response = await ledger.fetch('https://daily-rate-limit/blocked-events', {
133
+ method: 'POST',
134
+ headers: { 'Content-Type': 'application/json' },
135
+ body: JSON.stringify(event),
136
+ });
137
+ if (!(response instanceof Response)) {
138
+ console.error('일일 호출 제한 원장 기록 실패', '응답 형식 오류');
139
+ return false;
140
+ }
141
+ if (response.status !== 204) {
142
+ console.error('일일 호출 제한 원장 기록 실패', `Durable Object HTTP ${response.status}`);
143
+ return false;
144
+ }
145
+ return true;
146
+ }
147
+ catch {
148
+ console.error('일일 호출 제한 원장 기록 실패');
149
+ return false;
150
+ }
151
+ }
152
+ export function setRateLimitHeaders(headers, result) {
153
+ headers.set('X-RateLimit-Limit', String(DAILY_RATE_LIMIT));
154
+ headers.set('X-RateLimit-Remaining', String(result.remaining));
155
+ headers.set('X-RateLimit-Reset', String(result.resetAt));
156
+ }
157
+ export function createDailyRateLimitMiddleware() {
158
+ return async (c, next) => {
159
+ const decision = await consumeDailyRateLimitDecision(c.req.raw, c.env);
160
+ if (!decision) {
161
+ return next();
162
+ }
163
+ const { result } = decision;
164
+ if (!result.allowed) {
165
+ if (!(await recordBlockedRateLimitEvent(decision))) {
166
+ return next();
167
+ }
168
+ const response = errorResponse(c, 'DAILY_RATE_LIMIT_EXCEEDED', '이 IP의 일일 호출 한도 3,000회를 초과했습니다.', 429);
169
+ setRateLimitHeaders(response.headers, result);
170
+ response.headers.set('Retry-After', String(Math.max(1, result.resetAt - Math.floor(decision.nowMs / 1000))));
171
+ return response;
172
+ }
173
+ await next();
174
+ setRateLimitHeaders(c.res.headers, result);
175
+ };
176
+ }
@@ -14,6 +14,7 @@ import { OPENAPI_PATHS_PLACES } from './openapiSpecPathsPlaces.js';
14
14
  import { OPENAPI_PATHS_OPINET } from './openapiSpecPathsOpinet.js';
15
15
  import { OPENAPI_PATHS_COMPARE } from './openapiSpecPathsCompare.js';
16
16
  import { OPENAPI_PATHS_FEEDBACK } from './openapiSpecPathsFeedback.js';
17
+ import { OPENAPI_PATHS_DTRYX } from './openapiSpecPathsDtryx.js';
17
18
  import { OPENAPI_COMPONENTS } from './openapiSpecComponents.js';
18
19
  export function generateFullOpenApiSpec(baseUrl) {
19
20
  return {
@@ -63,6 +64,7 @@ export function generateFullOpenApiSpec(baseUrl) {
63
64
  ...OPENAPI_PATHS_MEGABOX,
64
65
  ...OPENAPI_PATHS_LOTTECINEMA,
65
66
  ...OPENAPI_PATHS_CGV,
67
+ ...OPENAPI_PATHS_DTRYX,
66
68
  },
67
69
  components: OPENAPI_COMPONENTS,
68
70
  };