daiso 1.2.8 → 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 +2 -1
- 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/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/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
|
}
|
|
@@ -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) {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { fetchJson } from './http.js';
|
|
2
|
+
import { searchNaverLocalPlaces } from '../services/places/client.js';
|
|
3
|
+
function validKakaoDocument(value) {
|
|
4
|
+
if (!value || typeof value !== 'object')
|
|
5
|
+
return false;
|
|
6
|
+
const document = value;
|
|
7
|
+
return ['place_name', 'address_name', 'road_address_name'].every((field) => document[field] === undefined || typeof document[field] === 'string');
|
|
8
|
+
}
|
|
9
|
+
export function readGeocodeEnvironment() {
|
|
10
|
+
const env = typeof process === 'undefined' ? {} : process.env;
|
|
11
|
+
return {
|
|
12
|
+
kakaoRestApiKey: env.KAKAO_REST_API_KEY,
|
|
13
|
+
naverClientId: env.NAVER_CLIENT_ID,
|
|
14
|
+
naverClientSecret: env.NAVER_CLIENT_SECRET,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function geocodeBindings(bindings) {
|
|
18
|
+
return {
|
|
19
|
+
kakaoRestApiKey: bindings?.KAKAO_REST_API_KEY,
|
|
20
|
+
naverClientId: bindings?.NAVER_CLIENT_ID,
|
|
21
|
+
naverClientSecret: bindings?.NAVER_CLIENT_SECRET,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function validKoreanCoordinates(latitude, longitude) {
|
|
25
|
+
return (Number.isFinite(latitude) &&
|
|
26
|
+
Number.isFinite(longitude) &&
|
|
27
|
+
latitude >= 33 &&
|
|
28
|
+
latitude <= 39 &&
|
|
29
|
+
longitude >= 124 &&
|
|
30
|
+
longitude <= 132);
|
|
31
|
+
}
|
|
32
|
+
const normalize = (value) => value
|
|
33
|
+
.replace(/특별자치시|특별자치도|특별시|광역시/gu, '')
|
|
34
|
+
.replace(/경기도/gu, '경기')
|
|
35
|
+
.replace(/충청북도/gu, '충북')
|
|
36
|
+
.replace(/충청남도/gu, '충남')
|
|
37
|
+
.replace(/전라북도|전북특별자치도/gu, '전북')
|
|
38
|
+
.replace(/전라남도/gu, '전남')
|
|
39
|
+
.replace(/경상북도/gu, '경북')
|
|
40
|
+
.replace(/경상남도/gu, '경남')
|
|
41
|
+
.replace(/강원도/gu, '강원')
|
|
42
|
+
.replace(/제주도/gu, '제주')
|
|
43
|
+
.replace(/[^0-9a-z가-힣]/giu, '')
|
|
44
|
+
.toLowerCase();
|
|
45
|
+
function matches(query, value) {
|
|
46
|
+
const tokens = query
|
|
47
|
+
.replace(/^대한민국\s*/u, '')
|
|
48
|
+
.match(/[0-9]+(?:-[0-9]+)?|[a-z가-힣]+/giu)
|
|
49
|
+
?.map((token) => (/^\d/u.test(token) ? token : normalize(token))) || [];
|
|
50
|
+
const normalized = normalize(value);
|
|
51
|
+
return (tokens.length > 0 &&
|
|
52
|
+
tokens.every((token) => /^\d+(?:-\d+)?$/u.test(token)
|
|
53
|
+
? value.match(/\d+(?:-\d+)?/gu)?.includes(token)
|
|
54
|
+
: token.endsWith('역')
|
|
55
|
+
? new RegExp(`${token}(?:[0-9]+호선|$|[^가-힣])`, 'u').test(value)
|
|
56
|
+
: normalized.includes(token)));
|
|
57
|
+
}
|
|
58
|
+
function matchesAddress(query, address) {
|
|
59
|
+
// 주소 검색은 숫자 존재뿐 아니라 도로 번호와 건물 번호의 순서도 검증합니다.
|
|
60
|
+
return (matches(query, address) &&
|
|
61
|
+
query.match(/\d+(?:-\d+)?/gu).join(',') === address.match(/\d+(?:-\d+)?/gu).join(','));
|
|
62
|
+
}
|
|
63
|
+
export async function searchKakaoPlaces(query, options, coordinates) {
|
|
64
|
+
const key = (options.kakaoRestApiKey ?? readGeocodeEnvironment().kakaoRestApiKey)?.trim();
|
|
65
|
+
if (!key)
|
|
66
|
+
return [];
|
|
67
|
+
const endpoint = new URL('https://dapi.kakao.com/v2/local/search/keyword.json');
|
|
68
|
+
endpoint.searchParams.set('query', query);
|
|
69
|
+
endpoint.searchParams.set('size', '15');
|
|
70
|
+
if (coordinates) {
|
|
71
|
+
endpoint.searchParams.set('x', String(coordinates.longitude));
|
|
72
|
+
endpoint.searchParams.set('y', String(coordinates.latitude));
|
|
73
|
+
endpoint.searchParams.set('radius', '20000');
|
|
74
|
+
endpoint.searchParams.set('sort', 'distance');
|
|
75
|
+
}
|
|
76
|
+
const body = await fetchJson(endpoint.toString(), {
|
|
77
|
+
fetchImpl: options.fetchImpl,
|
|
78
|
+
headers: { Authorization: `KakaoAK ${key}` },
|
|
79
|
+
timeout: options.timeout ?? options.timeoutMs ?? 10000,
|
|
80
|
+
});
|
|
81
|
+
return Array.isArray(body.documents) ? body.documents.filter(validKakaoDocument) : [];
|
|
82
|
+
}
|
|
83
|
+
export async function geocodeLocation(query, input = {}) {
|
|
84
|
+
const keyword = query.trim();
|
|
85
|
+
if (!keyword)
|
|
86
|
+
return null;
|
|
87
|
+
const env = readGeocodeEnvironment();
|
|
88
|
+
const options = {
|
|
89
|
+
...input,
|
|
90
|
+
kakaoRestApiKey: input.kakaoRestApiKey ?? env.kakaoRestApiKey,
|
|
91
|
+
naverClientId: input.naverClientId ?? env.naverClientId,
|
|
92
|
+
naverClientSecret: input.naverClientSecret ?? env.naverClientSecret,
|
|
93
|
+
};
|
|
94
|
+
const isAddress = /(?:로|길|동|읍|면|리)\s*\d/u.test(keyword);
|
|
95
|
+
let documents = [];
|
|
96
|
+
if (options.kakaoRestApiKey?.trim()) {
|
|
97
|
+
try {
|
|
98
|
+
if (isAddress) {
|
|
99
|
+
const endpoint = new URL('https://dapi.kakao.com/v2/local/search/address.json');
|
|
100
|
+
endpoint.searchParams.set('query', keyword);
|
|
101
|
+
const body = await fetchJson(endpoint.toString(), {
|
|
102
|
+
fetchImpl: options.fetchImpl,
|
|
103
|
+
headers: { Authorization: `KakaoAK ${options.kakaoRestApiKey.trim()}` },
|
|
104
|
+
timeout: options.timeout ?? options.timeoutMs ?? 10000,
|
|
105
|
+
});
|
|
106
|
+
documents = Array.isArray(body.documents) ? body.documents.filter(validKakaoDocument) : [];
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
documents = await searchKakaoPlaces(keyword, options);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// 장소명은 네이버 지역 검색으로 보완하고 주소는 잘못된 장소로 대체하지 않습니다.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const document of documents) {
|
|
116
|
+
if (!isAddress && !document.place_name?.trim())
|
|
117
|
+
continue;
|
|
118
|
+
const latitude = Number(document.y);
|
|
119
|
+
const longitude = Number(document.x);
|
|
120
|
+
if (!validKoreanCoordinates(latitude, longitude) ||
|
|
121
|
+
!(isAddress
|
|
122
|
+
? matchesAddress(keyword, document.address_name || '') ||
|
|
123
|
+
matchesAddress(keyword, document.road_address_name || '')
|
|
124
|
+
: matches(keyword, `${document.place_name} ${document.address_name || ''} ${document.road_address_name || ''}`)))
|
|
125
|
+
continue;
|
|
126
|
+
return {
|
|
127
|
+
latitude,
|
|
128
|
+
longitude,
|
|
129
|
+
formattedAddress: document.road_address_name || document.address_name || null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (!isAddress && options.naverClientId && options.naverClientSecret) {
|
|
133
|
+
try {
|
|
134
|
+
const result = await searchNaverLocalPlaces({
|
|
135
|
+
...options,
|
|
136
|
+
keyword,
|
|
137
|
+
timeoutMs: options.timeout ?? options.timeoutMs,
|
|
138
|
+
});
|
|
139
|
+
const place = result.places.find((item) => item.latitude !== null &&
|
|
140
|
+
item.longitude !== null &&
|
|
141
|
+
validKoreanCoordinates(item.latitude, item.longitude) &&
|
|
142
|
+
matches(keyword, `${item.name} ${item.address} ${item.roadAddress}`));
|
|
143
|
+
if (place)
|
|
144
|
+
return {
|
|
145
|
+
latitude: place.latitude,
|
|
146
|
+
longitude: place.longitude,
|
|
147
|
+
formattedAddress: place.roadAddress || place.address || null,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
package/dist/utils/http.js
CHANGED
|
@@ -67,7 +67,7 @@ function parseRetryAfterDelayMs(response) {
|
|
|
67
67
|
return null;
|
|
68
68
|
}
|
|
69
69
|
async function requestWithTimeout(url, options, readResponse) {
|
|
70
|
-
const { timeout = 10000, retries = 0, retryDelayMs = 250, retryStatusCodes = DEFAULT_RETRY_STATUS_CODES, retryUnsafeMethods = false, retryMethods = DEFAULT_RETRY_METHODS, onRetry, expectedStatus: _expectedStatus, ...restOptions } = options;
|
|
70
|
+
const { timeout = 10000, fetchImpl = fetch, retries = 0, retryDelayMs = 250, retryStatusCodes = DEFAULT_RETRY_STATUS_CODES, retryUnsafeMethods = false, retryMethods = DEFAULT_RETRY_METHODS, onRetry, expectedStatus: _expectedStatus, ...restOptions } = options;
|
|
71
71
|
const maxAttempts = Math.max(1, Math.trunc(retries) + 1);
|
|
72
72
|
const method = normalizeMethod(restOptions.method);
|
|
73
73
|
const retryAllowed = canRetryMethod(method, retryMethods, retryUnsafeMethods);
|
|
@@ -77,7 +77,7 @@ async function requestWithTimeout(url, options, readResponse) {
|
|
|
77
77
|
const started = Date.now();
|
|
78
78
|
let responseStatus;
|
|
79
79
|
try {
|
|
80
|
-
const response = await
|
|
80
|
+
const response = await fetchImpl(url, {
|
|
81
81
|
...restOptions,
|
|
82
82
|
signal: controller.signal,
|
|
83
83
|
});
|