daiso 1.2.7 → 1.2.9
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 +17 -17
- package/dist/api/actionsProxy.js +0 -10
- package/dist/api/cgvHandlers.js +4 -3
- package/dist/api/configStatus.js +7 -2
- package/dist/api/gs25Handlers.js +4 -3
- package/dist/api/handlers.js +11 -7
- package/dist/api/healthCheckDefinitions.js +0 -9
- package/dist/api/healthChecks.js +0 -1
- package/dist/api/lottecinemaHandlers.js +4 -3
- package/dist/api/lottemartHandlers.js +5 -142
- package/dist/api/megaboxHandlers.js +4 -3
- package/dist/api/opinetHandlers.js +2 -1
- package/dist/api/routes/cgvRoutes.js +3 -3
- package/dist/api/routes/cuRoutes.js +2 -2
- package/dist/api/routes/gs25Routes.js +3 -3
- package/dist/api/routes/lottecinemaRoutes.js +3 -3
- package/dist/api/routes/lottemartRoutes.js +4 -16
- package/dist/api/routes/megaboxRoutes.js +3 -3
- package/dist/api/routes/opinetRoutes.js +4 -4
- package/dist/cli.js +5 -5
- package/dist/cliHelp.js +0 -3
- package/dist/cliHelpDefinitions.js +2 -19
- package/dist/index.js +10 -11
- package/dist/middleware/dailyRateLimit.js +0 -1
- package/dist/pages/openapiFullSpec.js +0 -2
- package/dist/pages/openapiSpecActionParameters.js +4 -4
- package/dist/pages/openapiSpecComponents.js +0 -2
- package/dist/pages/openapiSpecPathsMegabox.js +1 -1
- package/dist/pages/promptReferenceText.js +0 -5
- package/dist/pages/promptRetailCinemaText.js +0 -26
- package/dist/services/cgv/index.js +3 -6
- package/dist/services/cgv/location.js +38 -125
- package/dist/services/cgv/tools/findTheaters.js +5 -4
- package/dist/services/cgv/tools/getTimetable.js +5 -4
- package/dist/services/cgv/tools/searchMovies.js +5 -4
- package/dist/services/cu/client.js +3 -35
- package/dist/services/cu/index.js +1 -0
- package/dist/services/cu/tools/checkInventory.js +3 -1
- package/dist/services/gs25/client.js +3 -33
- package/dist/services/gs25/index.js +2 -2
- package/dist/services/gs25/tools/checkInventory.js +3 -1
- package/dist/services/gs25/tools/findNearbyStores.js +3 -1
- package/dist/services/lottecinema/index.js +3 -6
- package/dist/services/lottecinema/location.js +13 -100
- package/dist/services/lottecinema/tools/findNearbyTheaters.js +7 -6
- package/dist/services/lottecinema/tools/getRemainingSeats.js +5 -4
- package/dist/services/lottecinema/tools/listNowShowing.js +5 -4
- package/dist/services/lottemart/client.js +5 -32
- package/dist/services/lottemart/geocode.js +3 -46
- package/dist/services/megabox/index.js +9 -8
- package/dist/services/megabox/location.js +19 -84
- package/dist/services/megabox/tools/findNearbyTheaters.js +7 -7
- package/dist/services/megabox/tools/getRemainingSeats.js +5 -5
- package/dist/services/megabox/tools/listNowShowing.js +5 -5
- package/dist/services/oliveyoung/tools/checkInventory.js +5 -5
- package/dist/services/oliveyoung/tools/findNearbyStores.js +3 -3
- package/dist/services/oliveyoung/tools/searchProducts.js +3 -3
- package/dist/services/oliveyoung/transport.js +49 -33
- package/dist/services/opinet/client.js +1 -1
- package/dist/services/opinet/index.js +1 -1
- package/dist/services/opinet/location.js +5 -56
- package/dist/services/opinet/tools.js +2 -2
- package/dist/utils/geocode.js +155 -0
- package/dist/utils/http.js +2 -2
- package/dist/utils/relayQuota.js +4 -2
- package/openapi.json +4 -183
- package/openapi.yaml +4 -131
- package/package.json +1 -2
- package/dist/pages/openapiSpecComponentsLotteMart.js +0 -85
- package/dist/pages/openapiSpecPathsLotteMart.js +0 -157
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 메가박스 위치 해석 및 근처 지점 조회 보조 모듈
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
4
|
+
import { geocodeLocation, validKoreanCoordinates } from '../../utils/geocode.js';
|
|
5
5
|
import { fetchMegaboxBookingList, fetchMegaboxTheaterInfo } from './client.js';
|
|
6
6
|
const DEFAULT_LATITUDE = 37.5665;
|
|
7
7
|
const DEFAULT_LONGITUDE = 126.978;
|
|
8
8
|
const DEFAULT_AREA_CODE = '11';
|
|
9
9
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
10
|
-
const GEOCODE_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
11
|
-
const megaboxGeocodeCache = new Map();
|
|
12
10
|
const MEGABOX_AREA_CODE_BY_REGION = [
|
|
13
11
|
['서울특별시', '11'],
|
|
14
12
|
['서울', '11'],
|
|
@@ -69,94 +67,31 @@ function resolveAreaCodeFromText(text) {
|
|
|
69
67
|
}
|
|
70
68
|
return null;
|
|
71
69
|
}
|
|
72
|
-
function resolveAreaCodeFromGeocodeResult(result) {
|
|
73
|
-
for (const component of result.address_components || []) {
|
|
74
|
-
const candidates = [component.long_name || '', component.short_name || ''];
|
|
75
|
-
for (const candidate of candidates) {
|
|
76
|
-
const areaCode = resolveAreaCodeFromText(candidate);
|
|
77
|
-
if (areaCode) {
|
|
78
|
-
return areaCode;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return resolveAreaCodeFromText(result.formatted_address || '');
|
|
83
|
-
}
|
|
84
|
-
async function geocodeMegaboxLocation(params, options = {}) {
|
|
85
|
-
const apiKey = (options.googleMapsApiKey || '').trim();
|
|
86
|
-
if (apiKey.length === 0) {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
const hasCoordinates = isValidCoordinate(params.latitude) && isValidCoordinate(params.longitude);
|
|
90
|
-
const keyword = (params.keyword || '').trim();
|
|
91
|
-
if (!hasCoordinates && keyword.length === 0) {
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
const cacheKey = hasCoordinates ? `coords:${params.latitude},${params.longitude}` : `keyword:${keyword}`;
|
|
95
|
-
const cached = megaboxGeocodeCache.get(cacheKey);
|
|
96
|
-
if (cached && cached.expiresAt > Date.now()) {
|
|
97
|
-
return cached.value;
|
|
98
|
-
}
|
|
99
|
-
const endpoint = new URL('https://maps.googleapis.com/maps/api/geocode/json');
|
|
100
|
-
if (hasCoordinates) {
|
|
101
|
-
endpoint.searchParams.set('latlng', `${params.latitude},${params.longitude}`);
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
endpoint.searchParams.set('address', keyword);
|
|
105
|
-
}
|
|
106
|
-
endpoint.searchParams.set('key', apiKey);
|
|
107
|
-
const body = await fetchJson(endpoint.toString(), {
|
|
108
|
-
method: 'GET',
|
|
109
|
-
timeout: options.timeout || DEFAULT_TIMEOUT_MS,
|
|
110
|
-
headers: {
|
|
111
|
-
Accept: 'application/json',
|
|
112
|
-
},
|
|
113
|
-
});
|
|
114
|
-
if (body.status !== 'OK') {
|
|
115
|
-
megaboxGeocodeCache.set(cacheKey, {
|
|
116
|
-
expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS,
|
|
117
|
-
value: null,
|
|
118
|
-
});
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
const firstResult = body.results?.[0];
|
|
122
|
-
const latitude = firstResult?.geometry?.location?.lat;
|
|
123
|
-
const longitude = firstResult?.geometry?.location?.lng;
|
|
124
|
-
const areaCode = firstResult ? resolveAreaCodeFromGeocodeResult(firstResult) : null;
|
|
125
|
-
const value = typeof latitude === 'number' && typeof longitude === 'number'
|
|
126
|
-
? { latitude, longitude, areaCode }
|
|
127
|
-
: null;
|
|
128
|
-
megaboxGeocodeCache.set(cacheKey, {
|
|
129
|
-
expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS,
|
|
130
|
-
value,
|
|
131
|
-
});
|
|
132
|
-
return value;
|
|
133
|
-
}
|
|
134
70
|
export async function resolveMegaboxLocation(params, options = {}) {
|
|
135
71
|
const explicitAreaCode = (params.areaCode || '').trim();
|
|
136
72
|
const keyword = (params.keyword || '').trim();
|
|
137
73
|
const hasCoordinates = isValidCoordinate(params.latitude) && isValidCoordinate(params.longitude);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (!
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
74
|
+
if (hasCoordinates &&
|
|
75
|
+
!validKoreanCoordinates(params.latitude, params.longitude))
|
|
76
|
+
throw new Error('유효한 위도/경도를 입력해주세요.');
|
|
77
|
+
const geocoded = !hasCoordinates && keyword ? await geocodeLocation(keyword, options) : null;
|
|
78
|
+
if (keyword && !hasCoordinates && !geocoded)
|
|
79
|
+
throw new Error(`위치를 좌표로 변환하지 못했습니다: ${keyword}`);
|
|
80
|
+
const latitude = hasCoordinates
|
|
81
|
+
? params.latitude
|
|
82
|
+
: (geocoded?.latitude ?? DEFAULT_LATITUDE);
|
|
83
|
+
const longitude = hasCoordinates
|
|
84
|
+
? params.longitude
|
|
85
|
+
: (geocoded?.longitude ?? DEFAULT_LONGITUDE);
|
|
86
|
+
const areaCode = explicitAreaCode ||
|
|
87
|
+
resolveAreaCodeFromText(geocoded?.formattedAddress || keyword) ||
|
|
88
|
+
(hasCoordinates ? '' : DEFAULT_AREA_CODE);
|
|
89
|
+
const geocodeUsed = Boolean(geocoded);
|
|
155
90
|
return {
|
|
156
91
|
keyword: keyword.length > 0 ? keyword : null,
|
|
157
92
|
latitude,
|
|
158
93
|
longitude,
|
|
159
|
-
areaCode
|
|
94
|
+
areaCode,
|
|
160
95
|
geocodeUsed,
|
|
161
96
|
};
|
|
162
97
|
}
|
|
@@ -215,5 +150,5 @@ export async function resolveMegaboxNearestTheater(params, options = {}) {
|
|
|
215
150
|
};
|
|
216
151
|
}
|
|
217
152
|
export function __testOnlyClearMegaboxLocationCaches() {
|
|
218
|
-
|
|
153
|
+
// 캐시를 유지하지 않으므로 초기화할 상태가 없습니다.
|
|
219
154
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { toYyyymmdd } from '../client.js';
|
|
6
6
|
import { fetchMegaboxNearbyTheaters } from '../location.js';
|
|
7
|
-
async function findNearbyTheaters(args) {
|
|
7
|
+
async function findNearbyTheaters(args, geocodeOptions = {}) {
|
|
8
8
|
const { keyword, latitude, longitude, playDate = toYyyymmdd(), areaCode, limit = 10, timeoutMs = 15000, } = args;
|
|
9
9
|
const result = await fetchMegaboxNearbyTheaters({
|
|
10
10
|
keyword,
|
|
@@ -15,14 +15,14 @@ async function findNearbyTheaters(args) {
|
|
|
15
15
|
limit,
|
|
16
16
|
timeout: timeoutMs,
|
|
17
17
|
}, {
|
|
18
|
-
|
|
18
|
+
...geocodeOptions,
|
|
19
19
|
timeout: timeoutMs,
|
|
20
20
|
});
|
|
21
21
|
return {
|
|
22
22
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
-
export function createFindNearbyTheatersTool() {
|
|
25
|
+
export function createFindNearbyTheatersTool(geocodeOptions = {}) {
|
|
26
26
|
return {
|
|
27
27
|
name: 'megabox_find_nearby_theaters',
|
|
28
28
|
metadata: {
|
|
@@ -30,14 +30,14 @@ export function createFindNearbyTheatersTool() {
|
|
|
30
30
|
description: '사용자 좌표 또는 위치 키워드 기준으로 메가박스 지점을 거리순으로 조회합니다.',
|
|
31
31
|
inputSchema: {
|
|
32
32
|
keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 강남역)'),
|
|
33
|
-
latitude: z.number().optional().
|
|
34
|
-
longitude: z.number().optional().
|
|
33
|
+
latitude: z.number().optional().describe('위도 (미입력 시 keyword 해석)'),
|
|
34
|
+
longitude: z.number().optional().describe('경도 (미입력 시 keyword 해석)'),
|
|
35
35
|
playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'),
|
|
36
|
-
areaCode: z.string().optional().
|
|
36
|
+
areaCode: z.string().optional().describe('지역 코드 (미입력 시 위치로 결정)'),
|
|
37
37
|
limit: z.number().optional().default(10).describe('반환할 최대 지점 수 (기본값: 10)'),
|
|
38
38
|
timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
|
|
39
39
|
},
|
|
40
40
|
},
|
|
41
|
-
handler: findNearbyTheaters,
|
|
41
|
+
handler: ((args) => findNearbyTheaters(args, geocodeOptions)),
|
|
42
42
|
};
|
|
43
43
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { fetchMegaboxBookingList, toYyyymmdd } from '../client.js';
|
|
6
6
|
import { resolveMegaboxNearestTheater } from '../location.js';
|
|
7
|
-
async function getRemainingSeats(args) {
|
|
7
|
+
async function getRemainingSeats(args, geocodeOptions = {}) {
|
|
8
8
|
const { playDate = toYyyymmdd(), theaterId: inputTheaterId, movieId, keyword, latitude, longitude, areaCode, limit = 50, timeoutMs = 15000, } = args;
|
|
9
9
|
let theaterId = inputTheaterId;
|
|
10
10
|
let resolvedTheater = null;
|
|
@@ -18,7 +18,7 @@ async function getRemainingSeats(args) {
|
|
|
18
18
|
playDate,
|
|
19
19
|
timeout: timeoutMs,
|
|
20
20
|
}, {
|
|
21
|
-
|
|
21
|
+
...geocodeOptions,
|
|
22
22
|
timeout: timeoutMs,
|
|
23
23
|
});
|
|
24
24
|
theaterId = resolved.theater?.theaterId;
|
|
@@ -62,7 +62,7 @@ async function getRemainingSeats(args) {
|
|
|
62
62
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
|
-
export function createGetRemainingSeatsTool() {
|
|
65
|
+
export function createGetRemainingSeatsTool(geocodeOptions = {}) {
|
|
66
66
|
return {
|
|
67
67
|
name: 'megabox_get_remaining_seats',
|
|
68
68
|
metadata: {
|
|
@@ -75,11 +75,11 @@ export function createGetRemainingSeatsTool() {
|
|
|
75
75
|
keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 강남역)'),
|
|
76
76
|
latitude: z.number().optional().describe('위도'),
|
|
77
77
|
longitude: z.number().optional().describe('경도'),
|
|
78
|
-
areaCode: z.string().optional().
|
|
78
|
+
areaCode: z.string().optional().describe('지역 코드 (미입력 시 위치로 결정)'),
|
|
79
79
|
limit: z.number().optional().default(50).describe('반환할 최대 회차 수 (기본값: 50)'),
|
|
80
80
|
timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
|
|
81
81
|
},
|
|
82
82
|
},
|
|
83
|
-
handler: getRemainingSeats,
|
|
83
|
+
handler: ((args) => getRemainingSeats(args, geocodeOptions)),
|
|
84
84
|
};
|
|
85
85
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { fetchMegaboxBookingList, toYyyymmdd } from '../client.js';
|
|
6
6
|
import { resolveMegaboxNearestTheater } from '../location.js';
|
|
7
|
-
async function listNowShowing(args) {
|
|
7
|
+
async function listNowShowing(args, geocodeOptions = {}) {
|
|
8
8
|
const { playDate = toYyyymmdd(), theaterId: inputTheaterId, movieId, keyword, latitude, longitude, areaCode, timeoutMs = 15000, } = args;
|
|
9
9
|
let theaterId = inputTheaterId;
|
|
10
10
|
let resolvedTheater = null;
|
|
@@ -18,7 +18,7 @@ async function listNowShowing(args) {
|
|
|
18
18
|
playDate,
|
|
19
19
|
timeout: timeoutMs,
|
|
20
20
|
}, {
|
|
21
|
-
|
|
21
|
+
...geocodeOptions,
|
|
22
22
|
timeout: timeoutMs,
|
|
23
23
|
});
|
|
24
24
|
theaterId = resolved.theater?.theaterId;
|
|
@@ -57,7 +57,7 @@ async function listNowShowing(args) {
|
|
|
57
57
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
-
export function createListNowShowingTool() {
|
|
60
|
+
export function createListNowShowingTool(geocodeOptions = {}) {
|
|
61
61
|
return {
|
|
62
62
|
name: 'megabox_list_now_showing',
|
|
63
63
|
metadata: {
|
|
@@ -70,10 +70,10 @@ export function createListNowShowingTool() {
|
|
|
70
70
|
keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 강남역)'),
|
|
71
71
|
latitude: z.number().optional().describe('위도'),
|
|
72
72
|
longitude: z.number().optional().describe('경도'),
|
|
73
|
-
areaCode: z.string().optional().
|
|
73
|
+
areaCode: z.string().optional().describe('지역 코드 (미입력 시 위치로 결정)'),
|
|
74
74
|
timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
|
|
75
75
|
},
|
|
76
76
|
},
|
|
77
|
-
handler: listNowShowing,
|
|
77
|
+
handler: ((args) => listNowShowing(args, geocodeOptions)),
|
|
78
78
|
};
|
|
79
79
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { enrichOliveyoungProductsWithNearbyStoreInventory, fetchOliveyoungProducts, fetchOliveyoungStores, } from '../client.js';
|
|
6
6
|
async function checkInventory(args, transport) {
|
|
7
|
-
const { keyword, latitude = 37.5665, longitude = 126.978, storeKeyword = '', page = 1, size = 20, sort = '01', includeSoldOut = false, storeLimit = 10, stockCheckLimit = 5, timeoutMs = 15000, zyteApiKey, } = args;
|
|
7
|
+
const { keyword, latitude = 37.5665, longitude = 126.978, storeKeyword = '', page = 1, size = 20, sort = '01', includeSoldOut = false, storeLimit = 10, stockCheckLimit = 5, timeoutMs = transport.timeout ?? (transport.relayUrl ? 60000 : 15000), zyteApiKey, } = args;
|
|
8
8
|
if (!keyword || keyword.trim().length === 0) {
|
|
9
9
|
throw new Error('상품 검색어(keyword)를 입력해주세요.');
|
|
10
10
|
}
|
|
@@ -15,9 +15,9 @@ async function checkInventory(args, transport) {
|
|
|
15
15
|
pageIdx: 1,
|
|
16
16
|
searchWords: storeKeyword,
|
|
17
17
|
}, {
|
|
18
|
-
timeout: timeoutMs,
|
|
19
18
|
apiKey: zyteApiKey,
|
|
20
19
|
...transport,
|
|
20
|
+
timeout: timeoutMs,
|
|
21
21
|
}),
|
|
22
22
|
fetchOliveyoungProducts({
|
|
23
23
|
keyword,
|
|
@@ -26,9 +26,9 @@ async function checkInventory(args, transport) {
|
|
|
26
26
|
sort,
|
|
27
27
|
includeSoldOut,
|
|
28
28
|
}, {
|
|
29
|
-
timeout: timeoutMs,
|
|
30
29
|
apiKey: zyteApiKey,
|
|
31
30
|
...transport,
|
|
31
|
+
timeout: timeoutMs,
|
|
32
32
|
}),
|
|
33
33
|
]);
|
|
34
34
|
const enrichedInventory = await enrichOliveyoungProductsWithNearbyStoreInventory(productResult.products, {
|
|
@@ -37,9 +37,9 @@ async function checkInventory(args, transport) {
|
|
|
37
37
|
storeKeyword,
|
|
38
38
|
maxProducts: Math.min(productResult.products.length, Math.max(0, stockCheckLimit)),
|
|
39
39
|
}, {
|
|
40
|
-
timeout: timeoutMs,
|
|
41
40
|
apiKey: zyteApiKey,
|
|
42
41
|
...transport,
|
|
42
|
+
timeout: timeoutMs,
|
|
43
43
|
});
|
|
44
44
|
const inStockProducts = enrichedInventory.products.filter((product) => product.inStock);
|
|
45
45
|
const result = {
|
|
@@ -98,7 +98,7 @@ export function createCheckInventoryTool(apiKey, transport = {}) {
|
|
|
98
98
|
.optional()
|
|
99
99
|
.default(5)
|
|
100
100
|
.describe('주변 매장별 재고를 보강할 상품 수 (기본값: 5, 최대: 5, 낮출수록 빠름)'),
|
|
101
|
-
timeoutMs: z.number().optional().default(15000).describe('
|
|
101
|
+
timeoutMs: z.number().optional().default(transport.timeout ?? (transport.relayUrl ? 60000 : 15000)).describe('개별 조회 제한 시간(ms, 기본값: 무료 릴레이 60000 / 직접 15000)'),
|
|
102
102
|
},
|
|
103
103
|
},
|
|
104
104
|
handler: ((args) => checkInventory({ ...args, zyteApiKey: apiKey }, transport)),
|
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { fetchOliveyoungStores } from '../client.js';
|
|
6
6
|
async function findNearbyStores(args, transport) {
|
|
7
|
-
const { latitude = 37.5665, longitude = 126.978, keyword = '', pageIdx = 1, limit = 20, timeoutMs = 15000, zyteApiKey, } = args;
|
|
7
|
+
const { latitude = 37.5665, longitude = 126.978, keyword = '', pageIdx = 1, limit = 20, timeoutMs = transport.timeout ?? (transport.relayUrl ? 60000 : 15000), zyteApiKey, } = args;
|
|
8
8
|
const { totalCount, stores } = await fetchOliveyoungStores({
|
|
9
9
|
latitude,
|
|
10
10
|
longitude,
|
|
11
11
|
pageIdx,
|
|
12
12
|
searchWords: keyword,
|
|
13
13
|
}, {
|
|
14
|
-
timeout: timeoutMs,
|
|
15
14
|
apiKey: zyteApiKey,
|
|
16
15
|
...transport,
|
|
16
|
+
timeout: timeoutMs,
|
|
17
17
|
});
|
|
18
18
|
const limitedStores = stores.slice(0, limit);
|
|
19
19
|
const result = {
|
|
@@ -46,7 +46,7 @@ export function createFindNearbyStoresTool(apiKey, transport = {}) {
|
|
|
46
46
|
.describe('매장 검색어 (예: 강남, 명동, 신촌). 비우면 주변 매장 전체 조회'),
|
|
47
47
|
pageIdx: z.number().optional().default(1).describe('매장 결과 페이지 번호 (기본값: 1)'),
|
|
48
48
|
limit: z.number().optional().default(20).describe('반환할 최대 매장 수 (기본값: 20)'),
|
|
49
|
-
timeoutMs: z.number().optional().default(15000).describe('
|
|
49
|
+
timeoutMs: z.number().optional().default(transport.timeout ?? (transport.relayUrl ? 60000 : 15000)).describe('개별 조회 제한 시간(ms, 기본값: 무료 릴레이 60000 / 직접 15000)'),
|
|
50
50
|
},
|
|
51
51
|
},
|
|
52
52
|
handler: ((args) => findNearbyStores({ ...args, zyteApiKey: apiKey }, transport)),
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import * as z from 'zod';
|
|
5
5
|
import { fetchOliveyoungProducts } from '../client.js';
|
|
6
6
|
async function searchProducts(args, transport) {
|
|
7
|
-
const { keyword, page = 1, size = 20, sort = '01', includeSoldOut = false, timeoutMs = 15000, zyteApiKey, } = args;
|
|
7
|
+
const { keyword, page = 1, size = 20, sort = '01', includeSoldOut = false, timeoutMs = transport.timeout ?? (transport.relayUrl ? 60000 : 15000), zyteApiKey, } = args;
|
|
8
8
|
if (!keyword || keyword.trim().length === 0) {
|
|
9
9
|
throw new Error('상품 검색어(keyword)를 입력해주세요.');
|
|
10
10
|
}
|
|
@@ -15,9 +15,9 @@ async function searchProducts(args, transport) {
|
|
|
15
15
|
sort,
|
|
16
16
|
includeSoldOut,
|
|
17
17
|
}, {
|
|
18
|
-
timeout: timeoutMs,
|
|
19
18
|
apiKey: zyteApiKey,
|
|
20
19
|
...transport,
|
|
20
|
+
timeout: timeoutMs,
|
|
21
21
|
});
|
|
22
22
|
return {
|
|
23
23
|
content: [
|
|
@@ -50,7 +50,7 @@ export function createSearchProductsTool(apiKey, transport = {}) {
|
|
|
50
50
|
size: z.number().optional().default(20).describe('페이지당 상품 수 (기본값: 20)'),
|
|
51
51
|
sort: z.string().optional().default('01').describe('정렬 코드 (기본값: 01)'),
|
|
52
52
|
includeSoldOut: z.boolean().optional().default(false).describe('품절 상품 포함 여부 (기본값: false)'),
|
|
53
|
-
timeoutMs: z.number().optional().default(15000).describe('
|
|
53
|
+
timeoutMs: z.number().optional().default(transport.timeout ?? (transport.relayUrl ? 60000 : 15000)).describe('개별 조회 제한 시간(ms, 기본값: 무료 릴레이 60000 / 직접 15000)'),
|
|
54
54
|
},
|
|
55
55
|
},
|
|
56
56
|
handler: ((args) => searchProducts({ ...args, zyteApiKey: apiKey }, transport)),
|
|
@@ -24,7 +24,8 @@ export function isValidOliveyoungRelayUrl(value) {
|
|
|
24
24
|
!(relay.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(relay.hostname))));
|
|
25
25
|
}
|
|
26
26
|
export async function requestOliveyoung(path, body, options = {}) {
|
|
27
|
-
const { timeout =
|
|
27
|
+
const { relayUrl, relayToken, timeout = relayUrl ? 60000 : 15000 } = options;
|
|
28
|
+
const deadline = Date.now() + timeout;
|
|
28
29
|
const headers = {
|
|
29
30
|
'Content-Type': 'application/json',
|
|
30
31
|
Accept: 'application/json',
|
|
@@ -64,40 +65,55 @@ export async function requestOliveyoung(path, body, options = {}) {
|
|
|
64
65
|
}
|
|
65
66
|
if (relayUrl) {
|
|
66
67
|
scope = await relayCredentialScope([relayUrl.replace(/\/$/, ''), relayToken, options.accessClientId, options.accessClientSecret]);
|
|
67
|
-
const quota = cooldown.get(scope, consumer);
|
|
68
|
-
if (quota)
|
|
69
|
-
throw toOliveyoungRelayError(new HttpError(429, '', '', new Headers({
|
|
70
|
-
'x-relay-quota-reason': quota.quotaReason, 'retry-after': String(quota.retryAfter),
|
|
71
|
-
})));
|
|
72
68
|
}
|
|
73
|
-
let
|
|
74
|
-
|
|
75
|
-
result
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
69
|
+
let retried = false;
|
|
70
|
+
while (true) {
|
|
71
|
+
let result;
|
|
72
|
+
const cachedQuota = relayUrl ? cooldown.get(scope, consumer) : undefined;
|
|
73
|
+
try {
|
|
74
|
+
const remaining = deadline - Date.now();
|
|
75
|
+
if (remaining <= 0)
|
|
76
|
+
throw new DOMException('Request deadline exceeded', 'AbortError');
|
|
77
|
+
if (cachedQuota)
|
|
78
|
+
throw new HttpError(429, '', '', new Headers({
|
|
79
|
+
'x-relay-quota-reason': cachedQuota.quotaReason,
|
|
80
|
+
'retry-after': String(cachedQuota.retryAfter),
|
|
81
|
+
}));
|
|
82
|
+
result = await fetchJson(url, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
// Workers는 error 모드를 지원하지 않으므로 따라가지 않고 아래 200 검사로 거절합니다.
|
|
85
|
+
redirect: 'manual',
|
|
86
|
+
headers,
|
|
87
|
+
body: JSON.stringify(body),
|
|
88
|
+
timeout: remaining,
|
|
89
|
+
retries: 0,
|
|
90
|
+
expectedStatus: 200,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (relayUrl) {
|
|
95
|
+
if (error instanceof HttpError && error.quota) {
|
|
96
|
+
if (!cachedQuota)
|
|
97
|
+
cooldown.set(scope, consumer, error.quota);
|
|
98
|
+
if (!retried && error.quota.quotaReason === 'consumer-busy' &&
|
|
99
|
+
deadline - Date.now() > 1000) {
|
|
100
|
+
retried = true;
|
|
101
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
throw toOliveyoungRelayError(error);
|
|
106
|
+
}
|
|
107
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
108
|
+
throw new Error('올리브영 API 요청 시간 초과');
|
|
109
|
+
}
|
|
110
|
+
throw new Error('올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', { cause: error });
|
|
91
111
|
}
|
|
92
|
-
if (
|
|
93
|
-
|
|
112
|
+
if (result?.status !== 'SUCCESS') {
|
|
113
|
+
if (relayUrl)
|
|
114
|
+
throw toOliveyoungRelayError(new SyntaxError());
|
|
115
|
+
throw new Error(`올리브영 API 상태 오류: ${result?.status || 'UNKNOWN'}`);
|
|
94
116
|
}
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
if (result?.status !== 'SUCCESS') {
|
|
98
|
-
if (relayUrl)
|
|
99
|
-
throw toOliveyoungRelayError(new SyntaxError());
|
|
100
|
-
throw new Error(`올리브영 API 상태 오류: ${result?.status || 'UNKNOWN'}`);
|
|
117
|
+
return result;
|
|
101
118
|
}
|
|
102
|
-
return result;
|
|
103
119
|
}
|
|
@@ -180,7 +180,7 @@ export async function fetchOpinetLowestStations(params, options = {}) {
|
|
|
180
180
|
}
|
|
181
181
|
export async function fetchOpinetStationsAround(params, options = {}) {
|
|
182
182
|
const resolvedLocation = await resolveOpinetLocation(params, {
|
|
183
|
-
|
|
183
|
+
...options,
|
|
184
184
|
timeoutMs: options.timeoutMs,
|
|
185
185
|
fetchImpl: options.fetchImpl,
|
|
186
186
|
});
|
|
@@ -15,7 +15,7 @@ class OpinetService {
|
|
|
15
15
|
return [
|
|
16
16
|
createOpinetAveragePricesTool(this.options.apiKey),
|
|
17
17
|
createOpinetLowestStationsTool(this.options.apiKey),
|
|
18
|
-
createOpinetStationsAroundTool(this.options.apiKey, this.options.googleMapsApiKey),
|
|
18
|
+
createOpinetStationsAroundTool(this.options.apiKey, this.options.googleMapsApiKey, this.options),
|
|
19
19
|
createOpinetStationDetailTool(this.options.apiKey),
|
|
20
20
|
];
|
|
21
21
|
}
|
|
@@ -1,14 +1,9 @@
|
|
|
1
|
+
import { geocodeLocation as resolveFreeLocation, validKoreanCoordinates, } from '../../utils/geocode.js';
|
|
1
2
|
import proj4 from 'proj4';
|
|
2
3
|
const WGS84 = 'EPSG:4326';
|
|
3
4
|
const KATEC = 'KATEC';
|
|
4
5
|
const KATEC_PROJ4 = '+proj=tmerc +lat_0=38 +lon_0=128 +k=0.9999 +x_0=400000 +y_0=600000 +ellps=bessel +units=m +no_defs +towgs84=-115.80,474.99,674.11,1.16,-2.31,-1.63,6.43';
|
|
5
6
|
proj4.defs(KATEC, KATEC_PROJ4);
|
|
6
|
-
const GEOCODE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
7
|
-
const geocodeCache = new Map();
|
|
8
|
-
function getProcessEnvValue(name) {
|
|
9
|
-
/* c8 ignore next -- Worker 런타임은 bindings로 키를 주입합니다. */
|
|
10
|
-
return typeof process !== 'undefined' ? process.env[name] : undefined;
|
|
11
|
-
}
|
|
12
7
|
function isFiniteNumber(value) {
|
|
13
8
|
return typeof value === 'number' && Number.isFinite(value);
|
|
14
9
|
}
|
|
@@ -23,55 +18,7 @@ export function wgs84ToKatec(latitude, longitude) {
|
|
|
23
18
|
};
|
|
24
19
|
}
|
|
25
20
|
async function geocodeLocation(location, options) {
|
|
26
|
-
|
|
27
|
-
const cached = geocodeCache.get(keyword);
|
|
28
|
-
if (cached && cached.expiresAt > Date.now()) {
|
|
29
|
-
return cached.value;
|
|
30
|
-
}
|
|
31
|
-
const apiKey = options.googleMapsApiKey?.trim() || getProcessEnvValue('GOOGLE_MAPS_API_KEY')?.trim();
|
|
32
|
-
if (!apiKey) {
|
|
33
|
-
throw new Error('location 검색에는 GOOGLE_MAPS_API_KEY가 필요합니다.');
|
|
34
|
-
}
|
|
35
|
-
const endpoint = new URL('https://maps.googleapis.com/maps/api/geocode/json');
|
|
36
|
-
endpoint.searchParams.set('address', keyword);
|
|
37
|
-
endpoint.searchParams.set('key', apiKey);
|
|
38
|
-
const response = await (options.fetchImpl ?? fetch)(endpoint.toString(), {
|
|
39
|
-
headers: { Accept: 'application/json' },
|
|
40
|
-
signal: AbortSignal.timeout(options.timeoutMs ?? 10000),
|
|
41
|
-
});
|
|
42
|
-
const bodyText = await response.text();
|
|
43
|
-
let parsed;
|
|
44
|
-
try {
|
|
45
|
-
parsed = JSON.parse(bodyText);
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
parsed = { status: 'INVALID_JSON' };
|
|
49
|
-
}
|
|
50
|
-
if (!response.ok) {
|
|
51
|
-
throw new Error(`Google Geocoding 호출 실패: HTTP ${response.status} ${bodyText}`);
|
|
52
|
-
}
|
|
53
|
-
if (parsed.status !== 'OK') {
|
|
54
|
-
geocodeCache.set(keyword, {
|
|
55
|
-
expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS,
|
|
56
|
-
value: null,
|
|
57
|
-
});
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
const first = parsed.results?.[0];
|
|
61
|
-
const latitude = first?.geometry?.location?.lat;
|
|
62
|
-
const longitude = first?.geometry?.location?.lng;
|
|
63
|
-
const value = isFiniteNumber(latitude) && isFiniteNumber(longitude)
|
|
64
|
-
? {
|
|
65
|
-
latitude,
|
|
66
|
-
longitude,
|
|
67
|
-
formattedAddress: first?.formatted_address ?? null,
|
|
68
|
-
}
|
|
69
|
-
: null;
|
|
70
|
-
geocodeCache.set(keyword, {
|
|
71
|
-
expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS,
|
|
72
|
-
value,
|
|
73
|
-
});
|
|
74
|
-
return value;
|
|
21
|
+
return resolveFreeLocation(location, options);
|
|
75
22
|
}
|
|
76
23
|
export async function resolveOpinetLocation(input, options = {}) {
|
|
77
24
|
if (isFiniteNumber(input.x) && isFiniteNumber(input.y)) {
|
|
@@ -86,6 +33,8 @@ export async function resolveOpinetLocation(input, options = {}) {
|
|
|
86
33
|
};
|
|
87
34
|
}
|
|
88
35
|
if (isFiniteNumber(input.latitude) && isFiniteNumber(input.longitude)) {
|
|
36
|
+
if (!validKoreanCoordinates(input.latitude, input.longitude))
|
|
37
|
+
throw new Error('유효한 위도/경도를 입력해주세요.');
|
|
89
38
|
return {
|
|
90
39
|
katec: wgs84ToKatec(input.latitude, input.longitude),
|
|
91
40
|
latitude: input.latitude,
|
|
@@ -115,5 +64,5 @@ export async function resolveOpinetLocation(input, options = {}) {
|
|
|
115
64
|
throw new Error('KATEC x/y, 위도/경도(latitude/longitude), 또는 location 중 하나를 입력해주세요.');
|
|
116
65
|
}
|
|
117
66
|
export function __testOnlyClearOpinetGeocodeCache() {
|
|
118
|
-
|
|
67
|
+
// 캐시를 유지하지 않으므로 초기화할 상태가 없습니다.
|
|
119
68
|
}
|
|
@@ -39,7 +39,7 @@ export function createOpinetLowestStationsTool(apiKey) {
|
|
|
39
39
|
handler: (async (args) => buildTextResponse(await fetchOpinetLowestStations(args, { apiKey, timeoutMs: args.timeoutMs }))),
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
-
export function createOpinetStationsAroundTool(apiKey, googleMapsApiKey) {
|
|
42
|
+
export function createOpinetStationsAroundTool(apiKey, googleMapsApiKey, geocodeOptions = {}) {
|
|
43
43
|
return {
|
|
44
44
|
name: 'opinet_search_stations_around',
|
|
45
45
|
metadata: {
|
|
@@ -61,7 +61,7 @@ export function createOpinetStationsAroundTool(apiKey, googleMapsApiKey) {
|
|
|
61
61
|
timeoutMs: z.number().optional().default(10000).describe('요청 제한 시간(ms)'),
|
|
62
62
|
},
|
|
63
63
|
},
|
|
64
|
-
handler: (async (args) => buildTextResponse(await fetchOpinetStationsAround(args, { apiKey, googleMapsApiKey, timeoutMs: args.timeoutMs }))),
|
|
64
|
+
handler: (async (args) => buildTextResponse(await fetchOpinetStationsAround(args, { ...geocodeOptions, apiKey, googleMapsApiKey, timeoutMs: args.timeoutMs }))),
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
export function createOpinetStationDetailTool(apiKey) {
|