daiso 1.0.4 → 1.0.5

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 (158) hide show
  1. package/README.md +218 -540
  2. package/dist/api/actionsProxy.js +82 -0
  3. package/dist/api/cgvHandlers.js +103 -16
  4. package/dist/api/daisoHandlers.js +127 -0
  5. package/dist/api/emart24Handlers.js +176 -0
  6. package/dist/api/gs25Handlers.js +256 -0
  7. package/dist/api/handlers.js +184 -108
  8. package/dist/api/healthChecks.js +332 -0
  9. package/dist/api/lottecinemaHandlers.js +155 -0
  10. package/dist/api/lottemartHandlers.js +128 -0
  11. package/dist/api/megaboxHandlers.js +81 -50
  12. package/dist/api/routes/cuRoutes.js +17 -0
  13. package/dist/api/routes/daisoRoutes.js +6 -1
  14. package/dist/api/routes/emart24Routes.js +22 -0
  15. package/dist/api/routes/gs25Routes.js +22 -0
  16. package/dist/api/routes/healthRoutes.js +44 -0
  17. package/dist/api/routes/lottecinemaRoutes.js +22 -0
  18. package/dist/api/routes/lottemartRoutes.js +18 -0
  19. package/dist/api/routes/oliveyoungRoutes.js +6 -1
  20. package/dist/api/routes/sevenelevenRoutes.js +32 -0
  21. package/dist/api/sevenelevenHandlers.js +200 -0
  22. package/dist/cli/args.js +54 -0
  23. package/dist/cli/commands/convenience.js +241 -0
  24. package/dist/cli/commands/daiso.js +96 -0
  25. package/dist/cli/constants.js +5 -0
  26. package/dist/cli/deps.js +48 -0
  27. package/dist/cli/http.js +98 -0
  28. package/dist/cli/interactiveFetch.js +31 -0
  29. package/dist/cli/interactiveItemSearch.js +259 -0
  30. package/dist/cli/interactivePrompt.js +49 -0
  31. package/dist/cli/interactiveTypes.js +4 -0
  32. package/dist/cli/types.js +4 -0
  33. package/dist/cli.js +66 -197
  34. package/dist/cliHelp.js +206 -1
  35. package/dist/cliInteractive.js +174 -0
  36. package/dist/cliPicker.js +56 -0
  37. package/dist/cliRenderer.js +4 -0
  38. package/dist/index.js +147 -8
  39. package/dist/pages/openapi.js +2 -1
  40. package/dist/pages/openapiFullSpec.js +58 -0
  41. package/dist/pages/openapiResponse.js +28 -0
  42. package/dist/pages/openapiSpec.js +2 -41
  43. package/dist/pages/openapiSpecActions.js +109 -0
  44. package/dist/pages/openapiSpecComponents.js +30 -391
  45. package/dist/pages/openapiSpecComponentsCgv.js +24 -0
  46. package/dist/pages/openapiSpecComponentsCu.js +112 -0
  47. package/dist/pages/openapiSpecComponentsDaisoOliveyoung.js +342 -0
  48. package/dist/pages/openapiSpecComponentsEmart24.js +141 -0
  49. package/dist/pages/openapiSpecComponentsGs25.js +109 -0
  50. package/dist/pages/openapiSpecComponentsLotteMart.js +85 -0
  51. package/dist/pages/openapiSpecComponentsLottecinema.js +163 -0
  52. package/dist/pages/openapiSpecComponentsMegabox.js +144 -0
  53. package/dist/pages/openapiSpecComponentsSeveneleven.js +82 -0
  54. package/dist/pages/openapiSpecPathsCgv.js +66 -3
  55. package/dist/pages/openapiSpecPathsCu.js +138 -0
  56. package/dist/pages/openapiSpecPathsDaiso.js +302 -0
  57. package/dist/pages/openapiSpecPathsDaisoOliveyoung.js +4 -376
  58. package/dist/pages/openapiSpecPathsEmart24.js +223 -0
  59. package/dist/pages/openapiSpecPathsGs25.js +166 -0
  60. package/dist/pages/openapiSpecPathsLotteMart.js +135 -0
  61. package/dist/pages/openapiSpecPathsLottecinema.js +212 -0
  62. package/dist/pages/openapiSpecPathsMegabox.js +52 -3
  63. package/dist/pages/openapiSpecPathsOliveyoung.js +231 -0
  64. package/dist/pages/openapiSpecPathsSeveneleven.js +234 -0
  65. package/dist/pages/prompt.js +8 -402
  66. package/dist/pages/promptCoreText.js +278 -0
  67. package/dist/pages/promptReferenceText.js +131 -0
  68. package/dist/pages/promptRetailCinemaText.js +273 -0
  69. package/dist/services/cgv/client.js +6 -1
  70. package/dist/services/cgv/index.js +3 -3
  71. package/dist/services/cgv/location.js +235 -0
  72. package/dist/services/cgv/tools/findTheaters.js +30 -5
  73. package/dist/services/cgv/tools/getTimetable.js +42 -14
  74. package/dist/services/cgv/tools/searchMovies.js +40 -12
  75. package/dist/services/cgv/transport.js +0 -3
  76. package/dist/services/cu/api.js +11 -0
  77. package/dist/services/cu/client.js +297 -0
  78. package/dist/services/cu/index.js +21 -0
  79. package/dist/services/cu/tools/checkInventory.js +123 -0
  80. package/dist/services/cu/tools/findNearbyStores.js +49 -0
  81. package/dist/services/cu/types.js +4 -0
  82. package/dist/services/daiso/api.js +22 -2
  83. package/dist/services/daiso/client.js +59 -0
  84. package/dist/services/daiso/index.js +2 -0
  85. package/dist/services/daiso/product.js +17 -0
  86. package/dist/services/daiso/tools/checkInventory.js +76 -41
  87. package/dist/services/daiso/tools/findStores.js +17 -7
  88. package/dist/services/daiso/tools/getDisplayLocation.js +75 -0
  89. package/dist/services/emart24/api.js +11 -0
  90. package/dist/services/emart24/client.js +173 -0
  91. package/dist/services/emart24/index.js +22 -0
  92. package/dist/services/emart24/inventoryLookup.js +134 -0
  93. package/dist/services/emart24/tools/checkInventory.js +54 -0
  94. package/dist/services/emart24/tools/findNearbyStores.js +72 -0
  95. package/dist/services/emart24/tools/searchProducts.js +63 -0
  96. package/dist/services/emart24/types.js +4 -0
  97. package/dist/services/gs25/api.js +9 -0
  98. package/dist/services/gs25/client.js +198 -0
  99. package/dist/services/gs25/index.js +22 -0
  100. package/dist/services/gs25/storeUtils.js +168 -0
  101. package/dist/services/gs25/tools/checkInventory.js +163 -0
  102. package/dist/services/gs25/tools/findNearbyStores.js +103 -0
  103. package/dist/services/gs25/tools/searchProducts.js +50 -0
  104. package/dist/services/gs25/types.js +4 -0
  105. package/dist/services/lottecinema/api.js +12 -0
  106. package/dist/services/lottecinema/client.js +161 -0
  107. package/dist/services/lottecinema/index.js +30 -0
  108. package/dist/services/lottecinema/location.js +191 -0
  109. package/dist/services/lottecinema/tools/findNearbyTheaters.js +47 -0
  110. package/dist/services/lottecinema/tools/getRemainingSeats.js +85 -0
  111. package/dist/services/lottecinema/tools/listNowShowing.js +79 -0
  112. package/dist/services/lottecinema/types.js +4 -0
  113. package/dist/services/lottemart/api.js +31 -0
  114. package/dist/services/lottemart/client.js +245 -0
  115. package/dist/services/lottemart/clientTypes.js +4 -0
  116. package/dist/services/lottemart/config.js +4 -0
  117. package/dist/services/lottemart/debug.js +92 -0
  118. package/dist/services/lottemart/geocode.js +49 -0
  119. package/dist/services/lottemart/index.js +30 -0
  120. package/dist/services/lottemart/keyword.js +29 -0
  121. package/dist/services/lottemart/parser.js +185 -0
  122. package/dist/services/lottemart/session.js +331 -0
  123. package/dist/services/lottemart/storeSearch.js +52 -0
  124. package/dist/services/lottemart/tools/findNearbyStores.js +67 -0
  125. package/dist/services/lottemart/tools/searchProducts.js +62 -0
  126. package/dist/services/lottemart/types.js +4 -0
  127. package/dist/services/lottemart/zetta.js +105 -0
  128. package/dist/services/megabox/location.js +219 -0
  129. package/dist/services/megabox/tools/findNearbyTheaters.js +13 -44
  130. package/dist/services/megabox/tools/getRemainingSeats.js +31 -3
  131. package/dist/services/megabox/tools/listNowShowing.js +31 -3
  132. package/dist/services/oliveyoung/api.js +2 -0
  133. package/dist/services/oliveyoung/client.js +150 -9
  134. package/dist/services/oliveyoung/index.js +3 -1
  135. package/dist/services/oliveyoung/tools/checkInventory.js +15 -4
  136. package/dist/services/oliveyoung/tools/searchProducts.js +57 -0
  137. package/dist/services/seveneleven/api.js +15 -0
  138. package/dist/services/seveneleven/client.js +279 -0
  139. package/dist/services/seveneleven/index.js +30 -0
  140. package/dist/services/seveneleven/inventory.js +302 -0
  141. package/dist/services/seveneleven/productKeyword.js +141 -0
  142. package/dist/services/seveneleven/tools/checkInventory.js +82 -0
  143. package/dist/services/seveneleven/tools/getCatalogSnapshot.js +55 -0
  144. package/dist/services/seveneleven/tools/getSearchPopwords.js +41 -0
  145. package/dist/services/seveneleven/tools/searchProducts.js +56 -0
  146. package/dist/services/seveneleven/tools/searchStores.js +45 -0
  147. package/dist/services/seveneleven/types.js +4 -0
  148. package/dist/utils/cliInteractiveHelpers.js +63 -0
  149. package/dist/utils/daisoKeyword.js +30 -0
  150. package/dist/utils/http.js +16 -2
  151. package/openapi.json +1977 -734
  152. package/openapi.yaml +1430 -543
  153. package/package.json +28 -15
  154. package/dist/tools/checkInventory.js +0 -98
  155. package/dist/tools/findStores.js +0 -125
  156. package/dist/tools/getPriceInfo.js +0 -77
  157. package/dist/tools/searchProducts.js +0 -61
  158. package/dist/types/index.js +0 -4
@@ -0,0 +1,219 @@
1
+ /**
2
+ * 메가박스 위치 해석 및 근처 지점 조회 보조 모듈
3
+ */
4
+ import { fetchJson } from '../../utils/http.js';
5
+ import { fetchMegaboxBookingList, fetchMegaboxTheaterInfo } from './client.js';
6
+ const DEFAULT_LATITUDE = 37.5665;
7
+ const DEFAULT_LONGITUDE = 126.978;
8
+ const DEFAULT_AREA_CODE = '11';
9
+ const DEFAULT_TIMEOUT_MS = 15000;
10
+ const GEOCODE_CACHE_TTL_MS = 60 * 60 * 1000;
11
+ const megaboxGeocodeCache = new Map();
12
+ const MEGABOX_AREA_CODE_BY_REGION = [
13
+ ['서울특별시', '11'],
14
+ ['서울', '11'],
15
+ ['부산광역시', '26'],
16
+ ['부산', '26'],
17
+ ['대구광역시', '27'],
18
+ ['대구', '27'],
19
+ ['인천광역시', '28'],
20
+ ['인천', '28'],
21
+ ['광주광역시', '29'],
22
+ ['광주', '29'],
23
+ ['대전광역시', '30'],
24
+ ['대전', '30'],
25
+ ['울산광역시', '31'],
26
+ ['울산', '31'],
27
+ ['세종특별자치시', '36'],
28
+ ['세종', '36'],
29
+ ['경기도', '41'],
30
+ ['경기', '41'],
31
+ ['강원특별자치도', '42'],
32
+ ['강원도', '42'],
33
+ ['강원', '42'],
34
+ ['충청북도', '43'],
35
+ ['충북', '43'],
36
+ ['충청남도', '44'],
37
+ ['충남', '44'],
38
+ ['전북특별자치도', '45'],
39
+ ['전라북도', '45'],
40
+ ['전북', '45'],
41
+ ['전라남도', '46'],
42
+ ['전남', '46'],
43
+ ['경상북도', '47'],
44
+ ['경북', '47'],
45
+ ['경상남도', '48'],
46
+ ['경남', '48'],
47
+ ['제주특별자치도', '50'],
48
+ ['제주도', '50'],
49
+ ['제주', '50'],
50
+ ];
51
+ function calculateDistanceKm(lat1, lng1, lat2, lng2) {
52
+ const toRad = (deg) => (deg * Math.PI) / 180;
53
+ const dLat = toRad(lat2 - lat1);
54
+ const dLng = toRad(lng2 - lng1);
55
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
56
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
57
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
58
+ return 6371 * c;
59
+ }
60
+ function isValidCoordinate(value) {
61
+ return typeof value === 'number' && Number.isFinite(value);
62
+ }
63
+ function resolveAreaCodeFromText(text) {
64
+ const normalized = text.replace(/\s+/g, '');
65
+ for (const [region, areaCode] of MEGABOX_AREA_CODE_BY_REGION) {
66
+ if (normalized.includes(region.replace(/\s+/g, ''))) {
67
+ return areaCode;
68
+ }
69
+ }
70
+ return null;
71
+ }
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
+ export async function resolveMegaboxLocation(params, options = {}) {
135
+ const explicitAreaCode = (params.areaCode || '').trim();
136
+ const keyword = (params.keyword || '').trim();
137
+ const hasCoordinates = isValidCoordinate(params.latitude) && isValidCoordinate(params.longitude);
138
+ let latitude = hasCoordinates ? params.latitude : DEFAULT_LATITUDE;
139
+ let longitude = hasCoordinates ? params.longitude : DEFAULT_LONGITUDE;
140
+ let areaCode = explicitAreaCode || '';
141
+ let geocodeUsed = false;
142
+ if (!explicitAreaCode || !hasCoordinates) {
143
+ const geocoded = await geocodeMegaboxLocation(params, options);
144
+ if (geocoded) {
145
+ if (!hasCoordinates) {
146
+ latitude = geocoded.latitude;
147
+ longitude = geocoded.longitude;
148
+ }
149
+ if (!explicitAreaCode && geocoded.areaCode) {
150
+ areaCode = geocoded.areaCode;
151
+ }
152
+ geocodeUsed = true;
153
+ }
154
+ }
155
+ return {
156
+ keyword: keyword.length > 0 ? keyword : null,
157
+ latitude,
158
+ longitude,
159
+ areaCode: areaCode || DEFAULT_AREA_CODE,
160
+ geocodeUsed,
161
+ };
162
+ }
163
+ export async function fetchMegaboxNearbyTheaters(params, options = {}) {
164
+ const timeout = params.timeout || DEFAULT_TIMEOUT_MS;
165
+ const limit = Math.max(1, params.limit || 10);
166
+ const resolved = await resolveMegaboxLocation(params, options);
167
+ const { theaters } = await fetchMegaboxBookingList({
168
+ playDate: params.playDate,
169
+ areaCode: resolved.areaCode,
170
+ timeout,
171
+ });
172
+ const infoResults = await Promise.allSettled(theaters.map((theater) => fetchMegaboxTheaterInfo(theater.theaterId, timeout)));
173
+ const merged = theaters
174
+ .map((theater, index) => {
175
+ const infoResult = infoResults[index];
176
+ if (infoResult.status !== 'fulfilled') {
177
+ return null;
178
+ }
179
+ const info = infoResult.value;
180
+ if (info.latitude === null || info.longitude === null) {
181
+ return null;
182
+ }
183
+ return {
184
+ theaterId: theater.theaterId,
185
+ theaterName: theater.theaterName,
186
+ address: info.address,
187
+ latitude: info.latitude,
188
+ longitude: info.longitude,
189
+ distanceKm: Number(calculateDistanceKm(resolved.latitude, resolved.longitude, info.latitude, info.longitude).toFixed(2)),
190
+ };
191
+ })
192
+ .filter((theater) => theater !== null)
193
+ .sort((left, right) => left.distanceKm - right.distanceKm)
194
+ .slice(0, limit);
195
+ return {
196
+ ...resolved,
197
+ playDate: params.playDate,
198
+ count: merged.length,
199
+ theaters: merged,
200
+ };
201
+ }
202
+ export async function resolveMegaboxNearestTheater(params, options = {}) {
203
+ const location = await resolveMegaboxLocation(params, options);
204
+ const nearby = await fetchMegaboxNearbyTheaters({
205
+ ...params,
206
+ playDate: params.playDate,
207
+ limit: 1,
208
+ areaCode: location.areaCode,
209
+ latitude: location.latitude,
210
+ longitude: location.longitude,
211
+ }, options);
212
+ return {
213
+ theater: nearby.theaters[0] || null,
214
+ location,
215
+ };
216
+ }
217
+ export function __testOnlyClearMegaboxLocationCaches() {
218
+ megaboxGeocodeCache.clear();
219
+ }
@@ -2,54 +2,22 @@
2
2
  * 메가박스 주변 지점 탐색 도구
3
3
  */
4
4
  import * as z from 'zod';
5
- import { fetchMegaboxBookingList, fetchMegaboxTheaterInfo, toYyyymmdd } from '../client.js';
6
- function calculateDistanceKm(lat1, lng1, lat2, lng2) {
7
- const toRad = (deg) => (deg * Math.PI) / 180;
8
- const dLat = toRad(lat2 - lat1);
9
- const dLng = toRad(lng2 - lng1);
10
- const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
11
- Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
12
- const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
13
- return 6371 * c;
14
- }
5
+ import { toYyyymmdd } from '../client.js';
6
+ import { fetchMegaboxNearbyTheaters } from '../location.js';
15
7
  async function findNearbyTheaters(args) {
16
- const { latitude = 37.5665, longitude = 126.978, playDate = toYyyymmdd(), areaCode = '11', limit = 10, timeoutMs = 15000, } = args;
17
- const { theaters } = await fetchMegaboxBookingList({
8
+ const { keyword, latitude, longitude, playDate = toYyyymmdd(), areaCode, limit = 10, timeoutMs = 15000, } = args;
9
+ const result = await fetchMegaboxNearbyTheaters({
10
+ keyword,
11
+ latitude,
12
+ longitude,
18
13
  playDate,
19
14
  areaCode,
15
+ limit,
16
+ timeout: timeoutMs,
17
+ }, {
18
+ googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
20
19
  timeout: timeoutMs,
21
20
  });
22
- const infoResults = await Promise.allSettled(theaters.map((theater) => fetchMegaboxTheaterInfo(theater.theaterId, timeoutMs)));
23
- const merged = theaters
24
- .map((theater, index) => {
25
- const infoResult = infoResults[index];
26
- if (infoResult.status !== 'fulfilled') {
27
- return null;
28
- }
29
- const info = infoResult.value;
30
- if (info.latitude === null || info.longitude === null) {
31
- return null;
32
- }
33
- const distanceKm = calculateDistanceKm(latitude, longitude, info.latitude, info.longitude);
34
- return {
35
- theaterId: theater.theaterId,
36
- theaterName: theater.theaterName,
37
- address: info.address,
38
- latitude: info.latitude,
39
- longitude: info.longitude,
40
- distanceKm: Number(distanceKm.toFixed(2)),
41
- };
42
- })
43
- .filter((theater) => theater !== null)
44
- .sort((a, b) => a.distanceKm - b.distanceKm)
45
- .slice(0, limit);
46
- const result = {
47
- location: { latitude, longitude },
48
- playDate,
49
- areaCode,
50
- count: merged.length,
51
- theaters: merged,
52
- };
53
21
  return {
54
22
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
55
23
  };
@@ -59,8 +27,9 @@ export function createFindNearbyTheatersTool() {
59
27
  name: 'megabox_find_nearby_theaters',
60
28
  metadata: {
61
29
  title: '메가박스 주변 지점 탐색',
62
- description: '사용자 좌표 기준으로 메가박스 지점을 거리순으로 조회합니다.',
30
+ description: '사용자 좌표 또는 위치 키워드 기준으로 메가박스 지점을 거리순으로 조회합니다.',
63
31
  inputSchema: {
32
+ keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 강남역)'),
64
33
  latitude: z.number().optional().default(37.5665).describe('위도 (기본값: 서울 시청 37.5665)'),
65
34
  longitude: z.number().optional().default(126.978).describe('경도 (기본값: 서울 시청 126.978)'),
66
35
  playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'),
@@ -3,13 +3,34 @@
3
3
  */
4
4
  import * as z from 'zod';
5
5
  import { fetchMegaboxBookingList, toYyyymmdd } from '../client.js';
6
+ import { resolveMegaboxNearestTheater } from '../location.js';
6
7
  async function getRemainingSeats(args) {
7
- const { playDate = toYyyymmdd(), theaterId, movieId, areaCode = '11', limit = 50, timeoutMs = 15000, } = args;
8
+ const { playDate = toYyyymmdd(), theaterId: inputTheaterId, movieId, keyword, latitude, longitude, areaCode, limit = 50, timeoutMs = 15000, } = args;
9
+ let theaterId = inputTheaterId;
10
+ let resolvedTheater = null;
11
+ let resolvedLocation = null;
12
+ if (!theaterId && (typeof latitude === 'number' || typeof longitude === 'number' || (keyword || '').trim().length > 0)) {
13
+ const resolved = await resolveMegaboxNearestTheater({
14
+ keyword,
15
+ latitude,
16
+ longitude,
17
+ areaCode,
18
+ playDate,
19
+ timeout: timeoutMs,
20
+ }, {
21
+ googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
22
+ timeout: timeoutMs,
23
+ });
24
+ theaterId = resolved.theater?.theaterId;
25
+ resolvedTheater = resolved.theater;
26
+ resolvedLocation = resolved.location;
27
+ }
28
+ const resolvedAreaCode = resolvedLocation?.areaCode || areaCode || '11';
8
29
  const { showtimes } = await fetchMegaboxBookingList({
9
30
  playDate,
10
31
  theaterId,
11
32
  movieId,
12
- areaCode,
33
+ areaCode: resolvedAreaCode,
13
34
  timeout: timeoutMs,
14
35
  });
15
36
  const filteredShowtimes = showtimes
@@ -27,9 +48,13 @@ async function getRemainingSeats(args) {
27
48
  filters: {
28
49
  theaterId: theaterId || null,
29
50
  movieId: movieId || null,
30
- areaCode,
51
+ keyword: keyword || null,
52
+ latitude: typeof latitude === 'number' ? latitude : null,
53
+ longitude: typeof longitude === 'number' ? longitude : null,
54
+ areaCode: resolvedAreaCode,
31
55
  limit,
32
56
  },
57
+ resolvedTheater,
33
58
  count: filteredShowtimes.length,
34
59
  seats: filteredShowtimes,
35
60
  };
@@ -47,6 +72,9 @@ export function createGetRemainingSeatsTool() {
47
72
  playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'),
48
73
  theaterId: z.string().optional().describe('메가박스 지점 번호 (예: 1372)'),
49
74
  movieId: z.string().optional().describe('메가박스 영화 번호 (예: 25104500)'),
75
+ keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 강남역)'),
76
+ latitude: z.number().optional().describe('위도'),
77
+ longitude: z.number().optional().describe('경도'),
50
78
  areaCode: z.string().optional().default('11').describe('지역 코드 (기본값: 11, 서울)'),
51
79
  limit: z.number().optional().default(50).describe('반환할 최대 회차 수 (기본값: 50)'),
52
80
  timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
@@ -3,13 +3,34 @@
3
3
  */
4
4
  import * as z from 'zod';
5
5
  import { fetchMegaboxBookingList, toYyyymmdd } from '../client.js';
6
+ import { resolveMegaboxNearestTheater } from '../location.js';
6
7
  async function listNowShowing(args) {
7
- const { playDate = toYyyymmdd(), theaterId, movieId, areaCode = '11', timeoutMs = 15000, } = args;
8
+ const { playDate = toYyyymmdd(), theaterId: inputTheaterId, movieId, keyword, latitude, longitude, areaCode, timeoutMs = 15000, } = args;
9
+ let theaterId = inputTheaterId;
10
+ let resolvedTheater = null;
11
+ let resolvedLocation = null;
12
+ if (!theaterId && (typeof latitude === 'number' || typeof longitude === 'number' || (keyword || '').trim().length > 0)) {
13
+ const resolved = await resolveMegaboxNearestTheater({
14
+ keyword,
15
+ latitude,
16
+ longitude,
17
+ areaCode,
18
+ playDate,
19
+ timeout: timeoutMs,
20
+ }, {
21
+ googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
22
+ timeout: timeoutMs,
23
+ });
24
+ theaterId = resolved.theater?.theaterId;
25
+ resolvedTheater = resolved.theater;
26
+ resolvedLocation = resolved.location;
27
+ }
28
+ const resolvedAreaCode = resolvedLocation?.areaCode || areaCode || '11';
8
29
  const { theaters, movies, showtimes } = await fetchMegaboxBookingList({
9
30
  playDate,
10
31
  theaterId,
11
32
  movieId,
12
- areaCode,
33
+ areaCode: resolvedAreaCode,
13
34
  timeout: timeoutMs,
14
35
  });
15
36
  const result = {
@@ -17,8 +38,12 @@ async function listNowShowing(args) {
17
38
  filters: {
18
39
  theaterId: theaterId || null,
19
40
  movieId: movieId || null,
20
- areaCode,
41
+ keyword: keyword || null,
42
+ latitude: typeof latitude === 'number' ? latitude : null,
43
+ longitude: typeof longitude === 'number' ? longitude : null,
44
+ areaCode: resolvedAreaCode,
21
45
  },
46
+ resolvedTheater,
22
47
  counts: {
23
48
  theaters: theaters.length,
24
49
  movies: movies.length,
@@ -42,6 +67,9 @@ export function createListNowShowingTool() {
42
67
  playDate: z.string().optional().describe('조회 날짜(YYYYMMDD, 기본값: 오늘)'),
43
68
  theaterId: z.string().optional().describe('메가박스 지점 번호 (예: 1372)'),
44
69
  movieId: z.string().optional().describe('메가박스 영화 번호 (예: 25104500)'),
70
+ keyword: z.string().optional().describe('위치 키워드 (예: 안산 중앙역, 강남역)'),
71
+ latitude: z.number().optional().describe('위도'),
72
+ longitude: z.number().optional().describe('경도'),
45
73
  areaCode: z.string().optional().default('11').describe('지역 코드 (기본값: 11, 서울)'),
46
74
  timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
47
75
  },
@@ -5,4 +5,6 @@ export const OLIVEYOUNG_API = {
5
5
  BASE_URL: 'https://www.oliveyoung.co.kr',
6
6
  STORE_FINDER_PATH: '/oystore/api/storeFinder/find-store',
7
7
  PRODUCT_SEARCH_PATH: '/oystore/api/stock/product-search-v3',
8
+ STOCK_GOODS_INFO_PATH: '/oystore/api/stock/stock-goods-info-v3',
9
+ STOCK_STORES_PATH: '/oystore/api/stock/stock-stores',
8
10
  };
@@ -5,6 +5,61 @@
5
5
  */
6
6
  import { OLIVEYOUNG_API } from './api.js';
7
7
  import { decodeBase64, requestByZyte } from '../../utils/zyte.js';
8
+ const OLIVEYOUNG_IMAGE_HOST = 'https://image.oliveyoung.co.kr';
9
+ const OLIVEYOUNG_IMAGE_PATH_PREFIX = '/uploads/images/goods';
10
+ function resolveOliveyoungInStock(o2oStockFlag, o2oRemainQuantity) {
11
+ return o2oStockFlag || o2oRemainQuantity > 0;
12
+ }
13
+ function resolveOliveyoungImageUrl(imagePath) {
14
+ if (!imagePath) {
15
+ return undefined;
16
+ }
17
+ if (/^https?:\/\//i.test(imagePath)) {
18
+ return imagePath;
19
+ }
20
+ if (imagePath.startsWith('//')) {
21
+ return `https:${imagePath}`;
22
+ }
23
+ const normalizedPath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`;
24
+ if (normalizedPath.startsWith(`${OLIVEYOUNG_IMAGE_PATH_PREFIX}/`)) {
25
+ return `${OLIVEYOUNG_IMAGE_HOST}${normalizedPath}`;
26
+ }
27
+ return `${OLIVEYOUNG_IMAGE_HOST}${OLIVEYOUNG_IMAGE_PATH_PREFIX}${normalizedPath}`;
28
+ }
29
+ function resolveOliveyoungStoreQuantity(remainQuantity, o2oRemainQuantity) {
30
+ return Math.max(remainQuantity, o2oRemainQuantity);
31
+ }
32
+ function resolveOliveyoungStoreStock(rawStore) {
33
+ const remainQuantity = rawStore?.remainQuantity || 0;
34
+ const o2oRemainQuantity = rawStore?.o2oRemainQuantity || 0;
35
+ const quantity = resolveOliveyoungStoreQuantity(remainQuantity, o2oRemainQuantity);
36
+ const salesStoreYn = Boolean(rawStore?.salesStoreYn);
37
+ let stockStatus = 'out_of_stock';
38
+ let stockLabel = '품절';
39
+ if (!salesStoreYn) {
40
+ stockStatus = 'not_sold';
41
+ stockLabel = '미판매';
42
+ }
43
+ else if (quantity > 0) {
44
+ stockStatus = 'in_stock';
45
+ stockLabel = quantity >= 9 ? '재고 9개 이상' : `재고 ${quantity}개`;
46
+ }
47
+ return {
48
+ storeCode: rawStore?.storeCode || '',
49
+ storeName: rawStore?.storeName || '',
50
+ address: rawStore?.address || '',
51
+ latitude: rawStore?.latitude || 0,
52
+ longitude: rawStore?.longitude || 0,
53
+ distance: rawStore?.distance || 0,
54
+ pickupYn: Boolean(rawStore?.pickupYn),
55
+ salesStoreYn,
56
+ remainQuantity,
57
+ o2oRemainQuantity,
58
+ stockStatus,
59
+ stockLabel,
60
+ openYn: Boolean(rawStore?.openYn),
61
+ };
62
+ }
8
63
  async function zyteExtract(targetPath, requestBody, options = {}) {
9
64
  const { timeout = 15000, apiKey } = options;
10
65
  try {
@@ -73,18 +128,104 @@ export async function fetchOliveyoungProducts(params, options = {}) {
73
128
  };
74
129
  const body = await zyteExtract(OLIVEYOUNG_API.PRODUCT_SEARCH_PATH, payload, options);
75
130
  const list = body.data?.serachList || body.data?.searchList || [];
76
- const products = list.map((product) => ({
77
- goodsNumber: product.goodsNumber || '',
78
- goodsName: product.goodsName || '',
79
- priceToPay: product.priceToPay || 0,
80
- originalPrice: product.originalPrice || 0,
81
- discountRate: product.discountRate || 0,
82
- o2oStockFlag: Boolean(product.o2oStockFlag),
83
- o2oRemainQuantity: product.o2oRemainQuantity || 0,
84
- }));
131
+ const products = list.map((product) => {
132
+ const o2oStockFlag = Boolean(product.o2oStockFlag);
133
+ const o2oRemainQuantity = product.o2oRemainQuantity || 0;
134
+ const inStock = resolveOliveyoungInStock(o2oStockFlag, o2oRemainQuantity);
135
+ const stockStatus = inStock ? 'in_stock' : 'out_of_stock';
136
+ const stockSource = 'global_search';
137
+ return {
138
+ goodsNumber: product.goodsNumber || '',
139
+ goodsName: product.goodsName || '',
140
+ imageUrl: resolveOliveyoungImageUrl(product.imagePath),
141
+ priceToPay: product.priceToPay || 0,
142
+ originalPrice: product.originalPrice || 0,
143
+ discountRate: product.discountRate || 0,
144
+ o2oStockFlag,
145
+ o2oRemainQuantity,
146
+ inStock,
147
+ stockStatus,
148
+ stockSource,
149
+ };
150
+ });
85
151
  return {
86
152
  totalCount: body.data?.totalCount || 0,
87
153
  nextPage: Boolean(body.data?.nextPage),
88
154
  products,
89
155
  };
90
156
  }
157
+ async function fetchOliveyoungProductId(goodsNumber, options = {}) {
158
+ const body = await zyteExtract(OLIVEYOUNG_API.STOCK_GOODS_INFO_PATH, { goodsNo: goodsNumber }, options);
159
+ return body.data?.goodsInfo?.masterGoodsNumber || '';
160
+ }
161
+ async function fetchOliveyoungStockStores(params, options = {}) {
162
+ const body = await zyteExtract(OLIVEYOUNG_API.STOCK_STORES_PATH, {
163
+ productId: params.productId,
164
+ lat: params.latitude,
165
+ lon: params.longitude,
166
+ pageIdx: params.pageIdx,
167
+ searchWords: params.searchWords,
168
+ mapLat: params.latitude,
169
+ mapLon: params.longitude,
170
+ }, options);
171
+ const stores = (body.data?.storeList || []).map((store) => resolveOliveyoungStoreStock(store));
172
+ const inStockCount = stores.filter((store) => store.stockStatus === 'in_stock').length;
173
+ const notSoldCount = stores.filter((store) => store.stockStatus === 'not_sold').length;
174
+ return {
175
+ totalCount: body.data?.totalCount || 0,
176
+ inStockCount,
177
+ outOfStockCount: stores.length - inStockCount - notSoldCount,
178
+ notSoldCount,
179
+ stores,
180
+ };
181
+ }
182
+ function sortOliveyoungProducts(products) {
183
+ const score = (product) => {
184
+ if (product.stockSource === 'nearby_store') {
185
+ return product.inStock ? 4 : 3;
186
+ }
187
+ return product.inStock ? 2 : 1;
188
+ };
189
+ return [...products].sort((left, right) => score(right) - score(left));
190
+ }
191
+ export async function enrichOliveyoungProductsWithNearbyStoreInventory(products, params, options = {}) {
192
+ const maxProducts = Math.max(0, Math.min(products.length, params.maxProducts));
193
+ if (maxProducts === 0) {
194
+ return { checkedCount: 0, products };
195
+ }
196
+ const enrichedProducts = [];
197
+ let checkedCount = 0;
198
+ for (const [index, product] of products.entries()) {
199
+ if (index >= maxProducts) {
200
+ enrichedProducts.push(product);
201
+ continue;
202
+ }
203
+ const productId = await fetchOliveyoungProductId(product.goodsNumber, options);
204
+ if (!productId) {
205
+ enrichedProducts.push(product);
206
+ continue;
207
+ }
208
+ const storeInventory = await fetchOliveyoungStockStores({
209
+ productId,
210
+ latitude: params.latitude,
211
+ longitude: params.longitude,
212
+ pageIdx: 1,
213
+ searchWords: params.storeKeyword,
214
+ }, options);
215
+ checkedCount += 1;
216
+ const inStock = storeInventory.inStockCount > 0;
217
+ const stockStatus = inStock ? 'in_stock' : 'out_of_stock';
218
+ const stockSource = 'nearby_store';
219
+ enrichedProducts.push({
220
+ ...product,
221
+ inStock,
222
+ stockStatus,
223
+ stockSource,
224
+ storeInventory,
225
+ });
226
+ }
227
+ return {
228
+ checkedCount,
229
+ products: sortOliveyoungProducts(enrichedProducts),
230
+ };
231
+ }
@@ -3,11 +3,12 @@
3
3
  */
4
4
  import { createFindNearbyStoresTool } from './tools/findNearbyStores.js';
5
5
  import { createCheckInventoryTool } from './tools/checkInventory.js';
6
+ import { createSearchProductsTool } from './tools/searchProducts.js';
6
7
  const OLIVEYOUNG_METADATA = {
7
8
  id: 'oliveyoung',
8
9
  name: '올리브영',
9
10
  version: '1.0.0',
10
- description: '올리브영 주변 매장 탐색 및 재고 파악 서비스 (Zyte API 기반)',
11
+ description: '올리브영 상품 검색, 주변 매장 탐색 및 재고 파악 서비스 (Zyte API 기반)',
11
12
  };
12
13
  class OliveyoungService {
13
14
  options;
@@ -17,6 +18,7 @@ class OliveyoungService {
17
18
  }
18
19
  getTools() {
19
20
  return [
21
+ createSearchProductsTool(this.options.zyteApiKey),
20
22
  createFindNearbyStoresTool(this.options.zyteApiKey),
21
23
  createCheckInventoryTool(this.options.zyteApiKey),
22
24
  ];
@@ -2,7 +2,7 @@
2
2
  * 올리브영 재고 파악 도구
3
3
  */
4
4
  import * as z from 'zod';
5
- import { fetchOliveyoungProducts, fetchOliveyoungStores } from '../client.js';
5
+ import { enrichOliveyoungProductsWithNearbyStoreInventory, fetchOliveyoungProducts, fetchOliveyoungStores, } from '../client.js';
6
6
  async function checkInventory(args) {
7
7
  const { keyword, latitude = 37.5665, longitude = 126.978, storeKeyword = '', page = 1, size = 20, sort = '01', includeSoldOut = false, storeLimit = 10, timeoutMs = 15000, zyteApiKey, } = args;
8
8
  if (!keyword || keyword.trim().length === 0) {
@@ -29,7 +29,16 @@ async function checkInventory(args) {
29
29
  apiKey: zyteApiKey,
30
30
  }),
31
31
  ]);
32
- const inStockProducts = productResult.products.filter((product) => product.o2oStockFlag || product.o2oRemainQuantity > 0);
32
+ const enrichedInventory = await enrichOliveyoungProductsWithNearbyStoreInventory(productResult.products, {
33
+ latitude,
34
+ longitude,
35
+ storeKeyword,
36
+ maxProducts: Math.min(productResult.products.length, 5),
37
+ }, {
38
+ timeout: timeoutMs,
39
+ apiKey: zyteApiKey,
40
+ });
41
+ const inStockProducts = enrichedInventory.products.filter((product) => product.inStock);
33
42
  const result = {
34
43
  keyword,
35
44
  searchOptions: {
@@ -51,9 +60,11 @@ async function checkInventory(args) {
51
60
  inventory: {
52
61
  totalCount: productResult.totalCount,
53
62
  nextPage: productResult.nextPage,
63
+ stockCheckedCount: enrichedInventory.checkedCount,
64
+ stockUncheckedCount: Math.max(0, productResult.products.length - enrichedInventory.checkedCount),
54
65
  inStockCount: inStockProducts.length,
55
- outOfStockCount: productResult.products.length - inStockProducts.length,
56
- products: productResult.products,
66
+ outOfStockCount: enrichedInventory.products.length - inStockProducts.length,
67
+ products: enrichedInventory.products,
57
68
  },
58
69
  };
59
70
  return {