import { ArgumentError } from '@jackwener/opencli/errors'; /** * Parse a positive integer arg (--limit / --page / --review-page). * * Throws ArgumentError on out-of-range / non-integer values rather than * silently clamping. We prefer typed-fail-fast over silent clamping for the * same reason as feedback_typed_fail_fast_for_adapters: callers cannot tell * that their value was rewritten and end up confused why "limit=999" returned * 50 rows. */ export function parseLimitArg(raw, fallback, max) { if (raw === undefined || raw === null || raw === '') { return fallback; } const num = Number(raw); if (!Number.isInteger(num) || num < 1 || num > max) { throw new ArgumentError(`--limit must be an integer between 1 and ${max} (got ${raw})`); } return num; } export function parsePageArg(raw, fallback) { if (raw === undefined || raw === null || raw === '') { return fallback; } const num = Number(raw); if (!Number.isInteger(num) || num < 1) { throw new ArgumentError(`--page must be a positive integer (got ${raw})`); } return num; } function itemKey(item) { return item.url || item.product_id || `${item.title}:${item.price ?? ''}`; } const ROCKET_PATTERNS = ['판매자로켓', '로켓프레시', '로켓와우', '로켓배송', '로켓직구']; const DELIVERY_TYPE_PATTERNS = ['무료배송', '일반배송']; const DELIVERY_PROMISE_PATTERNS = ['오늘도착', '내일도착', '새벽도착', '오늘출발']; const BADGE_ID_TO_ROCKET = { ROCKET: '로켓배송', ROCKET_MERCHANT: '판매자로켓', ROCKET_WOW: '로켓와우', WOW: '로켓와우', ROCKET_FRESH: '로켓프레시', FRESH: '로켓프레시', SELLER_ROCKET: '판매자로켓', ROCKET_JIKGU: '로켓직구', JIKGU: '로켓직구', COUPANG_GLOBAL: '로켓직구', }; const BADGE_ID_TO_PROMISE = { DAWN: '새벽도착', EARLY_DAWN: '새벽도착', TOMORROW: '내일도착', TODAY: '오늘도착', SAME_DAY: '오늘도착', TODAY_SHIP: '오늘출발', TODAY_DISPATCH: '오늘출발', }; function asString(value) { if (value == null) return ''; return String(value).trim(); } function toNumber(value) { if (typeof value === 'number' && Number.isFinite(value)) return value; const text = asString(value).replace(/[^\d.]/g, ''); if (!text) return null; const num = Number(text); return Number.isFinite(num) ? num : null; } function pickFirst(obj, paths) { for (const path of paths) { const parts = path.split('.'); let current = obj; let ok = true; for (const part of parts) { if (!current || typeof current !== 'object' || !(part in current)) { ok = false; break; } current = current[part]; } if (ok && current != null && asString(current) !== '') return current; } return null; } export function normalizeProductId(raw) { const text = asString(raw); if (!text) return ''; const match = text.match(/\/vp\/products\/(\d+)/) || text.match(/\b(\d{6,})\b/); return match?.[1] ?? ''; } export function requireProductIdArg(raw, label = '--product-id') { const text = asString(raw); if (label === '--url') { try { const url = new URL(text.startsWith('http') ? text : `https://www.coupang.com${text}`); const match = url.pathname.match(/^\/vp\/products\/(\d{6,})(?:\/|$)/); const isCoupangHost = url.hostname === 'coupang.com' || url.hostname.endsWith('.coupang.com'); if (isCoupangHost && match) { return match[1]; } } catch { // Fall through to the typed validation error below. } throw new ArgumentError(`${label} must be a Coupang product URL containing /vp/products/`); } if (!/^\d{6,}$/.test(text)) { throw new ArgumentError(`${label} must be a numeric Coupang product ID`); } return text; } export function canonicalizeProductUrl(rawUrl, productId) { const raw = asString(rawUrl); if (raw) { try { const url = new URL(raw.startsWith('http') ? raw : `https://www.coupang.com${raw}`); if (!url.hostname.includes('coupang.com')) return ''; const id = normalizeProductId(url.pathname) || normalizeProductId(productId); if (!id) return url.toString(); return `https://www.coupang.com/vp/products/${id}`; } catch { return ''; } } const id = normalizeProductId(productId); return id ? `https://www.coupang.com/vp/products/${id}` : ''; } function extractTokens(values) { return values .flatMap((value) => { const text = asString(value); if (!text) return []; return text.split(/[,\s|]+/); }) .map((token) => token.trim().toUpperCase()) .filter(Boolean); } function normalizeJoinedText(...values) { return values .map(asString) .filter(Boolean) .join(' ') .replace(/schema\.org\/[A-Za-z]+/gi, ' ') .replace(/\s+/g, ' ') .trim(); } function normalizeRocket(...values) { const tokens = extractTokens(values); for (const token of tokens) { if (BADGE_ID_TO_ROCKET[token]) return BADGE_ID_TO_ROCKET[token]; } const text = normalizeJoinedText(...values); if (!text) return ''; if (/판매자\s*로켓/.test(text)) return '판매자로켓'; if (/로켓\s*프레시|새벽\s*도착\s*보장/.test(text)) return '로켓프레시'; if (/로켓\s*와우/.test(text)) return '로켓와우'; if (/로켓\s*직구|직구/.test(text)) return '로켓직구'; if (/로켓\s*배송/.test(text)) return '로켓배송'; return ROCKET_PATTERNS.find(pattern => text.includes(pattern)) ?? ''; } function normalizeDeliveryType(...values) { const text = normalizeJoinedText(...values); if (!text) return ''; if (/무료\s*배송/.test(text)) return '무료배송'; if (/일반\s*배송/.test(text)) return '일반배송'; return DELIVERY_TYPE_PATTERNS.find(pattern => text.includes(pattern)) ?? ''; } function normalizeDeliveryPromise(...values) { const tokens = extractTokens(values); for (const token of tokens) { if (BADGE_ID_TO_PROMISE[token]) return BADGE_ID_TO_PROMISE[token]; } const text = normalizeJoinedText(...values); if (!text) return ''; if (/오늘\s*출발/.test(text)) return '오늘출발'; if (/오늘.*도착/.test(text)) return '오늘도착'; if (/새벽.*도착/.test(text)) return '새벽도착'; if (/내일.*도착/.test(text)) return '내일도착'; return DELIVERY_PROMISE_PATTERNS.find(pattern => text.includes(pattern)) ?? ''; } function normalizeBadge(value) { const normalizeOne = (entry) => { const text = asString(entry); if (!text) return ''; if (/schema\.org\//i.test(text)) { return text.split('/').pop() ?? ''; } return text; }; if (Array.isArray(value)) { return value.map(normalizeOne).filter(Boolean).join(', '); } return normalizeOne(value); } export function normalizeSearchItem(raw, index) { const productId = normalizeProductId(pickFirst(raw, ['productId', 'product_id', 'id', 'productNo', 'item.id', 'product.productId', 'url'])); const title = asString(pickFirst(raw, ['title', 'name', 'productName', 'productTitle', 'itemName', 'item.title'])); const price = toNumber(pickFirst(raw, ['price', 'salePrice', 'finalPrice', 'sellingPrice', 'discountPrice', 'item.price'])); const originalPrice = toNumber(pickFirst(raw, ['originalPrice', 'basePrice', 'listPrice', 'originPrice', 'strikePrice'])); const unitPrice = asString(pickFirst(raw, ['unitPrice', 'unit_price', 'unitPriceText'])); const rating = toNumber(pickFirst(raw, ['rating', 'star', 'reviewRating', 'review.rating', 'item.rating'])); const reviewCount = toNumber(pickFirst(raw, ['reviewCount', 'ratingCount', 'reviews', 'reviewCnt', 'item.reviewCount'])); const deliveryHintValues = [ pickFirst(raw, ['deliveryType', 'deliveryBadge', 'badgeLabel', 'shippingType', 'shippingBadge']), pickFirst(raw, ['badge', 'badges', 'labels', 'benefitBadge', 'promotionBadge']), pickFirst(raw, ['text', 'summary']), pickFirst(raw, ['deliveryPromise', 'promise', 'arrivalText', 'arrivalBadge']), pickFirst(raw, ['rocket', 'rocketType']), ]; const deliveryType = normalizeDeliveryType(...deliveryHintValues); const deliveryPromise = normalizeDeliveryPromise(...deliveryHintValues); const rocket = normalizeRocket(...deliveryHintValues); const badge = normalizeBadge(pickFirst(raw, ['badge', 'badges', 'labels', 'benefitBadge', 'promotionBadge'])); const category = asString(pickFirst(raw, ['category', 'categoryName', 'categoryPath', 'item.category'])); const seller = asString(pickFirst(raw, ['seller', 'sellerName', 'vendorName', 'merchantName', 'item.seller'])); const url = canonicalizeProductUrl(pickFirst(raw, ['url', 'productUrl', 'link', 'item.url']), productId); const discountRate = toNumber(pickFirst(raw, ['discountRate', 'discount', 'discountPercent', 'discount_rate'])); return { rank: index + 1, product_id: productId, title, price, original_price: originalPrice, unit_price: unitPrice, discount_rate: discountRate, rating, review_count: reviewCount, rocket, delivery_type: deliveryType, delivery_promise: deliveryPromise, seller, badge, category, url, }; } export function dedupeSearchItems(items) { const seen = new Set(); const out = []; for (const item of items) { const key = itemKey(item); if (!key || seen.has(key)) continue; seen.add(key); out.push({ ...item, rank: out.length + 1 }); } return out; } export function sanitizeSearchItems(items, limit) { return dedupeSearchItems(items.filter(item => Boolean(item.title && (item.product_id || item.url)))).slice(0, limit); } export function mergeSearchItems(base, extra, limit) { const extraMap = new Map(); for (const item of extra) { const key = itemKey(item); if (key) extraMap.set(key, item); } const merged = base.map((item) => { const key = itemKey(item); const patch = key ? extraMap.get(key) : null; if (!patch) return item; return { ...item, price: patch.price ?? item.price, original_price: patch.original_price ?? item.original_price, unit_price: patch.unit_price || item.unit_price, discount_rate: patch.discount_rate ?? item.discount_rate, rating: patch.rating ?? item.rating, review_count: patch.review_count ?? item.review_count, rocket: patch.rocket || item.rocket, delivery_type: patch.delivery_type || item.delivery_type, delivery_promise: patch.delivery_promise || item.delivery_promise, seller: patch.seller || item.seller, badge: patch.badge || item.badge, category: patch.category || item.category, url: patch.url || item.url, }; }); const mergedKeys = new Set(merged.map(item => itemKey(item)).filter(Boolean)); const appended = extra.filter(item => { const key = itemKey(item); return key && !mergedKeys.has(key); }); return sanitizeSearchItems([...merged, ...appended], limit); }