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.
- package/README.md +77 -5
- package/dist/api/actionsProxy.js +3 -0
- package/dist/api/cgvHandlers.js +15 -3
- package/dist/api/configStatus.js +2 -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 +13 -5
- 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 +29 -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 +18 -31
- 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 +13 -0
- package/dist/services/cgv/transport.js +5 -22
- package/dist/services/cu/client.js +44 -30
- 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 +11 -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 +2 -14
- package/dist/services/gs25/storeStockTransport.js +30 -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 +33 -201
- package/dist/services/lottemart/socketTransport.js +149 -0
- package/dist/services/oliveyoung/client.js +7 -39
- package/dist/services/oliveyoung/index.js +4 -4
- package/dist/services/oliveyoung/tools/checkInventory.js +6 -3
- package/dist/services/oliveyoung/tools/findNearbyStores.js +4 -3
- package/dist/services/oliveyoung/tools/searchProducts.js +4 -3
- package/dist/services/oliveyoung/transport.js +46 -0
- package/dist/services/seveneleven/client.js +20 -9
- 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/readCache.js +49 -0
- 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 +40 -12
- package/dist/utils/zyte.js +4 -101
- package/dist/utils/zyteJsonFallback.js +28 -0
- package/openapi.json +51 -3
- package/openapi.yaml +36 -3
- package/package.json +28 -27
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
* CU API 클라이언트
|
|
3
3
|
*/
|
|
4
4
|
import { fetchJson } from '../../utils/http.js';
|
|
5
|
-
import {
|
|
5
|
+
import { ZYTE_COST_POLICY_MESSAGE } from '../../core/errors.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,44 +47,31 @@ function toStoreAddress(raw) {
|
|
|
44
47
|
}
|
|
45
48
|
return [raw.addrFst || '', raw.addrDetail || ''].join(' ').trim();
|
|
46
49
|
}
|
|
47
|
-
async function requestCuJson(path, body,
|
|
48
|
-
return
|
|
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
|
-
async function requestCuWebHtml(path, body,
|
|
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
|
-
|
|
60
|
-
|
|
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) {
|
|
66
71
|
return response.text();
|
|
67
72
|
}
|
|
68
73
|
if (response.status === 400 || response.status === 403 || response.status === 429) {
|
|
69
|
-
|
|
70
|
-
const result = await requestByZyte({
|
|
71
|
-
apiKey,
|
|
72
|
-
url: targetUrl,
|
|
73
|
-
timeout,
|
|
74
|
-
method: 'POST',
|
|
75
|
-
headers: Object.entries(CU_WEB_DEFAULT_HEADERS).map(([name, value]) => ({ name, value })),
|
|
76
|
-
bodyText: formText,
|
|
77
|
-
});
|
|
78
|
-
if (result.statusCode === 200 && result.httpResponseBody) {
|
|
79
|
-
return decodeBase64(result.httpResponseBody);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
// 직접 호출 실패 시 Zyte 재시도도 실패하면 원본 에러를 반환합니다.
|
|
84
|
-
}
|
|
74
|
+
throw new Error(ZYTE_COST_POLICY_MESSAGE);
|
|
85
75
|
}
|
|
86
76
|
throw new Error(`API 요청 실패: ${response.status} ${response.statusText}`);
|
|
87
77
|
}
|
|
@@ -240,7 +230,7 @@ export async function fetchCuStores(params, options = {}) {
|
|
|
240
230
|
item_cd: params.itemCd || '',
|
|
241
231
|
onItemNo: params.onItemNo || '',
|
|
242
232
|
};
|
|
243
|
-
const body = await requestCuJson(CU_API.STORE_PATH, payload,
|
|
233
|
+
const body = await requestCuJson(CU_API.STORE_PATH, payload, options);
|
|
244
234
|
const stores = (body.storeList || []).map((store) => normalizeCuStore(store));
|
|
245
235
|
return {
|
|
246
236
|
totalCount: toNumber(body.totalCnt) || stores.length,
|
|
@@ -252,16 +242,19 @@ export async function fetchCuStores(params, options = {}) {
|
|
|
252
242
|
* 정책 변경 시 사전 호출이 필요한 경우를 대비한 워밍업 요청입니다.
|
|
253
243
|
*/
|
|
254
244
|
export async function primeCuStockDisplay(options = {}) {
|
|
255
|
-
|
|
256
|
-
|
|
245
|
+
await fetchJson(`${CU_API.BASE_URL}${CU_API.STOCK_DISPLAY_PATH}`, {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
timeout: options.timeout,
|
|
248
|
+
headers: CU_DEFAULT_HEADERS,
|
|
249
|
+
body: '{}',
|
|
250
|
+
});
|
|
257
251
|
}
|
|
258
252
|
/**
|
|
259
253
|
* CU 상품 재고 검색 결과를 조회합니다.
|
|
260
254
|
*/
|
|
261
255
|
export async function fetchCuStock(params, options = {}) {
|
|
262
|
-
const { timeout = 15000 } = options;
|
|
263
256
|
try {
|
|
264
|
-
await primeCuStockDisplay(
|
|
257
|
+
await primeCuStockDisplay(options);
|
|
265
258
|
}
|
|
266
259
|
catch {
|
|
267
260
|
// 사전 워밍업 실패는 본 검색으로 재시도합니다.
|
|
@@ -274,7 +267,26 @@ export async function fetchCuStock(params, options = {}) {
|
|
|
274
267
|
limit: params.limit,
|
|
275
268
|
searchSort: params.searchSort,
|
|
276
269
|
};
|
|
277
|
-
|
|
270
|
+
let body;
|
|
271
|
+
try {
|
|
272
|
+
body = await requestCuJson(CU_API.STOCK_MAIN_PATH, payload, options);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
const unavailableReason = cuStockUnavailableReason(error);
|
|
276
|
+
if (unavailableReason) {
|
|
277
|
+
return {
|
|
278
|
+
available: false,
|
|
279
|
+
unavailableReason,
|
|
280
|
+
totalCount: 0,
|
|
281
|
+
spellModifyYn: 'N',
|
|
282
|
+
items: [],
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
if (body.resp_cd === '3000') {
|
|
288
|
+
throw new Error('CU 재고 API가 조회 실패 응답을 반환했습니다 (3000).');
|
|
289
|
+
}
|
|
278
290
|
const result = body.data?.stockResult?.result;
|
|
279
291
|
const rows = result?.rows || [];
|
|
280
292
|
const items = rows
|
|
@@ -290,6 +302,8 @@ export async function fetchCuStock(params, options = {}) {
|
|
|
290
302
|
reserveYn: toYnBoolean(fields.reserv_yn),
|
|
291
303
|
}));
|
|
292
304
|
return {
|
|
305
|
+
available: true,
|
|
306
|
+
unavailableReason: null,
|
|
293
307
|
totalCount: toNumber(result?.total_count) || items.length,
|
|
294
308
|
spellModifyYn: body.spellModifyYn || 'N',
|
|
295
309
|
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 [
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
+
apiKey: zyteApiKey,
|
|
37
34
|
});
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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,11 @@
|
|
|
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
|
+
return null;
|
|
11
|
+
}
|
|
@@ -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';
|