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 }