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.
Files changed (82) hide show
  1. package/README.md +75 -5
  2. package/dist/api/actionsProxy.js +3 -0
  3. package/dist/api/cgvHandlers.js +15 -3
  4. package/dist/api/configStatus.js +1 -1
  5. package/dist/api/dtryxHandlers.js +113 -0
  6. package/dist/api/emart24Handlers.js +6 -2
  7. package/dist/api/gs25Handlers.js +22 -19
  8. package/dist/api/handlers.js +3 -0
  9. package/dist/api/healthCheckDefinitions.js +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 +17 -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 +16 -30
  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 +12 -0
  38. package/dist/services/cgv/transport.js +19 -2
  39. package/dist/services/cu/client.js +42 -12
  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 +15 -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 +1 -0
  59. package/dist/services/gs25/storeStockTransport.js +55 -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 +29 -160
  65. package/dist/services/lottemart/socketTransport.js +149 -0
  66. package/dist/services/oliveyoung/client.js +1 -0
  67. package/dist/services/seveneleven/client.js +18 -8
  68. package/dist/services/seveneleven/index.js +11 -7
  69. package/dist/services/seveneleven/inventory.js +5 -2
  70. package/dist/services/seveneleven/productKeyword.js +3 -2
  71. package/dist/services/seveneleven/tools/checkInventory.js +4 -3
  72. package/dist/services/seveneleven/tools/getCatalogSnapshot.js +4 -3
  73. package/dist/services/seveneleven/tools/getSearchPopwords.js +4 -3
  74. package/dist/services/seveneleven/tools/searchProducts.js +4 -3
  75. package/dist/services/seveneleven/tools/searchStores.js +4 -3
  76. package/dist/utils/format.js +10 -3
  77. package/dist/utils/http.js +38 -10
  78. package/dist/utils/zyte.js +13 -2
  79. package/dist/utils/zyteJsonFallback.js +67 -0
  80. package/openapi.json +51 -3
  81. package/openapi.yaml +36 -3
  82. package/package.json +28 -27
@@ -3,11 +3,14 @@
3
3
  */
4
4
  import { fetchJson } from '../../utils/http.js';
5
5
  import { decodeBase64, requestByZyte } from '../../utils/zyte.js';
6
+ import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js';
6
7
  import { CU_API } from './api.js';
8
+ import { cuStockUnavailableReason } from './upstreamError.js';
7
9
  const CU_DEFAULT_HEADERS = {
8
10
  'Content-Type': 'application/json',
9
11
  Accept: 'application/json, text/javascript, */*; q=0.01',
10
12
  'X-Requested-With': 'XMLHttpRequest',
13
+ 'User-Agent': 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.0.0 Mobile Safari/537.36;BGFCU',
11
14
  };
12
15
  const CU_WEB_DEFAULT_HEADERS = {
13
16
  'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
@@ -44,22 +47,24 @@ function toStoreAddress(raw) {
44
47
  }
45
48
  return [raw.addrFst || '', raw.addrDetail || ''].join(' ').trim();
46
49
  }
47
- async function requestCuJson(path, body, timeout = 15000) {
48
- return fetchJson(`${CU_API.BASE_URL}${path}`, {
50
+ async function requestCuJson(path, body, options = {}) {
51
+ return fetchJsonWithZyteFallback(`${CU_API.BASE_URL}${path}`, {
49
52
  method: 'POST',
50
- timeout,
53
+ timeout: options.timeout,
51
54
  headers: CU_DEFAULT_HEADERS,
52
55
  body: JSON.stringify(body),
56
+ zyteApiKey: options.apiKey,
57
+ zyteTags: { service: 'cu' },
53
58
  });
54
59
  }
55
60
  async function requestCuWebHtml(path, body, apiKey, timeout = 15000) {
56
61
  const form = new URLSearchParams(body);
57
62
  const formText = form.toString();
58
63
  const targetUrl = `${CU_API.WEB_BASE_URL}${path}`;
59
- const response = await fetch(`${CU_API.WEB_BASE_URL}${path}`, {
60
- method: 'POST',
64
+ // 공식 웹의 GET 조회는 같은 검색 조건을 지원하며 추가 재시도 시간을 쓰지 않습니다.
65
+ const response = await fetch(`${targetUrl}?${formText}`, {
66
+ method: 'GET',
61
67
  headers: CU_WEB_DEFAULT_HEADERS,
62
- body: formText,
63
68
  signal: AbortSignal.timeout(timeout),
64
69
  });
65
70
  if (response.ok) {
@@ -74,6 +79,7 @@ async function requestCuWebHtml(path, body, apiKey, timeout = 15000) {
74
79
  method: 'POST',
75
80
  headers: Object.entries(CU_WEB_DEFAULT_HEADERS).map(([name, value]) => ({ name, value })),
76
81
  bodyText: formText,
82
+ tags: { service: 'cu' },
77
83
  });
78
84
  if (result.statusCode === 200 && result.httpResponseBody) {
79
85
  return decodeBase64(result.httpResponseBody);
@@ -240,7 +246,7 @@ export async function fetchCuStores(params, options = {}) {
240
246
  item_cd: params.itemCd || '',
241
247
  onItemNo: params.onItemNo || '',
242
248
  };
243
- const body = await requestCuJson(CU_API.STORE_PATH, payload, timeout);
249
+ const body = await requestCuJson(CU_API.STORE_PATH, payload, options);
244
250
  const stores = (body.storeList || []).map((store) => normalizeCuStore(store));
245
251
  return {
246
252
  totalCount: toNumber(body.totalCnt) || stores.length,
@@ -252,16 +258,19 @@ export async function fetchCuStores(params, options = {}) {
252
258
  * 정책 변경 시 사전 호출이 필요한 경우를 대비한 워밍업 요청입니다.
253
259
  */
254
260
  export async function primeCuStockDisplay(options = {}) {
255
- const { timeout = 15000 } = options;
256
- await requestCuJson(CU_API.STOCK_DISPLAY_PATH, {}, timeout);
261
+ await fetchJson(`${CU_API.BASE_URL}${CU_API.STOCK_DISPLAY_PATH}`, {
262
+ method: 'POST',
263
+ timeout: options.timeout,
264
+ headers: CU_DEFAULT_HEADERS,
265
+ body: '{}',
266
+ });
257
267
  }
258
268
  /**
259
269
  * CU 상품 재고 검색 결과를 조회합니다.
260
270
  */
261
271
  export async function fetchCuStock(params, options = {}) {
262
- const { timeout = 15000 } = options;
263
272
  try {
264
- await primeCuStockDisplay({ timeout });
273
+ await primeCuStockDisplay(options);
265
274
  }
266
275
  catch {
267
276
  // 사전 워밍업 실패는 본 검색으로 재시도합니다.
@@ -274,7 +283,26 @@ export async function fetchCuStock(params, options = {}) {
274
283
  limit: params.limit,
275
284
  searchSort: params.searchSort,
276
285
  };
277
- const body = await requestCuJson(CU_API.STOCK_MAIN_PATH, payload, timeout);
286
+ let body;
287
+ try {
288
+ body = await requestCuJson(CU_API.STOCK_MAIN_PATH, payload, options);
289
+ }
290
+ catch (error) {
291
+ const unavailableReason = cuStockUnavailableReason(error);
292
+ if (unavailableReason) {
293
+ return {
294
+ available: false,
295
+ unavailableReason,
296
+ totalCount: 0,
297
+ spellModifyYn: 'N',
298
+ items: [],
299
+ };
300
+ }
301
+ throw error;
302
+ }
303
+ if (body.resp_cd === '3000') {
304
+ throw new Error('CU 재고 API가 조회 실패 응답을 반환했습니다 (3000).');
305
+ }
278
306
  const result = body.data?.stockResult?.result;
279
307
  const rows = result?.rows || [];
280
308
  const items = rows
@@ -290,6 +318,8 @@ export async function fetchCuStock(params, options = {}) {
290
318
  reserveYn: toYnBoolean(fields.reserv_yn),
291
319
  }));
292
320
  return {
321
+ available: true,
322
+ unavailableReason: null,
293
323
  totalCount: toNumber(result?.total_count) || items.length,
294
324
  spellModifyYn: body.spellModifyYn || 'N',
295
325
  items,
@@ -10,12 +10,22 @@ const CU_METADATA = {
10
10
  description: 'CU 매장 탐색 및 상품 재고 조회 서비스',
11
11
  };
12
12
  class CuService {
13
+ options;
13
14
  metadata = CU_METADATA;
15
+ constructor(options = {}) {
16
+ this.options = options;
17
+ }
14
18
  getTools() {
15
- return [createFindNearbyStoresTool(), createCheckInventoryTool()];
19
+ return [
20
+ createFindNearbyStoresTool(this.options.zyteApiKey),
21
+ createCheckInventoryTool({
22
+ zyteApiKey: this.options.zyteApiKey,
23
+ googleMapsApiKey: this.options.googleMapsApiKey,
24
+ }),
25
+ ];
16
26
  }
17
27
  }
18
- export function createCuService() {
19
- return new CuService();
28
+ export function createCuService(options = {}) {
29
+ return new CuService(options);
20
30
  }
21
31
  export * from './types.js';
@@ -4,7 +4,7 @@
4
4
  import * as z from 'zod';
5
5
  import { fetchCuStock, fetchCuStores, geocodeCuAddress } from '../client.js';
6
6
  async function checkInventory(args) {
7
- const { keyword, latitude, longitude, storeKeyword = '', size = 20, offset = 0, searchSort = 'recom', storeLimit = 10, timeoutMs = 15000, } = args;
7
+ const { keyword, latitude, longitude, storeKeyword = '', size = 20, offset = 0, searchSort = 'recom', storeLimit = 10, timeoutMs = 15000, zyteApiKey, googleMapsApiKey, } = args;
8
8
  if (!keyword || keyword.trim().length === 0) {
9
9
  throw new Error('상품 검색어(keyword)를 입력해주세요.');
10
10
  }
@@ -15,61 +15,73 @@ async function checkInventory(args) {
15
15
  searchSort,
16
16
  }, {
17
17
  timeout: timeoutMs,
18
+ apiKey: zyteApiKey,
18
19
  });
19
20
  const firstStockItem = stockResult.items.find((item) => item.itemCode.trim().length > 0) || null;
20
21
  const hasInputLocation = typeof latitude === 'number' && typeof longitude === 'number';
21
22
  const resolvedLatitude = hasInputLocation ? latitude : undefined;
22
23
  const resolvedLongitude = hasInputLocation ? longitude : undefined;
23
- let storeResult = null;
24
- // 좌표 미입력 + 매장 키워드 입력 시, 키워드 기반 매장 검색 결과를 우선 사용합니다.
25
- if (!hasInputLocation && storeKeyword.trim().length > 0) {
26
- const keywordStoreResult = await fetchCuStores({
27
- searchWord: storeKeyword,
28
- }, {
29
- timeout: timeoutMs,
30
- });
31
- const firstAddress = keywordStoreResult.stores.find((store) => store.address.trim().length > 0)?.address || '';
32
- if (firstAddress.length > 0) {
33
- const googleMapsApiKey = process.env.GOOGLE_MAPS_API_KEY;
34
- const geocoded = await geocodeCuAddress(firstAddress, {
24
+ let storeResult = storeLimit <= 0 ? { totalCount: 0, stores: [] } : null;
25
+ let storeUnavailableReason = null;
26
+ try {
27
+ // 좌표 미입력 + 매장 키워드 입력 시, 키워드 기반 매장 검색 결과를 우선 사용합니다.
28
+ if (!storeResult && !hasInputLocation && storeKeyword.trim().length > 0) {
29
+ const keywordStoreResult = await fetchCuStores({
30
+ searchWord: storeKeyword,
31
+ }, {
35
32
  timeout: timeoutMs,
36
- googleMapsApiKey,
33
+ apiKey: zyteApiKey,
37
34
  });
38
- if (geocoded) {
39
- const hasStockSeed = !!firstStockItem?.itemCode;
40
- storeResult = await fetchCuStores({
41
- latitude: geocoded.latitude,
42
- longitude: geocoded.longitude,
43
- searchWord: storeKeyword,
44
- itemCd: firstStockItem?.itemCode || '',
45
- onItemNo: firstStockItem?.onItemNo || '',
46
- jipCd: firstStockItem?.itemCode || '',
47
- isRecommend: hasStockSeed ? 'Y' : '',
48
- recommendId: hasStockSeed ? 'stock' : '',
49
- pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve',
50
- }, {
35
+ const firstAddress = keywordStoreResult.stores.find((store) => store.address.trim().length > 0)?.address || '';
36
+ if (firstAddress.length > 0) {
37
+ const geocoded = await geocodeCuAddress(firstAddress, {
51
38
  timeout: timeoutMs,
39
+ googleMapsApiKey: googleMapsApiKey || process.env.GOOGLE_MAPS_API_KEY,
52
40
  });
41
+ if (geocoded) {
42
+ const hasStockSeed = !!firstStockItem?.itemCode;
43
+ storeResult = await fetchCuStores({
44
+ latitude: geocoded.latitude,
45
+ longitude: geocoded.longitude,
46
+ searchWord: storeKeyword,
47
+ itemCd: firstStockItem?.itemCode || '',
48
+ onItemNo: firstStockItem?.onItemNo || '',
49
+ jipCd: firstStockItem?.itemCode || '',
50
+ isRecommend: hasStockSeed ? 'Y' : '',
51
+ recommendId: hasStockSeed ? 'stock' : '',
52
+ pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve',
53
+ }, {
54
+ timeout: timeoutMs,
55
+ apiKey: zyteApiKey,
56
+ });
57
+ }
53
58
  }
59
+ storeResult ||= keywordStoreResult;
60
+ }
61
+ if (!storeResult) {
62
+ const hasStockSeed = !!firstStockItem?.itemCode;
63
+ storeResult = await fetchCuStores({
64
+ latitude: resolvedLatitude,
65
+ longitude: resolvedLongitude,
66
+ searchWord: storeKeyword,
67
+ itemCd: firstStockItem?.itemCode || '',
68
+ onItemNo: firstStockItem?.onItemNo || '',
69
+ jipCd: firstStockItem?.itemCode || '',
70
+ isRecommend: hasStockSeed ? 'Y' : '',
71
+ recommendId: hasStockSeed ? 'stock' : '',
72
+ pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve',
73
+ }, {
74
+ timeout: timeoutMs,
75
+ apiKey: zyteApiKey,
76
+ });
54
77
  }
55
- storeResult ||= keywordStoreResult;
56
78
  }
57
- if (!storeResult) {
58
- const hasStockSeed = !!firstStockItem?.itemCode;
59
- storeResult = await fetchCuStores({
60
- latitude: resolvedLatitude,
61
- longitude: resolvedLongitude,
62
- searchWord: storeKeyword,
63
- itemCd: firstStockItem?.itemCode || '',
64
- onItemNo: firstStockItem?.onItemNo || '',
65
- jipCd: firstStockItem?.itemCode || '',
66
- isRecommend: hasStockSeed ? 'Y' : '',
67
- recommendId: hasStockSeed ? 'stock' : '',
68
- pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve',
69
- }, {
70
- timeout: timeoutMs,
71
- });
79
+ catch (error) {
80
+ storeUnavailableReason =
81
+ error instanceof Error ? error.message : 'CU 매장 조회 중 알 수 없는 오류가 발생했습니다.';
82
+ storeResult = { totalCount: 0, stores: [] };
72
83
  }
84
+ storeResult ||= { totalCount: 0, stores: [] };
73
85
  const limitedStores = storeResult.stores.slice(0, storeLimit);
74
86
  const result = {
75
87
  keyword,
@@ -83,6 +95,8 @@ async function checkInventory(args) {
83
95
  ? { latitude: resolvedLatitude, longitude: resolvedLongitude }
84
96
  : null,
85
97
  nearbyStores: {
98
+ available: storeUnavailableReason === null,
99
+ unavailableReason: storeUnavailableReason,
86
100
  totalCount: storeResult.totalCount,
87
101
  count: limitedStores.length,
88
102
  stockItemCode: firstStockItem?.itemCode || null,
@@ -90,6 +104,8 @@ async function checkInventory(args) {
90
104
  stores: limitedStores,
91
105
  },
92
106
  inventory: {
107
+ available: stockResult.available,
108
+ unavailableReason: stockResult.unavailableReason,
93
109
  totalCount: stockResult.totalCount,
94
110
  count: stockResult.items.length,
95
111
  spellModifyYn: stockResult.spellModifyYn,
@@ -100,7 +116,7 @@ async function checkInventory(args) {
100
116
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
101
117
  };
102
118
  }
103
- export function createCheckInventoryTool() {
119
+ export function createCheckInventoryTool(options = {}) {
104
120
  return {
105
121
  name: 'cu_check_inventory',
106
122
  metadata: {
@@ -118,6 +134,10 @@ export function createCheckInventoryTool() {
118
134
  timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
119
135
  },
120
136
  },
121
- handler: checkInventory,
137
+ handler: ((args) => checkInventory({
138
+ ...args,
139
+ zyteApiKey: options.zyteApiKey ?? args.zyteApiKey,
140
+ googleMapsApiKey: options.googleMapsApiKey ?? args.googleMapsApiKey,
141
+ })),
122
142
  };
123
143
  }
@@ -4,13 +4,14 @@
4
4
  import * as z from 'zod';
5
5
  import { fetchCuStores } from '../client.js';
6
6
  async function findNearbyStores(args) {
7
- const { latitude, longitude, keyword = '', limit = 20, timeoutMs = 15000, } = args;
7
+ const { latitude, longitude, keyword = '', limit = 20, timeoutMs = 15000, zyteApiKey, } = args;
8
8
  const { totalCount, stores } = await fetchCuStores({
9
9
  latitude,
10
10
  longitude,
11
11
  searchWord: keyword,
12
12
  }, {
13
13
  timeout: timeoutMs,
14
+ apiKey: zyteApiKey,
14
15
  });
15
16
  const limitedStores = stores.slice(0, limit);
16
17
  return {
@@ -30,7 +31,7 @@ async function findNearbyStores(args) {
30
31
  ],
31
32
  };
32
33
  }
33
- export function createFindNearbyStoresTool() {
34
+ export function createFindNearbyStoresTool(zyteApiKey) {
34
35
  return {
35
36
  name: 'cu_find_nearby_stores',
36
37
  metadata: {
@@ -44,6 +45,6 @@ export function createFindNearbyStoresTool() {
44
45
  timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
45
46
  },
46
47
  },
47
- handler: findNearbyStores,
48
+ handler: ((args) => findNearbyStores({ ...args, zyteApiKey: zyteApiKey ?? args.zyteApiKey })),
48
49
  };
49
50
  }
@@ -0,0 +1,15 @@
1
+ /** CU 재고 조회 불가 사유를 응답 형식과 전송 실패에 따라 구분합니다. */
2
+ import { HttpError, UnexpectedHtmlResponseError } from '../../utils/http.js';
3
+ export function cuStockUnavailableReason(error) {
4
+ if (error instanceof UnexpectedHtmlResponseError) {
5
+ return 'CU 재고 API가 JSON 대신 HTML을 반환하여 재고를 확인할 수 없습니다.';
6
+ }
7
+ if (error instanceof HttpError && [400, 403, 429].includes(error.status)) {
8
+ return `CU 재고 API가 차단되었습니다 (${error.status} Request Blocked).`;
9
+ }
10
+ const message = error instanceof Error ? error.message : '';
11
+ if (message.includes('Zyte API 호출 실패: 520') || message.includes('Zyte 대상 응답 실패: 520')) {
12
+ return 'CU 재고 API가 차단되었습니다 (Zyte Website Ban 520).';
13
+ }
14
+ return null;
15
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 디트릭스(Dtryx) API 엔드포인트 및 상수
3
+ *
4
+ * 디트릭스는 국내 독립·예술영화관 다수가 공용으로 사용하는 예매 플랫폼입니다.
5
+ * 공개 조회 엔드포인트이며 별도 인증 키가 필요하지 않습니다.
6
+ */
7
+ export const DTRYX_API = {
8
+ BASE_URL: 'https://api.dtryx.com:30443',
9
+ THIRDPARTY_PATH: '/dtryx/cms/thirdparty/movie',
10
+ CHANNEL_CODE: 'homepage',
11
+ ENDPOINTS: {
12
+ TIMETABLE_LIST: 'third-party-type2-timetable-list',
13
+ PLAY_DATE_LIST: 'third-party-type2-timetable-play-date-list',
14
+ MOVIE_NOW: 'third-party-type2-movie-now',
15
+ },
16
+ /**
17
+ * 엔드포인트별 고정 작업 식별자입니다.
18
+ * 각 극장 홈페이지에 공개된 값과 동일합니다.
19
+ */
20
+ WORK_GUIDS: {
21
+ TIMETABLE_LIST: '37E0BA0F-DA5F-4376-9BA4-B5D27286AB87',
22
+ PLAY_DATE_LIST: '324A2914-AB19-42A3-BDFE-58A08B2DC35D',
23
+ MOVIE_NOW: '6D741A36-A973-4BC4-87B4-2475E6654114',
24
+ },
25
+ };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * 디트릭스 API 클라이언트
3
+ */
4
+ import { formatTime, toNumber, toYyyymmdd } from '../../utils/format.js';
5
+ import { createTimeoutController } from '../../utils/http.js';
6
+ import { DTRYX_API } from './api.js';
7
+ const DEFAULT_TIMEOUT_MS = 15000;
8
+ /**
9
+ * 디트릭스는 `PlaySDT` 를 `YYYY-MM-DD` 로만 받습니다.
10
+ * `YYYYMMDD` 로 보내면 오류 없이 `RecordCount: 0` 이 반환되므로 항상 변환합니다.
11
+ */
12
+ export function toDashedDate(playDate) {
13
+ if (/^\d{8}$/.test(playDate)) {
14
+ return `${playDate.slice(0, 4)}-${playDate.slice(4, 6)}-${playDate.slice(6, 8)}`;
15
+ }
16
+ return playDate;
17
+ }
18
+ /** 출력용으로 `YYYYMMDD` 형태로 되돌립니다. */
19
+ export function toCompactDate(playDate) {
20
+ return playDate.replace(/-/g, '');
21
+ }
22
+ async function requestDtryx(endpoint, workGuid, params, timeout = DEFAULT_TIMEOUT_MS) {
23
+ const { controller, timeoutId } = createTimeoutController(timeout);
24
+ const query = new URLSearchParams({
25
+ ChannelCd: DTRYX_API.CHANNEL_CODE,
26
+ EngVerYn: 'N',
27
+ WorkGuID: workGuid,
28
+ ...params,
29
+ });
30
+ const url = `${DTRYX_API.BASE_URL}${DTRYX_API.THIRDPARTY_PATH}/${endpoint}?${query.toString()}`;
31
+ try {
32
+ const response = await fetch(url, {
33
+ method: 'GET',
34
+ headers: {
35
+ Accept: 'application/json, text/javascript, */*; q=0.01',
36
+ },
37
+ signal: controller.signal,
38
+ });
39
+ if (!response.ok) {
40
+ throw new Error(`디트릭스 API 호출 실패: ${response.status}`);
41
+ }
42
+ const body = await response.json();
43
+ if (typeof body !== 'object' ||
44
+ body === null ||
45
+ !('RetCode' in body) ||
46
+ body.RetCode !== 'success' ||
47
+ !('Recordset' in body) ||
48
+ !Array.isArray(body.Recordset) ||
49
+ !body.Recordset.every((item) => typeof item === 'object' && item !== null && !Array.isArray(item))) {
50
+ throw new Error('디트릭스 API 응답 오류: RetCode 또는 Recordset이 유효하지 않습니다.');
51
+ }
52
+ return body;
53
+ }
54
+ catch (error) {
55
+ if (error instanceof Error && error.name === 'AbortError') {
56
+ throw new Error('디트릭스 API 호출 시간 초과');
57
+ }
58
+ throw error;
59
+ }
60
+ finally {
61
+ clearTimeout(timeoutId);
62
+ }
63
+ }
64
+ function buildScheduleId(cinemaCode, playDate, screenCode, showSeq) {
65
+ return `${playDate}-${cinemaCode}-${screenCode}-${showSeq}`;
66
+ }
67
+ /** 상영 회차와 좌석 현황을 조회합니다. */
68
+ export async function fetchDtryxTimetable(params) {
69
+ const playDate = params.playDate || toYyyymmdd();
70
+ const response = await requestDtryx(DTRYX_API.ENDPOINTS.TIMETABLE_LIST, DTRYX_API.WORK_GUIDS.TIMETABLE_LIST, {
71
+ BrandCd: params.brandCode,
72
+ CinemaCd: params.cinemaCode,
73
+ PlaySDT: toDashedDate(playDate),
74
+ ImgSize: 'small',
75
+ }, params.timeout);
76
+ return response.Recordset.filter((item) => item.CinemaCd && item.MovieCd && item.ScreenCd).map((item) => {
77
+ const totalSeats = toNumber(item.TotalSeatCnt);
78
+ const remainingSeats = toNumber(item.RemainSeatCnt);
79
+ const compactDate = toCompactDate(item.PlaySDT || playDate);
80
+ const cinemaCode = String(item.CinemaCd);
81
+ const screenCode = String(item.ScreenCd);
82
+ const showSeq = String(item.ShowSeq ?? '');
83
+ return {
84
+ scheduleId: buildScheduleId(cinemaCode, compactDate, screenCode, showSeq),
85
+ brandCode: params.brandCode,
86
+ cinemaCode,
87
+ cinemaName: item.CinemaNm || '',
88
+ screenCode,
89
+ screenName: item.ScreenNm || '',
90
+ movieCode: String(item.MovieCd),
91
+ movieName: item.MovieNm || '',
92
+ playDate: compactDate,
93
+ startTime: formatTime(item.StartTime),
94
+ endTime: formatTime(item.EndTime),
95
+ runningMinutes: toNumber(item.RunningTime),
96
+ rating: item.RatingNm || '',
97
+ totalSeats,
98
+ remainingSeats,
99
+ bookedSeats: Math.max(totalSeats - remainingSeats, 0),
100
+ planStatus: item.PlanStatus || '',
101
+ };
102
+ });
103
+ }
104
+ /** 예매 가능한 상영 날짜를 조회합니다. */
105
+ export async function fetchDtryxPlayDates(params) {
106
+ const response = await requestDtryx(DTRYX_API.ENDPOINTS.PLAY_DATE_LIST, DTRYX_API.WORK_GUIDS.PLAY_DATE_LIST, {
107
+ BrandCd: params.brandCode,
108
+ CinemaCd: params.cinemaCode,
109
+ MovieCd: '',
110
+ }, params.timeout);
111
+ return response.Recordset.filter((item) => item.PlaySDT).map((item) => ({
112
+ playDate: toCompactDate(item.PlaySDT),
113
+ hidden: item.HiddenYn === 'Y',
114
+ rest: item.RestYn === 'Y',
115
+ }));
116
+ }
117
+ /** 현재 상영작 목록을 조회합니다. */
118
+ export async function fetchDtryxNowShowing(params) {
119
+ const response = await requestDtryx(DTRYX_API.ENDPOINTS.MOVIE_NOW, DTRYX_API.WORK_GUIDS.MOVIE_NOW, {
120
+ BrandCd: params.brandCode,
121
+ CinemaCd: params.cinemaCode,
122
+ ImgSize: 'small',
123
+ }, params.timeout);
124
+ return response.Recordset.filter((item) => item.MovieCd && item.MovieNm).map((item) => ({
125
+ movieCode: String(item.MovieCd),
126
+ movieName: item.MovieNm,
127
+ rating: item.RatingNm || undefined,
128
+ runningMinutes: item.RunningTime === null || item.RunningTime === undefined
129
+ ? undefined
130
+ : toNumber(item.RunningTime),
131
+ }));
132
+ }
133
+ export { toYyyymmdd };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 디트릭스(Dtryx) 서비스 프로바이더
3
+ *
4
+ * 국내 독립·예술영화관 다수가 공용으로 사용하는 예매 플랫폼을 조회합니다.
5
+ */
6
+ import { createGetRemainingSeatsTool } from './tools/getRemainingSeats.js';
7
+ import { createListCinemasTool } from './tools/listCinemas.js';
8
+ import { createListNowShowingTool } from './tools/listNowShowing.js';
9
+ const DTRYX_METADATA = {
10
+ id: 'dtryx',
11
+ name: '디트릭스',
12
+ version: '1.0.0',
13
+ description: '디트릭스 예매 플랫폼 기반 독립·예술영화관 목록, 상영작, 상영시간표·잔여 좌석 조회 서비스',
14
+ };
15
+ class DtryxService {
16
+ metadata = DTRYX_METADATA;
17
+ getTools() {
18
+ return [createListCinemasTool(), createListNowShowingTool(), createGetRemainingSeatsTool()];
19
+ }
20
+ }
21
+ export function createDtryxService() {
22
+ return new DtryxService();
23
+ }
24
+ export * from './types.js';