Files
jackwener__opencli/clis/coupang/utils.js
jakevin 67cde0e263 enrich(coupang): product detail cmd + replace silent clamp/sentinel/Error with typed errors (#1370)
* enrich(coupang): add product detail cmd + replace silent clamp/sentinel/Error with typed errors

Two enrichment changes plus three silent-failure fixes on top of existing
search / add-to-cart.

New cmd: coupang product
─────────────────────────
Pairs with search as the listing↔detail round-trip target. Reads a logged-in
product page and extracts a single canonical row with price, original_price,
discount_rate, rating, review_count, seller, brand, rocket, delivery_promise,
image_url, url. Three-source extractor (JSON-LD Product schema → bootstrap
globals → DOM) merged in priority order, mirroring the search.js pattern.

The columns use string|null typing — null means "upstream did not provide
this field on this product" (e.g. some items have no original_price).
Failures (login wall / page mismatch / page failed to render) raise typed
errors instead of silently returning empty rows, so callers can treat any
returned row as real data.

Search column shape: added product_id
─────────────────────────────────────
Listing must pair with detail by id. The data was already extracted by
normalizeSearchItem; only the columns array needed updating so the field
projects through to the rendered row. Per the listing-id-pairing convention
(PR #1297) the new column lets agents round-trip rows directly into
`coupang product` without re-scraping URLs.

Silent-failure fixes
────────────────────
1. search --limit silent clamp.
   Old: `Math.min(Math.max(Number(kwargs.limit||20),1),50)` silently
        rewrote `--limit 999` to 50 and `--limit 0` to 1.
   New: `parseLimitArg(raw, 20, 50)` throws ArgumentError on out-of-range
        / non-integer / negative input. Same convention as the typed-fail-fast
        memory & PR #1289.

2. search --page silent clamp.
   Old: `Math.max(Number(kwargs.page||1),1)` silently lifted negative pages.
   New: parsePageArg throws ArgumentError on non-positive input.

3. Generic `throw new Error(...)` → typed errors.
   - Empty query, unsupported --filter, missing --product-id/--url
     → ArgumentError
   - Login wall detection → AuthRequiredError('coupang.com', ...)
   - Empty result / filter-not-rendered → EmptyResultError
   - PRODUCT_MISMATCH / OPTION_REQUIRED / button-not-found / unknown
     ack failure (add-to-cart) → CommandExecutionError
   - The PRODUCT_MISMATCH and `actualProductId || 'unknown'` sentinel were
     also fixed (silent-sentinel was the audit hit there).

Coverage
────────
- 21 contract assertions in clis/coupang/coupang.test.js covering
  parseLimitArg / parsePageArg (no silent clamp), registry shape (search has
  product_id, product is read-class with expected columns, add-to-cart is
  write-class), and typed-error pre-flight rejections (empty query / bad
  filter / out-of-range limit & page / missing detail args).
- Manifest 763 → 764 (+1 entry: coupang/product).
- Audits: typed-error-lint 196 → 194 (resolved 2 silent-clamp/sentinel
  baseline entries; baseline updated). silent-column-drop 103/103 unchanged.

* fix(coupang): tighten product id and browser errors

* fix(coupang): require real product urls
2026-05-07 00:18:57 +08:00

317 lines
12 KiB
JavaScript

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/<id>`);
}
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);
}