/ clis / coupang / utils.js
utils.js
  1  function itemKey(item) {
  2      return item.url || item.product_id || `${item.title}:${item.price ?? ''}`;
  3  }
  4  const ROCKET_PATTERNS = ['판매자로켓', '로켓프레시', '로켓와우', '로켓배송', '로켓직구'];
  5  const DELIVERY_TYPE_PATTERNS = ['무료배송', '일반배송'];
  6  const DELIVERY_PROMISE_PATTERNS = ['오늘도착', '내일도착', '새벽도착', '오늘출발'];
  7  const BADGE_ID_TO_ROCKET = {
  8      ROCKET: '로켓배송',
  9      ROCKET_MERCHANT: '판매자로켓',
 10      ROCKET_WOW: '로켓와우',
 11      WOW: '로켓와우',
 12      ROCKET_FRESH: '로켓프레시',
 13      FRESH: '로켓프레시',
 14      SELLER_ROCKET: '판매자로켓',
 15      ROCKET_JIKGU: '로켓직구',
 16      JIKGU: '로켓직구',
 17      COUPANG_GLOBAL: '로켓직구',
 18  };
 19  const BADGE_ID_TO_PROMISE = {
 20      DAWN: '새벽도착',
 21      EARLY_DAWN: '새벽도착',
 22      TOMORROW: '내일도착',
 23      TODAY: '오늘도착',
 24      SAME_DAY: '오늘도착',
 25      TODAY_SHIP: '오늘출발',
 26      TODAY_DISPATCH: '오늘출발',
 27  };
 28  function asString(value) {
 29      if (value == null)
 30          return '';
 31      return String(value).trim();
 32  }
 33  function toNumber(value) {
 34      if (typeof value === 'number' && Number.isFinite(value))
 35          return value;
 36      const text = asString(value).replace(/[^\d.]/g, '');
 37      if (!text)
 38          return null;
 39      const num = Number(text);
 40      return Number.isFinite(num) ? num : null;
 41  }
 42  function pickFirst(obj, paths) {
 43      for (const path of paths) {
 44          const parts = path.split('.');
 45          let current = obj;
 46          let ok = true;
 47          for (const part of parts) {
 48              if (!current || typeof current !== 'object' || !(part in current)) {
 49                  ok = false;
 50                  break;
 51              }
 52              current = current[part];
 53          }
 54          if (ok && current != null && asString(current) !== '')
 55              return current;
 56      }
 57      return null;
 58  }
 59  export function normalizeProductId(raw) {
 60      const text = asString(raw);
 61      if (!text)
 62          return '';
 63      const match = text.match(/\/vp\/products\/(\d+)/) || text.match(/\b(\d{6,})\b/);
 64      return match?.[1] ?? text;
 65  }
 66  export function canonicalizeProductUrl(rawUrl, productId) {
 67      const raw = asString(rawUrl);
 68      if (raw) {
 69          try {
 70              const url = new URL(raw.startsWith('http') ? raw : `https://www.coupang.com${raw}`);
 71              if (!url.hostname.includes('coupang.com'))
 72                  return '';
 73              const id = normalizeProductId(url.pathname) || normalizeProductId(productId);
 74              if (!id)
 75                  return url.toString();
 76              return `https://www.coupang.com/vp/products/${id}`;
 77          }
 78          catch {
 79              return '';
 80          }
 81      }
 82      const id = normalizeProductId(productId);
 83      return id ? `https://www.coupang.com/vp/products/${id}` : '';
 84  }
 85  function extractTokens(values) {
 86      return values
 87          .flatMap((value) => {
 88          const text = asString(value);
 89          if (!text)
 90              return [];
 91          return text.split(/[,\s|]+/);
 92      })
 93          .map((token) => token.trim().toUpperCase())
 94          .filter(Boolean);
 95  }
 96  function normalizeJoinedText(...values) {
 97      return values
 98          .map(asString)
 99          .filter(Boolean)
100          .join(' ')
101          .replace(/schema\.org\/[A-Za-z]+/gi, ' ')
102          .replace(/\s+/g, ' ')
103          .trim();
104  }
105  function normalizeRocket(...values) {
106      const tokens = extractTokens(values);
107      for (const token of tokens) {
108          if (BADGE_ID_TO_ROCKET[token])
109              return BADGE_ID_TO_ROCKET[token];
110      }
111      const text = normalizeJoinedText(...values);
112      if (!text)
113          return '';
114      if (/판매자\s*로켓/.test(text))
115          return '판매자로켓';
116      if (/로켓\s*프레시|새벽\s*도착\s*보장/.test(text))
117          return '로켓프레시';
118      if (/로켓\s*와우/.test(text))
119          return '로켓와우';
120      if (/로켓\s*직구|직구/.test(text))
121          return '로켓직구';
122      if (/로켓\s*배송/.test(text))
123          return '로켓배송';
124      return ROCKET_PATTERNS.find(pattern => text.includes(pattern)) ?? '';
125  }
126  function normalizeDeliveryType(...values) {
127      const text = normalizeJoinedText(...values);
128      if (!text)
129          return '';
130      if (/무료\s*배송/.test(text))
131          return '무료배송';
132      if (/일반\s*배송/.test(text))
133          return '일반배송';
134      return DELIVERY_TYPE_PATTERNS.find(pattern => text.includes(pattern)) ?? '';
135  }
136  function normalizeDeliveryPromise(...values) {
137      const tokens = extractTokens(values);
138      for (const token of tokens) {
139          if (BADGE_ID_TO_PROMISE[token])
140              return BADGE_ID_TO_PROMISE[token];
141      }
142      const text = normalizeJoinedText(...values);
143      if (!text)
144          return '';
145      if (/오늘\s*출발/.test(text))
146          return '오늘출발';
147      if (/오늘.*도착/.test(text))
148          return '오늘도착';
149      if (/새벽.*도착/.test(text))
150          return '새벽도착';
151      if (/내일.*도착/.test(text))
152          return '내일도착';
153      return DELIVERY_PROMISE_PATTERNS.find(pattern => text.includes(pattern)) ?? '';
154  }
155  function normalizeBadge(value) {
156      const normalizeOne = (entry) => {
157          const text = asString(entry);
158          if (!text)
159              return '';
160          if (/schema\.org\//i.test(text)) {
161              return text.split('/').pop() ?? '';
162          }
163          return text;
164      };
165      if (Array.isArray(value)) {
166          return value.map(normalizeOne).filter(Boolean).join(', ');
167      }
168      return normalizeOne(value);
169  }
170  export function normalizeSearchItem(raw, index) {
171      const productId = normalizeProductId(pickFirst(raw, ['productId', 'product_id', 'id', 'productNo', 'item.id', 'product.productId', 'url']));
172      const title = asString(pickFirst(raw, ['title', 'name', 'productName', 'productTitle', 'itemName', 'item.title']));
173      const price = toNumber(pickFirst(raw, ['price', 'salePrice', 'finalPrice', 'sellingPrice', 'discountPrice', 'item.price']));
174      const originalPrice = toNumber(pickFirst(raw, ['originalPrice', 'basePrice', 'listPrice', 'originPrice', 'strikePrice']));
175      const unitPrice = asString(pickFirst(raw, ['unitPrice', 'unit_price', 'unitPriceText']));
176      const rating = toNumber(pickFirst(raw, ['rating', 'star', 'reviewRating', 'review.rating', 'item.rating']));
177      const reviewCount = toNumber(pickFirst(raw, ['reviewCount', 'ratingCount', 'reviews', 'reviewCnt', 'item.reviewCount']));
178      const deliveryHintValues = [
179          pickFirst(raw, ['deliveryType', 'deliveryBadge', 'badgeLabel', 'shippingType', 'shippingBadge']),
180          pickFirst(raw, ['badge', 'badges', 'labels', 'benefitBadge', 'promotionBadge']),
181          pickFirst(raw, ['text', 'summary']),
182          pickFirst(raw, ['deliveryPromise', 'promise', 'arrivalText', 'arrivalBadge']),
183          pickFirst(raw, ['rocket', 'rocketType']),
184      ];
185      const deliveryType = normalizeDeliveryType(...deliveryHintValues);
186      const deliveryPromise = normalizeDeliveryPromise(...deliveryHintValues);
187      const rocket = normalizeRocket(...deliveryHintValues);
188      const badge = normalizeBadge(pickFirst(raw, ['badge', 'badges', 'labels', 'benefitBadge', 'promotionBadge']));
189      const category = asString(pickFirst(raw, ['category', 'categoryName', 'categoryPath', 'item.category']));
190      const seller = asString(pickFirst(raw, ['seller', 'sellerName', 'vendorName', 'merchantName', 'item.seller']));
191      const url = canonicalizeProductUrl(pickFirst(raw, ['url', 'productUrl', 'link', 'item.url']), productId);
192      const discountRate = toNumber(pickFirst(raw, ['discountRate', 'discount', 'discountPercent', 'discount_rate']));
193      return {
194          rank: index + 1,
195          product_id: productId,
196          title,
197          price,
198          original_price: originalPrice,
199          unit_price: unitPrice,
200          discount_rate: discountRate,
201          rating,
202          review_count: reviewCount,
203          rocket,
204          delivery_type: deliveryType,
205          delivery_promise: deliveryPromise,
206          seller,
207          badge,
208          category,
209          url,
210      };
211  }
212  export function dedupeSearchItems(items) {
213      const seen = new Set();
214      const out = [];
215      for (const item of items) {
216          const key = itemKey(item);
217          if (!key || seen.has(key))
218              continue;
219          seen.add(key);
220          out.push({ ...item, rank: out.length + 1 });
221      }
222      return out;
223  }
224  export function sanitizeSearchItems(items, limit) {
225      return dedupeSearchItems(items.filter(item => Boolean(item.title && (item.product_id || item.url)))).slice(0, limit);
226  }
227  export function mergeSearchItems(base, extra, limit) {
228      const extraMap = new Map();
229      for (const item of extra) {
230          const key = itemKey(item);
231          if (key)
232              extraMap.set(key, item);
233      }
234      const merged = base.map((item) => {
235          const key = itemKey(item);
236          const patch = key ? extraMap.get(key) : null;
237          if (!patch)
238              return item;
239          return {
240              ...item,
241              price: patch.price ?? item.price,
242              original_price: patch.original_price ?? item.original_price,
243              unit_price: patch.unit_price || item.unit_price,
244              discount_rate: patch.discount_rate ?? item.discount_rate,
245              rating: patch.rating ?? item.rating,
246              review_count: patch.review_count ?? item.review_count,
247              rocket: patch.rocket || item.rocket,
248              delivery_type: patch.delivery_type || item.delivery_type,
249              delivery_promise: patch.delivery_promise || item.delivery_promise,
250              seller: patch.seller || item.seller,
251              badge: patch.badge || item.badge,
252              category: patch.category || item.category,
253              url: patch.url || item.url,
254          };
255      });
256      const mergedKeys = new Set(merged.map(item => itemKey(item)).filter(Boolean));
257      const appended = extra.filter(item => {
258          const key = itemKey(item);
259          return key && !mergedKeys.has(key);
260      });
261      return sanitizeSearchItems([...merged, ...appended], limit);
262  }