daiso 1.0.4 → 1.0.6

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 (166) hide show
  1. package/README.md +249 -533
  2. package/dist/api/actionsProxy.js +82 -0
  3. package/dist/api/cgvHandlers.js +103 -16
  4. package/dist/api/configStatus.js +19 -0
  5. package/dist/api/daisoHandlers.js +127 -0
  6. package/dist/api/emart24Handlers.js +176 -0
  7. package/dist/api/gs25Handlers.js +267 -0
  8. package/dist/api/handlers.js +209 -104
  9. package/dist/api/healthChecks.js +504 -0
  10. package/dist/api/lottecinemaHandlers.js +155 -0
  11. package/dist/api/lottemartHandlers.js +144 -0
  12. package/dist/api/megaboxHandlers.js +81 -50
  13. package/dist/api/response.js +2 -0
  14. package/dist/api/routes/cuRoutes.js +17 -0
  15. package/dist/api/routes/daisoRoutes.js +6 -1
  16. package/dist/api/routes/emart24Routes.js +22 -0
  17. package/dist/api/routes/gs25Routes.js +22 -0
  18. package/dist/api/routes/healthRoutes.js +61 -0
  19. package/dist/api/routes/lottecinemaRoutes.js +22 -0
  20. package/dist/api/routes/lottemartRoutes.js +18 -0
  21. package/dist/api/routes/oliveyoungRoutes.js +6 -1
  22. package/dist/api/routes/sevenelevenRoutes.js +32 -0
  23. package/dist/api/sevenelevenHandlers.js +200 -0
  24. package/dist/bin.js +0 -0
  25. package/dist/cli/args.js +75 -0
  26. package/dist/cli/commandOptions.js +38 -0
  27. package/dist/cli/commands/convenience.js +293 -0
  28. package/dist/cli/commands/daiso.js +112 -0
  29. package/dist/cli/constants.js +5 -0
  30. package/dist/cli/deps.js +48 -0
  31. package/dist/cli/http.js +156 -0
  32. package/dist/cli/interactiveFetch.js +31 -0
  33. package/dist/cli/interactiveItemSearch.js +261 -0
  34. package/dist/cli/interactivePrompt.js +50 -0
  35. package/dist/cli/interactiveTypes.js +4 -0
  36. package/dist/cli/types.js +4 -0
  37. package/dist/cli.js +87 -196
  38. package/dist/cliHelp.js +212 -1
  39. package/dist/cliInteractive.js +243 -0
  40. package/dist/cliPicker.js +57 -0
  41. package/dist/cliRenderer.js +90 -3
  42. package/dist/core/errors.js +66 -0
  43. package/dist/core/registry.js +227 -3
  44. package/dist/index.js +149 -9
  45. package/dist/pages/openapi.js +2 -1
  46. package/dist/pages/openapiFullSpec.js +58 -0
  47. package/dist/pages/openapiResponse.js +28 -0
  48. package/dist/pages/openapiSpec.js +2 -41
  49. package/dist/pages/openapiSpecActions.js +109 -0
  50. package/dist/pages/openapiSpecComponents.js +30 -391
  51. package/dist/pages/openapiSpecComponentsCgv.js +24 -0
  52. package/dist/pages/openapiSpecComponentsCu.js +112 -0
  53. package/dist/pages/openapiSpecComponentsDaisoOliveyoung.js +342 -0
  54. package/dist/pages/openapiSpecComponentsEmart24.js +141 -0
  55. package/dist/pages/openapiSpecComponentsGs25.js +109 -0
  56. package/dist/pages/openapiSpecComponentsLotteMart.js +85 -0
  57. package/dist/pages/openapiSpecComponentsLottecinema.js +163 -0
  58. package/dist/pages/openapiSpecComponentsMegabox.js +144 -0
  59. package/dist/pages/openapiSpecComponentsSeveneleven.js +82 -0
  60. package/dist/pages/openapiSpecPathsCgv.js +66 -3
  61. package/dist/pages/openapiSpecPathsCu.js +145 -0
  62. package/dist/pages/openapiSpecPathsDaiso.js +302 -0
  63. package/dist/pages/openapiSpecPathsDaisoOliveyoung.js +4 -376
  64. package/dist/pages/openapiSpecPathsEmart24.js +223 -0
  65. package/dist/pages/openapiSpecPathsGs25.js +166 -0
  66. package/dist/pages/openapiSpecPathsLotteMart.js +149 -0
  67. package/dist/pages/openapiSpecPathsLottecinema.js +212 -0
  68. package/dist/pages/openapiSpecPathsMegabox.js +52 -3
  69. package/dist/pages/openapiSpecPathsOliveyoung.js +266 -0
  70. package/dist/pages/openapiSpecPathsSeveneleven.js +234 -0
  71. package/dist/pages/prompt.js +8 -402
  72. package/dist/pages/promptCoreText.js +278 -0
  73. package/dist/pages/promptReferenceText.js +137 -0
  74. package/dist/pages/promptRetailCinemaText.js +274 -0
  75. package/dist/services/cgv/client.js +6 -1
  76. package/dist/services/cgv/index.js +3 -3
  77. package/dist/services/cgv/location.js +235 -0
  78. package/dist/services/cgv/tools/findTheaters.js +30 -5
  79. package/dist/services/cgv/tools/getTimetable.js +42 -14
  80. package/dist/services/cgv/tools/searchMovies.js +40 -12
  81. package/dist/services/cgv/transport.js +0 -3
  82. package/dist/services/cu/api.js +11 -0
  83. package/dist/services/cu/client.js +297 -0
  84. package/dist/services/cu/index.js +21 -0
  85. package/dist/services/cu/tools/checkInventory.js +123 -0
  86. package/dist/services/cu/tools/findNearbyStores.js +49 -0
  87. package/dist/services/cu/types.js +4 -0
  88. package/dist/services/daiso/api.js +22 -2
  89. package/dist/services/daiso/client.js +67 -0
  90. package/dist/services/daiso/index.js +4 -0
  91. package/dist/services/daiso/product.js +17 -0
  92. package/dist/services/daiso/tools/checkInventory.js +80 -43
  93. package/dist/services/daiso/tools/findInventoryByName.js +222 -0
  94. package/dist/services/daiso/tools/findStores.js +17 -7
  95. package/dist/services/daiso/tools/getDisplayLocation.js +75 -0
  96. package/dist/services/emart24/api.js +11 -0
  97. package/dist/services/emart24/client.js +182 -0
  98. package/dist/services/emart24/index.js +22 -0
  99. package/dist/services/emart24/inventoryLookup.js +134 -0
  100. package/dist/services/emart24/tools/checkInventory.js +54 -0
  101. package/dist/services/emart24/tools/findNearbyStores.js +72 -0
  102. package/dist/services/emart24/tools/searchProducts.js +73 -0
  103. package/dist/services/emart24/types.js +4 -0
  104. package/dist/services/gs25/api.js +9 -0
  105. package/dist/services/gs25/client.js +208 -0
  106. package/dist/services/gs25/index.js +22 -0
  107. package/dist/services/gs25/storeUtils.js +168 -0
  108. package/dist/services/gs25/tools/checkInventory.js +163 -0
  109. package/dist/services/gs25/tools/findNearbyStores.js +103 -0
  110. package/dist/services/gs25/tools/searchProducts.js +57 -0
  111. package/dist/services/gs25/types.js +4 -0
  112. package/dist/services/lottecinema/api.js +12 -0
  113. package/dist/services/lottecinema/client.js +161 -0
  114. package/dist/services/lottecinema/index.js +30 -0
  115. package/dist/services/lottecinema/location.js +191 -0
  116. package/dist/services/lottecinema/tools/findNearbyTheaters.js +47 -0
  117. package/dist/services/lottecinema/tools/getRemainingSeats.js +85 -0
  118. package/dist/services/lottecinema/tools/listNowShowing.js +79 -0
  119. package/dist/services/lottecinema/types.js +4 -0
  120. package/dist/services/lottemart/api.js +31 -0
  121. package/dist/services/lottemart/client.js +254 -0
  122. package/dist/services/lottemart/clientTypes.js +4 -0
  123. package/dist/services/lottemart/config.js +4 -0
  124. package/dist/services/lottemart/debug.js +92 -0
  125. package/dist/services/lottemart/geocode.js +49 -0
  126. package/dist/services/lottemart/index.js +30 -0
  127. package/dist/services/lottemart/keyword.js +29 -0
  128. package/dist/services/lottemart/parser.js +185 -0
  129. package/dist/services/lottemart/session.js +331 -0
  130. package/dist/services/lottemart/storeSearch.js +52 -0
  131. package/dist/services/lottemart/tools/findNearbyStores.js +67 -0
  132. package/dist/services/lottemart/tools/searchProducts.js +69 -0
  133. package/dist/services/lottemart/types.js +4 -0
  134. package/dist/services/lottemart/zetta.js +105 -0
  135. package/dist/services/megabox/location.js +219 -0
  136. package/dist/services/megabox/tools/findNearbyTheaters.js +13 -44
  137. package/dist/services/megabox/tools/getRemainingSeats.js +31 -3
  138. package/dist/services/megabox/tools/listNowShowing.js +31 -3
  139. package/dist/services/oliveyoung/api.js +2 -0
  140. package/dist/services/oliveyoung/client.js +225 -13
  141. package/dist/services/oliveyoung/index.js +3 -1
  142. package/dist/services/oliveyoung/tools/checkInventory.js +21 -5
  143. package/dist/services/oliveyoung/tools/searchProducts.js +57 -0
  144. package/dist/services/seveneleven/api.js +15 -0
  145. package/dist/services/seveneleven/client.js +286 -0
  146. package/dist/services/seveneleven/index.js +30 -0
  147. package/dist/services/seveneleven/inventory.js +302 -0
  148. package/dist/services/seveneleven/productKeyword.js +141 -0
  149. package/dist/services/seveneleven/tools/checkInventory.js +82 -0
  150. package/dist/services/seveneleven/tools/getCatalogSnapshot.js +55 -0
  151. package/dist/services/seveneleven/tools/getSearchPopwords.js +41 -0
  152. package/dist/services/seveneleven/tools/searchProducts.js +67 -0
  153. package/dist/services/seveneleven/tools/searchStores.js +45 -0
  154. package/dist/services/seveneleven/types.js +4 -0
  155. package/dist/utils/cliInteractiveHelpers.js +63 -0
  156. package/dist/utils/daisoKeyword.js +30 -0
  157. package/dist/utils/http.js +98 -12
  158. package/dist/utils/zyte.js +47 -26
  159. package/openapi.json +1979 -736
  160. package/openapi.yaml +1428 -541
  161. package/package.json +49 -18
  162. package/dist/tools/checkInventory.js +0 -98
  163. package/dist/tools/findStores.js +0 -125
  164. package/dist/tools/getPriceInfo.js +0 -77
  165. package/dist/tools/searchProducts.js +0 -61
  166. package/dist/types/index.js +0 -4
@@ -0,0 +1,254 @@
1
+ /**
2
+ * 롯데마트 모바일 도와센터 클라이언트
3
+ */
4
+ import { LOTTEMART_API } from './api.js';
5
+ import { DEFAULT_LOTTEMART_TIMEOUT_MS } from './config.js';
6
+ import { __testOnlyClearLotteMartGeocodeCache, geocodeLotteMartAddress } from './geocode.js';
7
+ import { __testOnlyClearLotteMartSessionCache, fetchLotteMartHtml, fetchLotteMartPageWithSession, getCachedLotteMartSessionCookie, } from './session.js';
8
+ import { attachDistance, fetchAllStoresForAreaList, fetchKeywordMatchedStores, getTargetAreas, normalizeArea, toDisplayArea, } from './storeSearch.js';
9
+ import { calculateDistanceM, dedupeProducts, matchesBrandVariant, matchesKeyword, parseMarketOptions, parseProductSummary, parseProducts, parseStores, sortStores, } from './parser.js';
10
+ import { createZettaFallbackStore, fetchZettaLotteMartProducts, fetchZettaLotteMartProductsWithPrimaryError } from './zetta.js';
11
+ const STORE_CACHE_TTL_MS = 30 * 60 * 1000;
12
+ const LOTTEMART_PRODUCT_LEGACY_TIMEOUT_MS = 7000;
13
+ const storeCache = new Map();
14
+ async function getLotteMartSessionCookie(options) {
15
+ if (options.sessionCookie) {
16
+ return options.sessionCookie;
17
+ }
18
+ return getCachedLotteMartSessionCookie(options.timeout || DEFAULT_LOTTEMART_TIMEOUT_MS);
19
+ }
20
+ export async function fetchLotteMartMarketOptions(area, type, options = {}) {
21
+ const normalizedArea = normalizeArea(area);
22
+ if (!normalizedArea) {
23
+ throw new Error(`지원하지 않는 지역입니다: ${area}`);
24
+ }
25
+ const endpoint = new URL(LOTTEMART_API.MARKET_OPTIONS_PATH, LOTTEMART_API.BASE_URL);
26
+ endpoint.searchParams.set('p_area', normalizedArea);
27
+ endpoint.searchParams.set('p_type', type);
28
+ const html = await fetchLotteMartHtml(endpoint.toString(), {
29
+ method: 'GET',
30
+ headers: {
31
+ Accept: 'text/html, */*; q=0.01',
32
+ 'X-Requested-With': 'XMLHttpRequest',
33
+ },
34
+ }, options.timeout || DEFAULT_LOTTEMART_TIMEOUT_MS, await getLotteMartSessionCookie(options), options.zyteApiKey);
35
+ return parseMarketOptions(toDisplayArea(normalizedArea), html);
36
+ }
37
+ export async function fetchLotteMartStoresByArea(area, options = {}) {
38
+ const normalizedArea = normalizeArea(area);
39
+ if (!normalizedArea) {
40
+ throw new Error(`지원하지 않는 지역입니다: ${area}`);
41
+ }
42
+ const cacheKey = normalizedArea;
43
+ const cached = storeCache.get(cacheKey);
44
+ if (cached && cached.expiresAt > Date.now()) {
45
+ return cached.stores;
46
+ }
47
+ const body = new URLSearchParams();
48
+ body.set('m_area', normalizedArea);
49
+ const html = await fetchLotteMartPageWithSession(LOTTEMART_API.STORE_SEARCH_PATH, {
50
+ method: 'POST',
51
+ headers: {
52
+ 'Content-Type': 'application/x-www-form-urlencoded',
53
+ },
54
+ body: body.toString(),
55
+ }, options.timeout || DEFAULT_LOTTEMART_TIMEOUT_MS, await getLotteMartSessionCookie(options), options.zyteApiKey);
56
+ const stores = parseStores(toDisplayArea(normalizedArea), html);
57
+ storeCache.set(cacheKey, {
58
+ expiresAt: Date.now() + STORE_CACHE_TTL_MS,
59
+ stores,
60
+ });
61
+ return stores;
62
+ }
63
+ async function fetchLotteMartStoresByAreaKeyword(area, keyword, options) {
64
+ const normalizedArea = normalizeArea(area);
65
+ const normalizedKeyword = keyword.trim();
66
+ const cacheKey = `${normalizedArea}:${normalizedKeyword}`;
67
+ const cached = storeCache.get(cacheKey);
68
+ if (cached && cached.expiresAt > Date.now()) {
69
+ return cached.stores;
70
+ }
71
+ const body = new URLSearchParams();
72
+ body.set('m_area', normalizedArea);
73
+ body.set('m_schWord', normalizedKeyword);
74
+ const html = await fetchLotteMartPageWithSession(LOTTEMART_API.STORE_SEARCH_PATH, {
75
+ method: 'POST',
76
+ headers: {
77
+ 'Content-Type': 'application/x-www-form-urlencoded',
78
+ },
79
+ body: body.toString(),
80
+ }, options.timeout, await getLotteMartSessionCookie(options), options.zyteApiKey);
81
+ const stores = parseStores(toDisplayArea(normalizedArea), html);
82
+ storeCache.set(cacheKey, {
83
+ expiresAt: Date.now() + STORE_CACHE_TTL_MS,
84
+ stores,
85
+ });
86
+ return stores;
87
+ }
88
+ export async function fetchLotteMartStores(params = {}, options = {}) {
89
+ const { area, keyword = '', brandVariant = '', latitude, longitude, limit = 20 } = params;
90
+ const { timeout = DEFAULT_LOTTEMART_TIMEOUT_MS, googleMapsApiKey } = options;
91
+ if (area && !normalizeArea(area)) {
92
+ throw new Error(`지원하지 않는 지역입니다: ${area}`);
93
+ }
94
+ let resolvedLatitude = latitude;
95
+ let resolvedLongitude = longitude;
96
+ let geocodeUsed = false;
97
+ if ((typeof resolvedLatitude !== 'number' || typeof resolvedLongitude !== 'number') &&
98
+ keyword.trim().length > 0) {
99
+ const geocoded = await geocodeLotteMartAddress(keyword, { timeout, googleMapsApiKey });
100
+ if (geocoded) {
101
+ resolvedLatitude = geocoded.latitude;
102
+ resolvedLongitude = geocoded.longitude;
103
+ geocodeUsed = true;
104
+ }
105
+ }
106
+ const sessionCookie = await getLotteMartSessionCookie({ timeout });
107
+ const targetAreas = getTargetAreas(area);
108
+ const hasKeyword = keyword.trim().length > 0;
109
+ const fetchAreaStores = (currentArea) => hasKeyword
110
+ ? fetchLotteMartStoresByAreaKeyword(currentArea, keyword, {
111
+ timeout,
112
+ sessionCookie,
113
+ zyteApiKey: options.zyteApiKey,
114
+ })
115
+ : fetchLotteMartStoresByArea(currentArea, { timeout, sessionCookie, zyteApiKey: options.zyteApiKey });
116
+ const keywordMatchedStores = hasKeyword
117
+ ? await fetchKeywordMatchedStores(targetAreas, keyword, brandVariant, limit, fetchAreaStores)
118
+ : [];
119
+ const useKeywordMatchedStores = keywordMatchedStores.length > 0 || hasKeyword;
120
+ const stores = useKeywordMatchedStores
121
+ ? keywordMatchedStores
122
+ : await fetchAllStoresForAreaList(targetAreas, fetchAreaStores);
123
+ const filtered = attachDistance(useKeywordMatchedStores
124
+ ? stores
125
+ : stores
126
+ .filter((store) => matchesKeyword(store, keyword))
127
+ .filter((store) => matchesBrandVariant(store, brandVariant)), resolvedLatitude, resolvedLongitude);
128
+ const enriched = await Promise.all(filtered.map(async (store) => {
129
+ if (typeof resolvedLatitude !== 'number' ||
130
+ typeof resolvedLongitude !== 'number' ||
131
+ store.latitude !== 0 ||
132
+ store.longitude !== 0) {
133
+ return store;
134
+ }
135
+ const geocoded = await geocodeLotteMartAddress(store.address, { timeout, googleMapsApiKey });
136
+ if (!geocoded) {
137
+ return store;
138
+ }
139
+ return {
140
+ ...store,
141
+ latitude: geocoded.latitude,
142
+ longitude: geocoded.longitude,
143
+ distanceM: calculateDistanceM(resolvedLatitude, resolvedLongitude, geocoded.latitude, geocoded.longitude),
144
+ };
145
+ }));
146
+ return {
147
+ stores: sortStores(enriched).slice(0, limit),
148
+ location: typeof resolvedLatitude === 'number' && typeof resolvedLongitude === 'number'
149
+ ? { latitude: resolvedLatitude, longitude: resolvedLongitude }
150
+ : null,
151
+ geocodeUsed,
152
+ };
153
+ }
154
+ export async function resolveLotteMartStore(area, storeCode, storeName, options = {}) {
155
+ const normalizedStoreCode = (storeCode || '').trim();
156
+ if (normalizedStoreCode.length > 0) {
157
+ for (const currentArea of getTargetAreas(area)) {
158
+ const matched = (await fetchLotteMartMarketOptions(currentArea, '1', options)).find((item) => item.storeCode === normalizedStoreCode);
159
+ if (matched) {
160
+ return matched;
161
+ }
162
+ }
163
+ return null;
164
+ }
165
+ const normalizedStoreName = (storeName || '').trim().toLowerCase();
166
+ if (normalizedStoreName.length === 0) {
167
+ return null;
168
+ }
169
+ const allOptions = (await Promise.all(getTargetAreas(area).map((currentArea) => fetchLotteMartMarketOptions(currentArea, '1', options)))).flat();
170
+ return (allOptions.find((item) => item.storeName.toLowerCase() === normalizedStoreName) ||
171
+ allOptions.find((item) => item.storeName.toLowerCase().includes(normalizedStoreName)) ||
172
+ null);
173
+ }
174
+ export async function searchLotteMartProducts(params, options = {}) {
175
+ const normalizedKeyword = params.keyword.trim();
176
+ if (normalizedKeyword.length === 0) {
177
+ throw new Error('상품 검색어(keyword)를 입력해주세요.');
178
+ }
179
+ const timeout = options.timeout || DEFAULT_LOTTEMART_TIMEOUT_MS;
180
+ if (params.source === 'zetta') {
181
+ return fetchZettaLotteMartProducts(createZettaFallbackStore(params), normalizedKeyword, params.pageLimit || 3, timeout);
182
+ }
183
+ const sessionCookie = await getLotteMartSessionCookie({ timeout: LOTTEMART_PRODUCT_LEGACY_TIMEOUT_MS });
184
+ let resolvedStore;
185
+ try {
186
+ resolvedStore = await resolveLotteMartStore(params.area, params.storeCode, params.storeName, {
187
+ timeout: LOTTEMART_PRODUCT_LEGACY_TIMEOUT_MS,
188
+ sessionCookie,
189
+ zyteApiKey: options.zyteApiKey,
190
+ });
191
+ }
192
+ catch (error) {
193
+ if (params.source === 'legacy') {
194
+ throw error;
195
+ }
196
+ const fallbackStore = createZettaFallbackStore(params);
197
+ return fetchZettaLotteMartProductsWithPrimaryError(fallbackStore, normalizedKeyword, params.pageLimit || 3, timeout, error);
198
+ }
199
+ if (!resolvedStore) {
200
+ throw new Error('검색할 롯데마트 매장을 찾지 못했습니다. area와 storeCode/storeName을 확인해주세요.');
201
+ }
202
+ const initialBody = new URLSearchParams();
203
+ initialBody.set('p_area', normalizeArea(resolvedStore.area));
204
+ initialBody.set('p_market', resolvedStore.storeCode);
205
+ initialBody.set('p_schWord', normalizedKeyword);
206
+ let initialHtml;
207
+ try {
208
+ initialHtml = await fetchLotteMartPageWithSession(LOTTEMART_API.PRODUCT_SEARCH_PATH, {
209
+ method: 'POST',
210
+ headers: {
211
+ 'Content-Type': 'application/x-www-form-urlencoded',
212
+ },
213
+ body: initialBody.toString(),
214
+ }, LOTTEMART_PRODUCT_LEGACY_TIMEOUT_MS, sessionCookie, options.zyteApiKey);
215
+ }
216
+ catch (error) {
217
+ if (params.source === 'legacy') {
218
+ throw error;
219
+ }
220
+ return fetchZettaLotteMartProductsWithPrimaryError(resolvedStore, normalizedKeyword, params.pageLimit || 3, timeout, error);
221
+ }
222
+ const pagedSessionCookie = await getCachedLotteMartSessionCookie(LOTTEMART_PRODUCT_LEGACY_TIMEOUT_MS);
223
+ const summary = parseProductSummary(initialHtml);
224
+ const firstPageProducts = parseProducts(resolvedStore.area, resolvedStore.storeCode, resolvedStore.storeName, normalizedKeyword, initialHtml);
225
+ const maxPage = Math.min(Math.max(params.pageLimit || 3, 1), Math.max(summary.totalPages, 1));
226
+ const pageHtmlList = await Promise.all(Array.from({ length: Math.max(maxPage - 1, 0) }, (_, index) => index + 2).map((page) => {
227
+ const endpoint = new URL(LOTTEMART_API.PRODUCT_PAGE_PATH, LOTTEMART_API.BASE_URL);
228
+ endpoint.searchParams.set('p_market', resolvedStore.storeCode);
229
+ endpoint.searchParams.set('p_schWord', normalizedKeyword);
230
+ endpoint.searchParams.set('page', String(page));
231
+ return fetchLotteMartHtml(endpoint.toString(), {
232
+ method: 'GET',
233
+ headers: {
234
+ Accept: 'text/html, */*; q=0.01',
235
+ 'X-Requested-With': 'XMLHttpRequest',
236
+ },
237
+ }, LOTTEMART_PRODUCT_LEGACY_TIMEOUT_MS, pagedSessionCookie, options.zyteApiKey);
238
+ }));
239
+ const pagedProducts = pageHtmlList.flatMap((html) => parseProducts(resolvedStore.area, resolvedStore.storeCode, resolvedStore.storeName, normalizedKeyword, html));
240
+ return {
241
+ area: resolvedStore.area,
242
+ storeCode: resolvedStore.storeCode,
243
+ storeName: resolvedStore.storeName,
244
+ totalCount: summary.totalCount,
245
+ totalPages: summary.totalPages,
246
+ products: dedupeProducts([...firstPageProducts, ...pagedProducts]),
247
+ };
248
+ }
249
+ export { calculateDistanceM, geocodeLotteMartAddress };
250
+ export function __testOnlyClearLotteMartCaches() {
251
+ storeCache.clear();
252
+ __testOnlyClearLotteMartGeocodeCache();
253
+ __testOnlyClearLotteMartSessionCache();
254
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * 롯데마트 클라이언트 입력 타입
3
+ */
4
+ export {};
@@ -0,0 +1,4 @@
1
+ /**
2
+ * 롯데마트 서비스 공통 설정
3
+ */
4
+ export const DEFAULT_LOTTEMART_TIMEOUT_MS = 45000;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 롯데마트 업스트림 단독 진단
3
+ */
4
+ import { LOTTEMART_API } from './api.js';
5
+ import { DEFAULT_LOTTEMART_TIMEOUT_MS } from './config.js';
6
+ import { probeLotteMartRequest } from './session.js';
7
+ import { normalizeArea } from './storeSearch.js';
8
+ function resolveArea(area) {
9
+ const normalized = normalizeArea(area || '경기');
10
+ return normalized || '경기';
11
+ }
12
+ export function buildLotteMartDebugRequest(params) {
13
+ const target = params.target;
14
+ const area = resolveArea(params.area);
15
+ switch (target) {
16
+ case 'market-options': {
17
+ const endpoint = new URL(LOTTEMART_API.MARKET_OPTIONS_PATH, LOTTEMART_API.BASE_URL);
18
+ endpoint.searchParams.set('p_area', area);
19
+ endpoint.searchParams.set('p_type', params.type || '1');
20
+ if ((params.storeCode || '').trim().length > 0) {
21
+ endpoint.searchParams.set('p_werks', params.storeCode.trim());
22
+ }
23
+ return {
24
+ target,
25
+ method: 'GET',
26
+ url: endpoint.toString(),
27
+ bodyText: null,
28
+ };
29
+ }
30
+ case 'stores':
31
+ return {
32
+ target,
33
+ method: 'POST',
34
+ url: new URL(LOTTEMART_API.STORE_SEARCH_PATH, LOTTEMART_API.BASE_URL).toString(),
35
+ bodyText: new URLSearchParams({
36
+ m_area: area,
37
+ ...(params.storeCode ? { m_market: params.storeCode.trim() } : {}),
38
+ ...(params.keyword ? { m_schWord: params.keyword.trim() } : {}),
39
+ }).toString(),
40
+ };
41
+ case 'products':
42
+ return {
43
+ target,
44
+ method: 'POST',
45
+ url: new URL(LOTTEMART_API.PRODUCT_SEARCH_PATH, LOTTEMART_API.BASE_URL).toString(),
46
+ bodyText: new URLSearchParams({
47
+ p_area: area,
48
+ p_market: (params.storeCode || '2415').trim(),
49
+ p_schWord: (params.keyword || '핫식스').trim(),
50
+ }).toString(),
51
+ };
52
+ case 'product-page': {
53
+ const endpoint = new URL(LOTTEMART_API.PRODUCT_PAGE_PATH, LOTTEMART_API.BASE_URL);
54
+ endpoint.searchParams.set('p_market', (params.storeCode || '2415').trim());
55
+ endpoint.searchParams.set('p_schWord', (params.keyword || '핫식스').trim());
56
+ endpoint.searchParams.set('page', String(Math.max(params.page || 2, 1)));
57
+ return {
58
+ target,
59
+ method: 'GET',
60
+ url: endpoint.toString(),
61
+ bodyText: null,
62
+ };
63
+ }
64
+ }
65
+ }
66
+ export async function probeLotteMartUpstream(params) {
67
+ const request = buildLotteMartDebugRequest(params);
68
+ const timeout = params.timeout || DEFAULT_LOTTEMART_TIMEOUT_MS;
69
+ const init = {
70
+ method: request.method,
71
+ headers: request.method === 'POST'
72
+ ? {
73
+ 'Content-Type': 'application/x-www-form-urlencoded',
74
+ }
75
+ : {
76
+ Accept: 'text/html, */*; q=0.01',
77
+ 'X-Requested-With': 'XMLHttpRequest',
78
+ },
79
+ body: request.bodyText || undefined,
80
+ };
81
+ return {
82
+ request: {
83
+ target: request.target,
84
+ method: request.method,
85
+ url: request.url,
86
+ bodyText: request.bodyText,
87
+ timeout,
88
+ hasZyteApiKey: Boolean(params.zyteApiKey),
89
+ },
90
+ attempts: await probeLotteMartRequest(request.url, init, timeout, '', params.zyteApiKey),
91
+ };
92
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 롯데마트 매장 주소 지오코딩
3
+ */
4
+ import { fetchJson } from '../../utils/http.js';
5
+ import { DEFAULT_LOTTEMART_TIMEOUT_MS } from './config.js';
6
+ const GEOCODE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
7
+ const geocodeCache = new Map();
8
+ export async function geocodeLotteMartAddress(address, options = {}) {
9
+ const keyword = address.trim();
10
+ if (keyword.length === 0) {
11
+ return null;
12
+ }
13
+ const cached = geocodeCache.get(keyword);
14
+ if (cached && cached.expiresAt > Date.now()) {
15
+ return cached.value;
16
+ }
17
+ const apiKey = (options.googleMapsApiKey || '').trim();
18
+ if (apiKey.length === 0) {
19
+ return null;
20
+ }
21
+ const endpoint = new URL('https://maps.googleapis.com/maps/api/geocode/json');
22
+ endpoint.searchParams.set('address', keyword);
23
+ endpoint.searchParams.set('key', apiKey);
24
+ const body = await fetchJson(endpoint.toString(), {
25
+ method: 'GET',
26
+ timeout: options.timeout || DEFAULT_LOTTEMART_TIMEOUT_MS,
27
+ headers: {
28
+ Accept: 'application/json',
29
+ },
30
+ });
31
+ if (body.status !== 'OK') {
32
+ geocodeCache.set(keyword, {
33
+ expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS,
34
+ value: null,
35
+ });
36
+ return null;
37
+ }
38
+ const latitude = body.results?.[0]?.geometry?.location?.lat;
39
+ const longitude = body.results?.[0]?.geometry?.location?.lng;
40
+ const value = typeof latitude === 'number' && typeof longitude === 'number' ? { latitude, longitude } : null;
41
+ geocodeCache.set(keyword, {
42
+ expiresAt: Date.now() + GEOCODE_CACHE_TTL_MS,
43
+ value,
44
+ });
45
+ return value;
46
+ }
47
+ export function __testOnlyClearLotteMartGeocodeCache() {
48
+ geocodeCache.clear();
49
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 롯데마트 서비스 프로바이더
3
+ */
4
+ import { createFindNearbyStoresTool } from './tools/findNearbyStores.js';
5
+ import { createSearchProductsTool } from './tools/searchProducts.js';
6
+ const LOTTEMART_METADATA = {
7
+ id: 'lottemart',
8
+ name: '롯데마트',
9
+ version: '1.0.0',
10
+ description: '롯데마트 계열 매장 탐색 및 매장별 상품 검색 서비스',
11
+ };
12
+ class LotteMartService {
13
+ googleMapsApiKey;
14
+ zyteApiKey;
15
+ metadata = LOTTEMART_METADATA;
16
+ constructor(googleMapsApiKey, zyteApiKey) {
17
+ this.googleMapsApiKey = googleMapsApiKey;
18
+ this.zyteApiKey = zyteApiKey;
19
+ }
20
+ getTools() {
21
+ return [
22
+ createFindNearbyStoresTool(this.googleMapsApiKey, this.zyteApiKey),
23
+ createSearchProductsTool(this.zyteApiKey),
24
+ ];
25
+ }
26
+ }
27
+ export function createLotteMartService(options = {}) {
28
+ return new LotteMartService(options.googleMapsApiKey, options.zyteApiKey);
29
+ }
30
+ export * from './types.js';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 롯데마트 매장 검색 키워드 보정
3
+ */
4
+ export function buildLotteMartKeywordVariants(rawKeyword) {
5
+ const base = rawKeyword.trim();
6
+ if (!base) {
7
+ return [];
8
+ }
9
+ const compact = base.replace(/\s+/g, '');
10
+ const withoutNoise = compact.replace(/(롯데마트|근처|주변)/g, '');
11
+ const tokens = base
12
+ .split(/\s+/)
13
+ .map((value) => value.trim())
14
+ .filter((value) => value.length > 0)
15
+ .filter((value) => !['롯데마트', '근처', '주변'].includes(value));
16
+ const normalizedTokens = tokens
17
+ .map((value) => value.replace(/역$/g, '').trim())
18
+ .filter((value) => value.length > 0);
19
+ const primaryToken = normalizedTokens[0] || tokens[0] || '';
20
+ const candidates = [
21
+ base,
22
+ compact,
23
+ withoutNoise,
24
+ primaryToken,
25
+ ...(tokens.length <= 1 ? tokens : []),
26
+ ...(normalizedTokens.length <= 1 ? normalizedTokens : []),
27
+ ];
28
+ return [...new Set(candidates.map((value) => value.trim()).filter((value) => value.length > 0))];
29
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * 롯데마트 HTML 파싱 유틸리티
3
+ */
4
+ import { buildLotteMartKeywordVariants } from './keyword.js';
5
+ export function detectBrandVariant(storeName) {
6
+ if (storeName.startsWith('토이저러스')) {
7
+ return 'toysrus';
8
+ }
9
+ if (storeName.startsWith('맥스')) {
10
+ return 'max';
11
+ }
12
+ if (storeName.startsWith('보틀벙커')) {
13
+ return 'bottlebunker';
14
+ }
15
+ if (storeName.startsWith('Mealguru')) {
16
+ return 'mealguru';
17
+ }
18
+ if (storeName.startsWith('그랑그로서리')) {
19
+ return 'grandgrocery';
20
+ }
21
+ return 'lottemart';
22
+ }
23
+ function decodeHtmlEntities(value) {
24
+ return value
25
+ .replace(/&nbsp;/gi, ' ')
26
+ .replace(/&lt;/gi, '<')
27
+ .replace(/&gt;/gi, '>')
28
+ .replace(/&quot;/gi, '"')
29
+ .replace(/&#39;/gi, "'")
30
+ .replace(/&#x2F;/gi, '/')
31
+ .replace(/&amp;/gi, '&');
32
+ }
33
+ function stripHtmlComments(value) {
34
+ let sanitized = '';
35
+ let index = 0;
36
+ while (index < value.length) {
37
+ if (value.startsWith('<!--', index)) {
38
+ const commentEnd = value.indexOf('-->', index + 4);
39
+ if (commentEnd < 0) {
40
+ break;
41
+ }
42
+ index = commentEnd + 3;
43
+ continue;
44
+ }
45
+ sanitized += value[index];
46
+ index += 1;
47
+ }
48
+ return sanitized.replaceAll('<!--', '').replaceAll('-->', '');
49
+ }
50
+ function stripTags(value) {
51
+ return stripHtmlComments(value).replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ' ');
52
+ }
53
+ function normalizeWhitespace(value) {
54
+ return decodeHtmlEntities(stripTags(value)).replace(/\s+/g, ' ').trim();
55
+ }
56
+ export function normalizeHtml(html) {
57
+ const trimmed = html.trim();
58
+ const htmlStart = trimmed.search(/<(?:!doctype|html|option|li|ul|section)\b/i);
59
+ return htmlStart >= 0 ? trimmed.slice(htmlStart) : trimmed;
60
+ }
61
+ function toNumber(value) {
62
+ const parsed = Number.parseFloat(value.replace(/[^\d.-]/g, ''));
63
+ return Number.isFinite(parsed) ? parsed : 0;
64
+ }
65
+ export function calculateDistanceM(lat1, lng1, lat2, lng2) {
66
+ const toRad = (deg) => (deg * Math.PI) / 180;
67
+ const dLat = toRad(lat2 - lat1);
68
+ const dLng = toRad(lng2 - lng1);
69
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
70
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
71
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
72
+ return Math.round(6371000 * c);
73
+ }
74
+ function splitTopLevelBlocks(html, markerClass) {
75
+ const pattern = new RegExp(`<li>\\s*<div class="${markerClass}">`, 'g');
76
+ const normalized = html.replace(/\r/g, '');
77
+ const indexes = Array.from(normalized.matchAll(pattern)).map((match) => match.index || 0);
78
+ return indexes.map((start, index) => normalized.slice(start, indexes[index + 1] || normalized.length));
79
+ }
80
+ function extractFirstMatch(pattern, value) {
81
+ return value.match(pattern)?.[1]?.trim() || '';
82
+ }
83
+ export function parseMarketOptions(area, html) {
84
+ return Array.from(normalizeHtml(html).matchAll(/<option value="([^"]*)"[^>]*>([^<]*)<\/option>/g))
85
+ .map((match) => ({
86
+ area,
87
+ storeCode: (match[1] || '').trim(),
88
+ storeName: normalizeWhitespace(match[2] || ''),
89
+ }))
90
+ .filter((item) => item.storeCode.length > 0 && item.storeName.length > 0)
91
+ .map((item) => ({
92
+ ...item,
93
+ brandVariant: detectBrandVariant(item.storeName),
94
+ }));
95
+ }
96
+ export function parseStores(area, html) {
97
+ return splitTopLevelBlocks(normalizeHtml(html), 'shop-tit')
98
+ .map((block) => {
99
+ const storeName = normalizeWhitespace(extractFirstMatch(/<div class="shop-tit">([\s\S]*?)<\/div>/, block));
100
+ const detailUrl = extractFirstMatch(/<a class="link" href="([^"]+)"/, block);
101
+ const storeCode = extractFirstMatch(/werks=([^'"]+)/, block) || extractFirstMatch(/goClick\('([^']+)'\)/, block);
102
+ return {
103
+ area,
104
+ storeCode,
105
+ storeName,
106
+ brandVariant: detectBrandVariant(storeName),
107
+ address: normalizeWhitespace(extractFirstMatch(/주소\s*:\s*<\/span>([\s\S]*?)<\/li>/, block)),
108
+ phone: normalizeWhitespace(extractFirstMatch(/상담전화\s*:\s*<\/span>([\s\S]*?)<\/li>/, block)),
109
+ openTime: normalizeWhitespace(extractFirstMatch(/영업시간\s*:\s*<\/span>([\s\S]*?)<\/li>/, block)),
110
+ closedDays: normalizeWhitespace(extractFirstMatch(/휴점일\s*:\s*<\/span>([\s\S]*?)<\/li>/, block)),
111
+ parkingType: normalizeWhitespace(extractFirstMatch(/주차정보\s*:\s*([^<]*)<\/span>/, block)),
112
+ parkingDetails: normalizeWhitespace(extractFirstMatch(/<div class="park-info">([\s\S]*?)<\/div>/, block)),
113
+ detailUrl: detailUrl.startsWith('.') ? `/mobiledowa/market/${detailUrl.replace(/^\.\//, '')}` : detailUrl,
114
+ latitude: 0,
115
+ longitude: 0,
116
+ distanceM: null,
117
+ };
118
+ })
119
+ .filter((store) => store.storeCode.length > 0 && store.storeName.length > 0);
120
+ }
121
+ export function parseProducts(area, storeCode, storeName, keyword, html) {
122
+ return splitTopLevelBlocks(normalizeHtml(html), 'prod-box')
123
+ .map((block) => {
124
+ const rawSpec = extractFirstMatch(/<div class="prod-count">([\s\S]*?)<\/div>/, block);
125
+ return {
126
+ area,
127
+ storeCode,
128
+ storeName,
129
+ keyword,
130
+ productName: normalizeWhitespace(extractFirstMatch(/<div class="prod-name">([\s\S]*?)<\/div>/, block)) ||
131
+ normalizeWhitespace(extractFirstMatch(/<div class="layer-head">([\s\S]*?)<\/div>/, block)),
132
+ barcode: normalizeWhitespace(extractFirstMatch(/<!--\s*([^>]+?)\s*-->/, rawSpec)),
133
+ spec: normalizeWhitespace(stripHtmlComments(rawSpec)),
134
+ manufacturer: normalizeWhitespace(extractFirstMatch(/제조사\s*:\s*<\/th>\s*<td>([\s\S]*?)<\/td>/, block)),
135
+ price: toNumber(extractFirstMatch(/가격\s*:\s*<\/th>\s*<td>([\s\S]*?)<\/td>/, block)),
136
+ stockQuantity: toNumber(extractFirstMatch(/재고\s*:\s*<\/th>\s*<td>([\s\S]*?)<\/td>/, block)),
137
+ };
138
+ })
139
+ .filter((product) => product.productName.length > 0);
140
+ }
141
+ export function parseProductSummary(html) {
142
+ const normalized = normalizeHtml(html);
143
+ const totalCount = toNumber(extractFirstMatch(/검색결과\s*:\s*<span>([\d,]+)<\/span>건/, normalized));
144
+ const totalPages = toNumber(extractFirstMatch(/var\s+totalPage\s*=\s*"(\d+)"/, normalized));
145
+ return {
146
+ totalCount,
147
+ totalPages: totalPages > 0 ? totalPages : 1,
148
+ };
149
+ }
150
+ export function dedupeProducts(products) {
151
+ return Array.from(new Map(products.map((product) => [
152
+ [product.productName, product.spec, product.manufacturer, product.price, product.stockQuantity].join('|'),
153
+ product,
154
+ ])).values());
155
+ }
156
+ export function matchesKeyword(store, keyword) {
157
+ const variants = buildLotteMartKeywordVariants(keyword).map((value) => value.toLowerCase());
158
+ if (variants.length === 0) {
159
+ return true;
160
+ }
161
+ const haystacks = [store.storeName, store.address, store.phone]
162
+ .map((value) => value.toLowerCase().replace(/\s+/g, ''))
163
+ .filter((value) => value.length > 0);
164
+ return variants.some((variant) => {
165
+ const normalizedVariant = variant.replace(/\s+/g, '');
166
+ return haystacks.some((value) => value.includes(normalizedVariant));
167
+ });
168
+ }
169
+ export function matchesBrandVariant(store, brandVariant) {
170
+ const normalized = (brandVariant || '').trim().toLowerCase();
171
+ return normalized.length === 0 ? true : store.brandVariant === normalized;
172
+ }
173
+ export function sortStores(stores) {
174
+ return [...stores].sort((a, b) => {
175
+ const distanceDelta = (a.distanceM ?? Number.MAX_SAFE_INTEGER) - (b.distanceM ?? Number.MAX_SAFE_INTEGER);
176
+ if (distanceDelta !== 0) {
177
+ return distanceDelta;
178
+ }
179
+ const areaDelta = a.area.localeCompare(b.area, 'ko');
180
+ if (areaDelta !== 0) {
181
+ return areaDelta;
182
+ }
183
+ return a.storeName.localeCompare(b.storeName, 'ko');
184
+ });
185
+ }