daiso 1.0.3 → 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 -537
  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 -181
  34. package/dist/cliHelp.js +216 -9
  35. package/dist/cliInteractive.js +174 -0
  36. package/dist/cliPicker.js +56 -0
  37. package/dist/cliRenderer.js +139 -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,256 @@
1
+ /**
2
+ * GS25 GET API 핸들러
3
+ */
4
+ /* c8 ignore start */
5
+ import { errorResponse, successResponse } from './response.js';
6
+ import { attachDistanceToGs25Stores, fetchGs25SearchProducts, fetchGs25Stores, filterGs25StoresByKeyword, geocodeGs25Address, selectGs25StoresForKeyword, sortGs25Stores, } from '../services/gs25/client.js';
7
+ const GS25_FALLBACK_STORE_LOOKUP_ITEM_CODE = '8801117752804';
8
+ /**
9
+ * GS25 매장 검색 API 핸들러
10
+ * GET /api/gs25/stores?keyword={키워드}&lat={위도}&lng={경도}
11
+ */
12
+ export async function handleGs25FindStores(c) {
13
+ const keyword = c.req.query('keyword') || '';
14
+ const serviceCode = c.req.query('serviceCode') || '01';
15
+ const limit = parseInt(c.req.query('limit') || '20', 10);
16
+ const rawLat = c.req.query('lat');
17
+ const rawLng = c.req.query('lng');
18
+ const parsedLat = rawLat ? parseFloat(rawLat) : undefined;
19
+ const parsedLng = rawLng ? parseFloat(rawLng) : undefined;
20
+ let latitude = typeof parsedLat === 'number' && Number.isFinite(parsedLat) ? parsedLat : undefined;
21
+ let longitude = typeof parsedLng === 'number' && Number.isFinite(parsedLng) ? parsedLng : undefined;
22
+ let geocodeUsed = false;
23
+ try {
24
+ if ((typeof latitude !== 'number' || typeof longitude !== 'number') && keyword.trim().length > 0) {
25
+ const geocoded = await geocodeGs25Address(keyword, {
26
+ timeout: 15000,
27
+ googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY,
28
+ });
29
+ if (geocoded) {
30
+ latitude = geocoded.latitude;
31
+ longitude = geocoded.longitude;
32
+ geocodeUsed = true;
33
+ }
34
+ }
35
+ let storeResult = await fetchGs25Stores({
36
+ serviceCode,
37
+ latitude,
38
+ longitude,
39
+ }, {
40
+ timeout: 20000,
41
+ });
42
+ let fallbackUsed = false;
43
+ if (storeResult.stores.length === 0 && typeof latitude === 'number' && typeof longitude === 'number') {
44
+ try {
45
+ const fallbackResult = await fetchGs25Stores({
46
+ serviceCode,
47
+ itemCode: GS25_FALLBACK_STORE_LOOKUP_ITEM_CODE,
48
+ realTimeStockYn: 'Y',
49
+ latitude,
50
+ longitude,
51
+ useCache: false,
52
+ }, {
53
+ timeout: 20000,
54
+ });
55
+ if (fallbackResult.stores.length > 0) {
56
+ storeResult = fallbackResult;
57
+ fallbackUsed = true;
58
+ }
59
+ }
60
+ catch {
61
+ fallbackUsed = false;
62
+ }
63
+ }
64
+ const selected = selectGs25StoresForKeyword(storeResult.stores, keyword, {
65
+ relaxWhenEmpty: typeof latitude === 'number' && typeof longitude === 'number',
66
+ });
67
+ const withDistance = attachDistanceToGs25Stores(selected.stores, latitude, longitude);
68
+ const stores = sortGs25Stores(withDistance).slice(0, limit);
69
+ return successResponse(c, {
70
+ serviceCode,
71
+ keyword,
72
+ geocodeUsed,
73
+ location: typeof latitude === 'number' && typeof longitude === 'number'
74
+ ? { latitude, longitude }
75
+ : null,
76
+ cacheHit: storeResult.cacheHit,
77
+ filterRelaxed: selected.filterRelaxed,
78
+ fallbackUsed,
79
+ stores,
80
+ }, {
81
+ total: selected.stores.length,
82
+ pageSize: limit,
83
+ });
84
+ }
85
+ catch (error) {
86
+ const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
87
+ return errorResponse(c, 'GS25_STORE_SEARCH_FAILED', message, 500);
88
+ }
89
+ }
90
+ /**
91
+ * GS25 상품 검색 API 핸들러
92
+ * GET /api/gs25/products?keyword={검색어}
93
+ */
94
+ export async function handleGs25SearchProducts(c) {
95
+ const keyword = c.req.query('keyword') || '';
96
+ const limit = parseInt(c.req.query('limit') || '20', 10);
97
+ if (keyword.trim().length === 0) {
98
+ return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.');
99
+ }
100
+ try {
101
+ const products = await fetchGs25SearchProducts(keyword, { timeout: 20000 });
102
+ const limitedProducts = products.slice(0, limit);
103
+ return successResponse(c, {
104
+ keyword,
105
+ count: limitedProducts.length,
106
+ products: limitedProducts.map((p) => ({
107
+ itemCode: p.itemCode,
108
+ itemName: p.itemName,
109
+ shortItemName: p.shortItemName,
110
+ imageUrl: p.imageUrl,
111
+ rating: p.rating,
112
+ stockCheckEnabled: p.stockCheckEnabled,
113
+ })),
114
+ }, {
115
+ total: limitedProducts.length,
116
+ pageSize: limit,
117
+ });
118
+ }
119
+ catch (error) {
120
+ const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
121
+ return errorResponse(c, 'GS25_PRODUCT_SEARCH_FAILED', message, 500);
122
+ }
123
+ }
124
+ /**
125
+ * GS25 재고 조회 API 핸들러
126
+ * GET /api/gs25/inventory?keyword={검색어}&storeKeyword={매장키워드}&lat={위도}&lng={경도}
127
+ */
128
+ export async function handleGs25CheckInventory(c) {
129
+ const keyword = c.req.query('keyword') || '';
130
+ const itemCode = c.req.query('itemCode') || '';
131
+ const storeKeyword = c.req.query('storeKeyword') || '';
132
+ const serviceCode = c.req.query('serviceCode') || '01';
133
+ const storeLimit = parseInt(c.req.query('storeLimit') || '20', 10);
134
+ const rawLat = c.req.query('lat');
135
+ const rawLng = c.req.query('lng');
136
+ const parsedLat = rawLat ? parseFloat(rawLat) : undefined;
137
+ const parsedLng = rawLng ? parseFloat(rawLng) : undefined;
138
+ let latitude = typeof parsedLat === 'number' && Number.isFinite(parsedLat) ? parsedLat : undefined;
139
+ let longitude = typeof parsedLng === 'number' && Number.isFinite(parsedLng) ? parsedLng : undefined;
140
+ let geocodeUsed = false;
141
+ if (keyword.trim().length === 0 && itemCode.trim().length === 0) {
142
+ return errorResponse(c, 'MISSING_QUERY', '검색어(keyword) 또는 상품 코드(itemCode)를 입력해주세요.');
143
+ }
144
+ try {
145
+ if ((typeof latitude !== 'number' || typeof longitude !== 'number') && storeKeyword.trim().length > 0) {
146
+ const baseStores = await fetchGs25Stores({
147
+ serviceCode,
148
+ }, {
149
+ timeout: 20000,
150
+ });
151
+ const firstAddress = filterGs25StoresByKeyword(baseStores.stores, storeKeyword).find((store) => store.address.trim().length > 0)
152
+ ?.address || '';
153
+ if (firstAddress.length > 0) {
154
+ const geocoded = await geocodeGs25Address(firstAddress, {
155
+ timeout: 15000,
156
+ googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY,
157
+ });
158
+ if (geocoded) {
159
+ latitude = geocoded.latitude;
160
+ longitude = geocoded.longitude;
161
+ geocodeUsed = true;
162
+ }
163
+ }
164
+ }
165
+ // 1단계: totalSearch API로 키워드 → itemCode 변환
166
+ let firstProduct;
167
+ let stockResult;
168
+ let itemCodeUsed = itemCode.trim().length > 0;
169
+ let resolvedItemCode = itemCode.trim().length > 0 ? itemCode.trim() : null;
170
+ if (resolvedItemCode) {
171
+ stockResult = await fetchGs25Stores({
172
+ serviceCode,
173
+ itemCode: resolvedItemCode,
174
+ realTimeStockYn: 'Y',
175
+ latitude,
176
+ longitude,
177
+ useCache: false,
178
+ }, {
179
+ timeout: 20000,
180
+ });
181
+ }
182
+ else {
183
+ const searchProducts = await fetchGs25SearchProducts(keyword, { timeout: 20000 });
184
+ firstProduct = searchProducts.find((p) => p.itemCode.length > 0);
185
+ if (firstProduct) {
186
+ resolvedItemCode = firstProduct.itemCode;
187
+ itemCodeUsed = true;
188
+ stockResult = await fetchGs25Stores({
189
+ serviceCode,
190
+ itemCode: resolvedItemCode,
191
+ realTimeStockYn: 'Y',
192
+ latitude,
193
+ longitude,
194
+ useCache: false,
195
+ }, {
196
+ timeout: 20000,
197
+ });
198
+ }
199
+ else {
200
+ stockResult = await fetchGs25Stores({
201
+ serviceCode,
202
+ keyword,
203
+ realTimeStockYn: 'Y',
204
+ latitude,
205
+ longitude,
206
+ useCache: false,
207
+ }, {
208
+ timeout: 20000,
209
+ });
210
+ }
211
+ }
212
+ const selected = selectGs25StoresForKeyword(stockResult.stores, storeKeyword, {
213
+ relaxWhenEmpty: typeof latitude === 'number' && typeof longitude === 'number',
214
+ });
215
+ const filtered = selected.stores;
216
+ const withDistance = attachDistanceToGs25Stores(filtered, latitude, longitude);
217
+ const stores = sortGs25Stores(withDistance).slice(0, storeLimit);
218
+ // 상품 정보: searchProducts에서 먼저 가져오고, 없으면 stores에서
219
+ const productName = firstProduct?.itemName || firstProduct?.shortItemName ||
220
+ filtered.find((item) => item.searchItemName.length > 0)?.searchItemName || null;
221
+ const productPrice = filtered.find((item) => item.searchItemSellPrice !== null)?.searchItemSellPrice ?? null;
222
+ const inStockStoreCount = filtered.filter((item) => item.realStockQuantity > 0).length;
223
+ const totalStockQuantity = filtered.reduce((sum, item) => sum + Math.max(item.realStockQuantity, 0), 0);
224
+ return successResponse(c, {
225
+ serviceCode,
226
+ keyword,
227
+ itemCodeUsed,
228
+ itemCode: resolvedItemCode,
229
+ storeKeyword,
230
+ geocodeUsed,
231
+ filterRelaxed: selected.filterRelaxed,
232
+ location: typeof latitude === 'number' && typeof longitude === 'number'
233
+ ? { latitude, longitude }
234
+ : null,
235
+ product: {
236
+ name: productName,
237
+ sellPrice: productPrice,
238
+ imageUrl: firstProduct?.imageUrl || null,
239
+ rating: firstProduct?.rating || null,
240
+ },
241
+ inventory: {
242
+ totalStoreCount: stockResult.totalCount,
243
+ matchedStoreCount: filtered.length,
244
+ inStockStoreCount,
245
+ totalStockQuantity,
246
+ count: stores.length,
247
+ stores,
248
+ },
249
+ });
250
+ }
251
+ catch (error) {
252
+ const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
253
+ return errorResponse(c, 'GS25_INVENTORY_CHECK_FAILED', message, 500);
254
+ }
255
+ }
256
+ /* c8 ignore stop */
@@ -4,122 +4,44 @@
4
4
  * MCP 미지원 서비스를 위한 GET 기반 API 핸들러입니다.
5
5
  * 기존 도구의 핵심 함수들을 재사용합니다.
6
6
  */
7
- import { fetchProducts } from '../services/daiso/tools/searchProducts.js';
8
- import { fetchStores } from '../services/daiso/tools/findStores.js';
9
- import { fetchOnlineStock, fetchStoreInventory, } from '../services/daiso/tools/checkInventory.js';
10
- import { fetchProductById } from '../services/daiso/tools/getPriceInfo.js';
11
- import { getImageUrl } from '../services/daiso/api.js';
12
- import { fetchOliveyoungProducts, fetchOliveyoungStores, } from '../services/oliveyoung/client.js';
7
+ import { enrichOliveyoungProductsWithNearbyStoreInventory, fetchOliveyoungProducts, fetchOliveyoungStores, } from '../services/oliveyoung/client.js';
8
+ import { fetchCuStock, fetchCuStores, geocodeCuAddress } from '../services/cu/client.js';
13
9
  import { errorResponse, successResponse } from './response.js';
10
+ export { handleCheckInventory, handleFindStores, handleGetDisplayLocation, handleGetProduct, handleSearchProducts, } from './daisoHandlers.js';
14
11
  /**
15
- * 제품 검색 API 핸들러
16
- * GET /api/daiso/products?q={검색어}&page={페이지}&pageSize={개수}
12
+ * 올리브영 상품 검색 API 핸들러
13
+ * GET /api/oliveyoung/products?keyword={검색어}
17
14
  */
18
- export async function handleSearchProducts(c) {
19
- const query = c.req.query('q');
20
- const page = parseInt(c.req.query('page') || '1');
21
- const pageSize = parseInt(c.req.query('pageSize') || '30');
22
- if (!query || query.trim().length === 0) {
23
- return errorResponse(c, 'MISSING_QUERY', '검색어(q)를 입력해주세요.');
24
- }
25
- try {
26
- const { products, totalCount } = await fetchProducts(query, page, pageSize);
27
- return successResponse(c, { products }, { total: totalCount, page, pageSize });
28
- }
29
- catch (error) {
30
- const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
31
- return errorResponse(c, 'SEARCH_FAILED', message, 500);
32
- }
33
- }
34
- /**
35
- * 제품 상세 정보 API 핸들러
36
- * GET /api/daiso/products/:id
37
- */
38
- export async function handleGetProduct(c) {
39
- const productId = c.req.param('id');
40
- if (!productId) {
41
- return errorResponse(c, 'MISSING_ID', '제품 ID가 필요합니다.');
42
- }
43
- try {
44
- const product = await fetchProductById(productId);
45
- if (!product) {
46
- return errorResponse(c, 'NOT_FOUND', '제품을 찾을 수 없습니다.', 404);
47
- }
48
- const result = {
49
- id: product.PD_NO,
50
- name: product.PDNM || product.EXH_PD_NM,
51
- price: parseInt(product.PD_PRC) || 0,
52
- currency: 'KRW',
53
- imageUrl: getImageUrl(product.ATCH_FILE_URL),
54
- brand: product.BRND_NM || undefined,
55
- soldOut: product.SOLD_OUT_YN === 'Y',
56
- isNew: product.NEW_PD_YN === 'Y',
57
- };
58
- return successResponse(c, result);
59
- }
60
- catch (error) {
61
- const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
62
- return errorResponse(c, 'FETCH_FAILED', message, 500);
63
- }
64
- }
65
- /**
66
- * 매장 검색 API 핸들러
67
- * GET /api/daiso/stores?keyword={키워드}&sido={시도}&gugun={구군}&dong={동}&limit={개수}
68
- */
69
- export async function handleFindStores(c) {
70
- const keyword = c.req.query('keyword');
71
- const sido = c.req.query('sido');
72
- const gugun = c.req.query('gugun');
73
- const dong = c.req.query('dong');
74
- const limit = parseInt(c.req.query('limit') || '50');
75
- if (!keyword && !sido) {
76
- return errorResponse(c, 'MISSING_PARAMS', '검색어(keyword) 또는 지역(sido)을 입력해주세요.');
77
- }
78
- try {
79
- const stores = await fetchStores(keyword, sido, gugun, dong);
80
- const limitedStores = stores.slice(0, limit);
81
- return successResponse(c, { stores: limitedStores }, { total: stores.length });
82
- }
83
- catch (error) {
84
- const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
85
- return errorResponse(c, 'SEARCH_FAILED', message, 500);
86
- }
87
- }
88
- /**
89
- * 재고 확인 API 핸들러
90
- * GET /api/daiso/inventory?productId={제품ID}&lat={위도}&lng={경도}&keyword={매장검색어}
91
- */
92
- export async function handleCheckInventory(c) {
93
- const productId = c.req.query('productId');
94
- const lat = parseFloat(c.req.query('lat') || '37.5665');
95
- const lng = parseFloat(c.req.query('lng') || '126.978');
15
+ export async function handleOliveyoungSearchProducts(c) {
96
16
  const keyword = c.req.query('keyword') || '';
97
17
  const page = parseInt(c.req.query('page') || '1');
98
- const pageSize = parseInt(c.req.query('pageSize') || '30');
99
- if (!productId) {
100
- return errorResponse(c, 'MISSING_PRODUCT_ID', '제품 ID(productId)를 입력해주세요.');
18
+ const size = parseInt(c.req.query('size') || '20');
19
+ const sort = c.req.query('sort') || '01';
20
+ const includeSoldOut = c.req.query('includeSoldOut') === 'true';
21
+ if (keyword.trim().length === 0) {
22
+ return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.');
101
23
  }
102
24
  try {
103
- const [onlineStock, storeResult] = await Promise.all([
104
- fetchOnlineStock(productId),
105
- fetchStoreInventory(productId, lat, lng, page, pageSize, keyword),
106
- ]);
107
- const inStockStores = storeResult.stores.filter((s) => s.quantity > 0);
108
- const result = {
109
- productId,
110
- location: { latitude: lat, longitude: lng },
111
- onlineStock,
112
- storeInventory: {
113
- totalStores: storeResult.totalCount,
114
- inStockCount: inStockStores.length,
115
- stores: storeResult.stores,
116
- },
117
- };
118
- return successResponse(c, result, { total: storeResult.totalCount, page, pageSize });
25
+ const result = await fetchOliveyoungProducts({
26
+ keyword,
27
+ page,
28
+ size,
29
+ sort,
30
+ includeSoldOut,
31
+ }, {
32
+ apiKey: c.env?.ZYTE_API_KEY,
33
+ });
34
+ return successResponse(c, {
35
+ keyword,
36
+ totalCount: result.totalCount,
37
+ nextPage: result.nextPage,
38
+ count: result.products.length,
39
+ products: result.products,
40
+ }, { total: result.totalCount, page, pageSize: size });
119
41
  }
120
42
  catch (error) {
121
43
  const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
122
- return errorResponse(c, 'INVENTORY_CHECK_FAILED', message, 500);
44
+ return errorResponse(c, 'OLIVEYOUNG_PRODUCT_SEARCH_FAILED', message, 500);
123
45
  }
124
46
  }
125
47
  /**
@@ -139,7 +61,7 @@ export async function handleOliveyoungFindStores(c) {
139
61
  pageIdx,
140
62
  searchWords: keyword,
141
63
  }, {
142
- apiKey: c.env.ZYTE_API_KEY,
64
+ apiKey: c.env?.ZYTE_API_KEY,
143
65
  });
144
66
  return successResponse(c, {
145
67
  stores: result.stores.slice(0, limit),
@@ -187,6 +109,15 @@ export async function handleOliveyoungCheckInventory(c) {
187
109
  apiKey: c.env.ZYTE_API_KEY,
188
110
  }),
189
111
  ]);
112
+ const enrichedInventory = await enrichOliveyoungProductsWithNearbyStoreInventory(productResult.products, {
113
+ latitude: lat,
114
+ longitude: lng,
115
+ storeKeyword,
116
+ maxProducts: Math.min(productResult.products.length, 5),
117
+ }, {
118
+ apiKey: c.env.ZYTE_API_KEY,
119
+ });
120
+ const inStockCount = enrichedInventory.products.filter((product) => product.inStock).length;
190
121
  return successResponse(c, {
191
122
  keyword,
192
123
  location: { latitude: lat, longitude: lng },
@@ -197,7 +128,11 @@ export async function handleOliveyoungCheckInventory(c) {
197
128
  inventory: {
198
129
  totalCount: productResult.totalCount,
199
130
  nextPage: productResult.nextPage,
200
- products: productResult.products,
131
+ stockCheckedCount: enrichedInventory.checkedCount,
132
+ stockUncheckedCount: Math.max(0, productResult.products.length - enrichedInventory.checkedCount),
133
+ inStockCount,
134
+ outOfStockCount: enrichedInventory.products.length - inStockCount,
135
+ products: enrichedInventory.products,
201
136
  },
202
137
  }, { total: productResult.totalCount, page, pageSize: size });
203
138
  }
@@ -206,3 +141,144 @@ export async function handleOliveyoungCheckInventory(c) {
206
141
  return errorResponse(c, 'OLIVEYOUNG_INVENTORY_CHECK_FAILED', message, 500);
207
142
  }
208
143
  }
144
+ /**
145
+ * CU 매장 검색 API 핸들러
146
+ * GET /api/cu/stores?keyword={키워드}&lat={위도}&lng={경도}
147
+ */
148
+ export async function handleCuFindStores(c) {
149
+ const keyword = c.req.query('keyword') || '';
150
+ const rawLat = c.req.query('lat');
151
+ const rawLng = c.req.query('lng');
152
+ const limit = parseInt(c.req.query('limit') || '20');
153
+ const parsedLat = rawLat ? parseFloat(rawLat) : undefined;
154
+ const parsedLng = rawLng ? parseFloat(rawLng) : undefined;
155
+ const lat = typeof parsedLat === 'number' && Number.isFinite(parsedLat) ? parsedLat : undefined;
156
+ const lng = typeof parsedLng === 'number' && Number.isFinite(parsedLng) ? parsedLng : undefined;
157
+ try {
158
+ const result = await fetchCuStores({
159
+ latitude: lat,
160
+ longitude: lng,
161
+ searchWord: keyword,
162
+ }, {
163
+ timeout: 15000,
164
+ apiKey: c.env.ZYTE_API_KEY,
165
+ });
166
+ return successResponse(c, {
167
+ location: typeof lat === 'number' && typeof lng === 'number' ? { latitude: lat, longitude: lng } : null,
168
+ keyword,
169
+ stores: result.stores.slice(0, limit),
170
+ }, { total: result.totalCount, pageSize: limit });
171
+ }
172
+ catch (error) {
173
+ const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
174
+ return errorResponse(c, 'CU_STORE_SEARCH_FAILED', message, 500);
175
+ }
176
+ }
177
+ /**
178
+ * CU 재고 검색 API 핸들러
179
+ * GET /api/cu/inventory?keyword={검색어}&lat={위도}&lng={경도}
180
+ */
181
+ export async function handleCuCheckInventory(c) {
182
+ const keyword = c.req.query('keyword');
183
+ const storeKeyword = c.req.query('storeKeyword') || '';
184
+ const rawLat = c.req.query('lat');
185
+ const rawLng = c.req.query('lng');
186
+ const parsedLat = rawLat ? parseFloat(rawLat) : undefined;
187
+ const parsedLng = rawLng ? parseFloat(rawLng) : undefined;
188
+ const lat = typeof parsedLat === 'number' && Number.isFinite(parsedLat) ? parsedLat : undefined;
189
+ const lng = typeof parsedLng === 'number' && Number.isFinite(parsedLng) ? parsedLng : undefined;
190
+ const size = parseInt(c.req.query('size') || '20');
191
+ const offset = parseInt(c.req.query('offset') || '0');
192
+ const searchSort = c.req.query('searchSort') || 'recom';
193
+ const storeLimit = parseInt(c.req.query('storeLimit') || '10');
194
+ if (!keyword || keyword.trim().length === 0) {
195
+ return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.');
196
+ }
197
+ try {
198
+ const stockResult = await fetchCuStock({
199
+ keyword,
200
+ limit: size,
201
+ offset,
202
+ searchSort,
203
+ }, {
204
+ timeout: 15000,
205
+ });
206
+ const firstStockItem = stockResult.items.find((item) => item.itemCode.trim().length > 0) || null;
207
+ const hasInputLocation = typeof lat === 'number' && typeof lng === 'number';
208
+ const resolvedLat = hasInputLocation ? lat : undefined;
209
+ const resolvedLng = hasInputLocation ? lng : undefined;
210
+ let storeResult = null;
211
+ // 좌표 미입력 + 매장 키워드 입력 시, 키워드 기반 매장 검색 결과를 우선 사용합니다.
212
+ if (!hasInputLocation && storeKeyword.trim().length > 0) {
213
+ const keywordStoreResult = await fetchCuStores({
214
+ searchWord: storeKeyword,
215
+ }, {
216
+ timeout: 15000,
217
+ apiKey: c.env?.ZYTE_API_KEY,
218
+ });
219
+ const firstAddress = keywordStoreResult.stores.find((store) => store.address.trim().length > 0)?.address || '';
220
+ if (firstAddress.length > 0) {
221
+ const geocoded = await geocodeCuAddress(firstAddress, {
222
+ timeout: 15000,
223
+ googleMapsApiKey: c.env?.GOOGLE_MAPS_API_KEY,
224
+ });
225
+ if (geocoded) {
226
+ const hasStockSeed = !!firstStockItem?.itemCode;
227
+ storeResult = await fetchCuStores({
228
+ latitude: geocoded.latitude,
229
+ longitude: geocoded.longitude,
230
+ searchWord: storeKeyword,
231
+ itemCd: firstStockItem?.itemCode || '',
232
+ onItemNo: firstStockItem?.onItemNo || '',
233
+ jipCd: firstStockItem?.itemCode || '',
234
+ isRecommend: hasStockSeed ? 'Y' : '',
235
+ recommendId: hasStockSeed ? 'stock' : '',
236
+ pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve',
237
+ }, {
238
+ timeout: 15000,
239
+ apiKey: c.env?.ZYTE_API_KEY,
240
+ });
241
+ }
242
+ }
243
+ storeResult ||= keywordStoreResult;
244
+ }
245
+ if (!storeResult) {
246
+ const hasStockSeed = !!firstStockItem?.itemCode;
247
+ storeResult = await fetchCuStores({
248
+ latitude: resolvedLat,
249
+ longitude: resolvedLng,
250
+ searchWord: storeKeyword,
251
+ itemCd: firstStockItem?.itemCode || '',
252
+ onItemNo: firstStockItem?.onItemNo || '',
253
+ jipCd: firstStockItem?.itemCode || '',
254
+ isRecommend: hasStockSeed ? 'Y' : '',
255
+ recommendId: hasStockSeed ? 'stock' : '',
256
+ pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve',
257
+ }, {
258
+ timeout: 15000,
259
+ apiKey: c.env?.ZYTE_API_KEY,
260
+ });
261
+ }
262
+ return successResponse(c, {
263
+ keyword,
264
+ location: typeof resolvedLat === 'number' && typeof resolvedLng === 'number'
265
+ ? { latitude: resolvedLat, longitude: resolvedLng }
266
+ : null,
267
+ nearbyStores: {
268
+ totalCount: storeResult.totalCount,
269
+ stockItemCode: firstStockItem?.itemCode || null,
270
+ stockItemName: firstStockItem?.itemName || null,
271
+ stores: storeResult.stores.slice(0, storeLimit),
272
+ },
273
+ inventory: {
274
+ totalCount: stockResult.totalCount,
275
+ spellModifyYn: stockResult.spellModifyYn,
276
+ items: stockResult.items,
277
+ },
278
+ }, { total: stockResult.totalCount, page: Math.floor(offset / Math.max(size, 1)) + 1, pageSize: size });
279
+ }
280
+ catch (error) {
281
+ const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
282
+ return errorResponse(c, 'CU_INVENTORY_CHECK_FAILED', message, 500);
283
+ }
284
+ }