9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
475 lines
22 KiB
JavaScript
475 lines
22 KiB
JavaScript
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { mergeSearchItems, normalizeSearchItem, parseLimitArg, parsePageArg, sanitizeSearchItems } from './utils.js';
|
|
function escapeJsString(value) {
|
|
return JSON.stringify(value);
|
|
}
|
|
function buildApplyFilterEvaluate(filter) {
|
|
return `
|
|
() => {
|
|
const filter = ${escapeJsString(filter)};
|
|
const labels = Array.from(document.querySelectorAll('label'));
|
|
const normalize = (value) => (value == null ? '' : String(value).trim().toLowerCase());
|
|
const target = labels.find((label) => {
|
|
const component = normalize(label.getAttribute('data-component-name'));
|
|
const imgAlt = normalize(label.querySelector('img')?.getAttribute('alt'));
|
|
const text = normalize(label.textContent);
|
|
|
|
if (filter === 'rocket') {
|
|
return (
|
|
component.includes('deliveryfilteroption-rocket_luxury,rocket_wow,coupang_global') ||
|
|
imgAlt.includes('rocket_luxury,rocket_wow,coupang_global') ||
|
|
imgAlt.includes('rocket-all') ||
|
|
text.includes('로켓')
|
|
);
|
|
}
|
|
|
|
return component.includes(filter) || imgAlt.includes(filter) || text.includes(filter);
|
|
});
|
|
|
|
if (!target) {
|
|
return { ok: false, reason: 'FILTER_NOT_FOUND' };
|
|
}
|
|
|
|
target.click();
|
|
|
|
return {
|
|
ok: true,
|
|
reason: 'FILTER_CLICKED',
|
|
component: target.getAttribute('data-component-name') || '',
|
|
text: (target.textContent || '').trim(),
|
|
alt: target.querySelector('img')?.getAttribute('alt') || '',
|
|
};
|
|
}
|
|
`;
|
|
}
|
|
function buildCurrentLocationEvaluate() {
|
|
return `
|
|
() => ({
|
|
href: location.href
|
|
})
|
|
`;
|
|
}
|
|
function buildSearchEvaluate(query, limit, pageNumber) {
|
|
return `
|
|
(async () => {
|
|
const query = ${escapeJsString(query)};
|
|
const limit = ${limit};
|
|
const pageNumber = ${pageNumber};
|
|
|
|
const normalizeText = (value) => (value == null ? '' : String(value).trim());
|
|
const parseNum = (value) => {
|
|
const text = normalizeText(value).replace(/[^\\d.]/g, '');
|
|
if (!text) return null;
|
|
const num = Number(text);
|
|
return Number.isFinite(num) ? num : null;
|
|
};
|
|
const extractPriceFromText = (text) => {
|
|
const matches = normalizeText(text).match(/\\d{1,3}(?:,\\d{3})*원/g) || [];
|
|
if (!matches.length) return '';
|
|
if (matches.length >= 2) return matches[matches.length - 2];
|
|
return matches[0];
|
|
};
|
|
const extractPriceInfo = (root) => {
|
|
const priceArea =
|
|
root.querySelector('.PriceArea_priceArea__NntJz, [class*="PriceArea_priceArea"], [class*="priceArea"]') ||
|
|
root;
|
|
const priceAreaText = normalizeText(priceArea.textContent || '');
|
|
const originalPrice = normalizeText(
|
|
priceArea.querySelector(
|
|
'del, .base-price, .origin-price, .original-price, .strike-price, [class*="base-price"], [class*="origin-price"], [class*="line-through"]'
|
|
)?.textContent || ''
|
|
);
|
|
const originalPriceNum = parseNum(originalPrice);
|
|
const unitPrice =
|
|
normalizeText(
|
|
priceArea.querySelector('.unit-price, [class*="unit-price"], [class*="unitPrice"]')?.textContent || ''
|
|
) ||
|
|
priceAreaText.match(/\\([^)]*당\\s*[^)]*원[^)]*\\)/)?.[0] ||
|
|
'';
|
|
|
|
const candidates = Array.from(priceArea.querySelectorAll('span, strong, div'))
|
|
.map((node) => {
|
|
const text = normalizeText(node.textContent || '');
|
|
if (!text || !/\\d/.test(text)) return null;
|
|
if (/\\d{1,2}:\\d{2}:\\d{2}/.test(text)) return null;
|
|
if (/당\\s*\\d/.test(text)) return null;
|
|
if (/^\\d+%$/.test(text)) return null;
|
|
|
|
const num = parseNum(text);
|
|
if (num == null) return null;
|
|
|
|
const className = normalizeText(node.getAttribute('class') || '').toLowerCase();
|
|
let score = 0;
|
|
if (/price|sale|selling|final/.test(className)) score += 6;
|
|
if (/red/.test(className)) score += 5;
|
|
if (/font-bold|bold/.test(className)) score += 3;
|
|
if (/line-through/.test(className)) score -= 12;
|
|
if (text.includes('원')) score += 2;
|
|
if (originalPriceNum != null && num === originalPriceNum) score -= 10;
|
|
if (num < 100) score -= 10;
|
|
|
|
return { text, num, score };
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
if (originalPriceNum != null) {
|
|
const aPrefer = a.num !== originalPriceNum ? 1 : 0;
|
|
const bPrefer = b.num !== originalPriceNum ? 1 : 0;
|
|
if (bPrefer !== aPrefer) return bPrefer - aPrefer;
|
|
}
|
|
return b.num - a.num;
|
|
});
|
|
|
|
const currentPrice =
|
|
normalizeText(candidates.find((candidate) => candidate.num !== originalPriceNum)?.text || '') ||
|
|
normalizeText(candidates[0]?.text || '') ||
|
|
extractPriceFromText(priceAreaText) ||
|
|
'';
|
|
|
|
return {
|
|
price: currentPrice,
|
|
originalPrice,
|
|
unitPrice,
|
|
};
|
|
};
|
|
const canonicalUrl = (url, productId) => {
|
|
if (url) {
|
|
try {
|
|
const parsed = new URL(url, 'https://www.coupang.com');
|
|
const match = parsed.pathname.match(/\\/vp\\/products\\/(\\d+)/);
|
|
return 'https://www.coupang.com/vp/products/' + (match?.[1] || productId || '');
|
|
} catch {}
|
|
}
|
|
return productId ? 'https://www.coupang.com/vp/products/' + productId : '';
|
|
};
|
|
const normalize = (raw) => {
|
|
const rawText = normalizeText(raw.text || raw.badgeText || raw.deliveryText || raw.summary);
|
|
const productId = normalizeText(
|
|
raw.productId || raw.product_id || raw.id || raw.productNo ||
|
|
raw?.product?.productId || raw?.item?.id
|
|
).match(/(\\d{6,})/)?.[1] || '';
|
|
const title = normalizeText(
|
|
raw.title || raw.name || raw.productName || raw.productTitle || raw.itemName
|
|
);
|
|
const price = parseNum(raw.price || raw.salePrice || raw.finalPrice || raw.sellingPrice);
|
|
const originalPrice = parseNum(raw.originalPrice || raw.basePrice || raw.listPrice || raw.originPrice);
|
|
const unitPrice = normalizeText(raw.unitPrice || raw.unit_price || raw.unitPriceText);
|
|
const rating = parseNum(raw.rating || raw.star || raw.reviewRating);
|
|
const reviewCount = parseNum(raw.reviewCount || raw.ratingCount || raw.reviewCnt || raw.reviews);
|
|
const badge = Array.isArray(raw.badges) ? raw.badges.map(normalizeText).filter(Boolean).join(', ') : normalizeText(raw.badge || raw.labels);
|
|
const seller = normalizeText(raw.seller || raw.sellerName || raw.vendorName || raw.merchantName);
|
|
const category = normalizeText(raw.category || raw.categoryName || raw.categoryPath);
|
|
const discountRate = parseNum(raw.discountRate || raw.discount || raw.discountPercent);
|
|
const url = canonicalUrl(raw.url || raw.productUrl || raw.link, productId);
|
|
return {
|
|
productId,
|
|
title,
|
|
price,
|
|
originalPrice,
|
|
unitPrice,
|
|
discountRate,
|
|
rating,
|
|
reviewCount,
|
|
rocket: normalizeText(raw.rocket || raw.rocketType),
|
|
deliveryType: normalizeText(raw.deliveryType || raw.deliveryBadge || raw.shippingType || raw.shippingBadge),
|
|
deliveryPromise: normalizeText(raw.deliveryPromise || raw.promise || raw.arrivalText || raw.arrivalBadge),
|
|
seller,
|
|
badge,
|
|
category,
|
|
url,
|
|
};
|
|
};
|
|
|
|
const byApi = async () => {
|
|
const candidates = [
|
|
'/np/search?q=' + encodeURIComponent(query) + '&component=&channel=user&page=' + pageNumber,
|
|
'/np/search?component=&q=' + encodeURIComponent(query) + '&channel=user&page=' + pageNumber,
|
|
];
|
|
|
|
for (const path of candidates) {
|
|
try {
|
|
const resp = await fetch(path, { credentials: 'include' });
|
|
if (!resp.ok) continue;
|
|
const text = await resp.text();
|
|
const data = text.trim().startsWith('<') ? null : JSON.parse(text);
|
|
const maybeItems =
|
|
data?.data?.products ||
|
|
data?.data?.productList ||
|
|
data?.products ||
|
|
data?.productList ||
|
|
data?.items;
|
|
if (Array.isArray(maybeItems) && maybeItems.length) {
|
|
return maybeItems.slice(0, limit).map(normalize);
|
|
}
|
|
} catch {}
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const byBootstrap = () => {
|
|
const isProductLike = (item) => {
|
|
if (!item || typeof item !== 'object') return false;
|
|
const values = [item.productId, item.product_id, item.id, item.productNo, item.url, item.productUrl, item.link, item.title, item.productName];
|
|
return values.some((value) => /\\/vp\\/products\\/|\\d{6,}/.test(normalizeText(value)));
|
|
};
|
|
|
|
const collectProducts = (node) => {
|
|
const queue = [node];
|
|
while (queue.length) {
|
|
const current = queue.shift();
|
|
if (!current || typeof current !== 'object') continue;
|
|
if (Array.isArray(current)) {
|
|
const productish = current.filter(isProductLike);
|
|
if (productish.length >= 3) return productish.slice(0, limit).map(normalize);
|
|
queue.push(...current.slice(0, 50));
|
|
continue;
|
|
}
|
|
for (const value of Object.values(current)) queue.push(value);
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const scriptNodes = Array.from(document.scripts);
|
|
for (const script of scriptNodes) {
|
|
const text = script.textContent || '';
|
|
if (!text || !/product|search/i.test(text)) continue;
|
|
const arrayMatches = [
|
|
...text.matchAll(/"products?"\\s*:\\s*(\\[[\\s\\S]{100,}?\\])/g),
|
|
...text.matchAll(/"itemList"\\s*:\\s*(\\[[\\s\\S]{100,}?\\])/g),
|
|
];
|
|
for (const match of arrayMatches) {
|
|
try {
|
|
const products = JSON.parse(match[1]);
|
|
if (Array.isArray(products) && products.length) {
|
|
return products.slice(0, limit).map(normalize);
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
const globals = [
|
|
window.__NEXT_DATA__,
|
|
window.__APOLLO_STATE__,
|
|
window.__INITIAL_STATE__,
|
|
window.__STATE__,
|
|
window.__PRELOADED_STATE__,
|
|
];
|
|
for (const candidate of globals) {
|
|
if (!candidate || typeof candidate !== 'object') continue;
|
|
const found = collectProducts(candidate);
|
|
if (found.length) return found;
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const byJsonLd = () => {
|
|
const scripts = Array.from(document.querySelectorAll('script[type="application/ld+json"]'));
|
|
for (const script of scripts) {
|
|
const text = script.textContent || '';
|
|
if (!text) continue;
|
|
try {
|
|
const payload = JSON.parse(text);
|
|
const docs = Array.isArray(payload) ? payload : [payload];
|
|
for (const doc of docs) {
|
|
const items =
|
|
doc?.itemListElement ||
|
|
doc?.about?.itemListElement ||
|
|
doc?.mainEntity?.itemListElement ||
|
|
[];
|
|
if (!Array.isArray(items) || !items.length) continue;
|
|
const mapped = items.map((entry) => {
|
|
const item = entry?.item || entry;
|
|
return normalize({
|
|
productId: item?.url || item?.sku || item?.productID,
|
|
title: item?.name,
|
|
price: item?.offers?.price,
|
|
originalPrice: item?.offers?.highPrice,
|
|
rating: item?.aggregateRating?.ratingValue,
|
|
reviewCount: item?.aggregateRating?.reviewCount,
|
|
seller: item?.offers?.seller?.name,
|
|
badge: item?.offers?.availability,
|
|
category: item?.category,
|
|
url: item?.url,
|
|
});
|
|
}).filter((item) => item.productId || item.url || item.title);
|
|
if (mapped.length) return mapped.slice(0, limit);
|
|
}
|
|
} catch {}
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const byDom = () => {
|
|
const domScanLimit = Math.max(limit * 6, 60);
|
|
const cards = Array.from(new Set([
|
|
...document.querySelectorAll('li.search-product'),
|
|
...document.querySelectorAll('li[class*="search-product"], div[class*="search-product"], article[class*="search-product"]'),
|
|
...document.querySelectorAll('li[class*="ProductUnit_productUnit"], [class*="ProductUnit_productUnit"]'),
|
|
...document.querySelectorAll('.impression-logged, [class*="promotion-item"], [class*="product-item"]'),
|
|
...document.querySelectorAll('[data-product-id]'),
|
|
...document.querySelectorAll('[data-id]'),
|
|
...document.querySelectorAll('a[href*="/vp/products/"]'),
|
|
])).slice(0, domScanLimit);
|
|
const items = [];
|
|
for (const el of cards) {
|
|
const root = el.closest('li, div, article, section') || el;
|
|
const html = root.innerHTML || '';
|
|
const priceInfo = extractPriceInfo(root);
|
|
const badgeImages = Array.from(root.querySelectorAll('img[data-badge-id]'));
|
|
const badgeIds = badgeImages
|
|
.map((node) => node.getAttribute('data-badge-id') || '')
|
|
.filter(Boolean);
|
|
const badgeSrcText = badgeImages
|
|
.map((node) => (node.getAttribute('data-badge-id') || '') + ' ' + (node.getAttribute('src') || ''))
|
|
.join(' ');
|
|
const productId =
|
|
root.getAttribute('data-product-id') ||
|
|
el.getAttribute('data-product-id') ||
|
|
root.querySelector('a[href*="/vp/products/"]')?.getAttribute('data-product-id') ||
|
|
root.querySelector('a[href*="/vp/products/"]')?.getAttribute('href')?.match(/\\/vp\\/products\\/(\\d+)/)?.[1] ||
|
|
html.match(/\\/vp\\/products\\/(\\d+)/)?.[1] ||
|
|
(el.getAttribute('href') || '').match(/\\/vp\\/products\\/(\\d+)/)?.[1] ||
|
|
'';
|
|
const title =
|
|
root.querySelector('.name, .title, .product-name, .search-product-title, .item-title, .ProductUnit_productNameV2__cV9cw, [class*="ProductUnit_productName"], [class*="productName"], [class*="product-name"], [class*="title"]')?.textContent ||
|
|
root.querySelector('img[alt]')?.getAttribute('alt') ||
|
|
html.match(/alt="([^"]+)"/)?.[1] ||
|
|
(root.textContent || '').replace(/\\s+/g, ' ').trim().match(/^(.+?)(\\d{1,3},\\d{3}원|무료배송|내일\\(|오늘\\(|새벽)/)?.[1] ||
|
|
el.getAttribute('title') ||
|
|
'';
|
|
const price = priceInfo.price || '';
|
|
const originalPrice = priceInfo.originalPrice || '';
|
|
const unitPrice = priceInfo.unitPrice || '';
|
|
const rating =
|
|
root.querySelector('.rating, .star em, [class*="rating"], [class*="star"], [class*="ProductRating"] [aria-label], [aria-label][class*="ProductRating"]')?.getAttribute?.('aria-label') ||
|
|
root.querySelector('.rating, .star em, [class*="rating"], [class*="star"], [class*="ProductRating"]')?.textContent ||
|
|
'';
|
|
const reviewCount =
|
|
root.querySelector('.rating-total-count, .count, .review-count, .promotion-item-review-count, [class*="review"], [class*="count"], [class*="ProductRating"] span, [class*="ProductRating"] [class*="fw-text"]')?.textContent ||
|
|
'';
|
|
const seller =
|
|
root.querySelector('.seller, .vendor, .search-product-wrap .vendor-name, [class*="vendor"], [class*="seller"]')?.textContent ||
|
|
'';
|
|
const category =
|
|
root.getAttribute('data-category') ||
|
|
root.querySelector('[class*="category"]')?.textContent ||
|
|
'';
|
|
const text = (root.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
const badgeNodes = Array.from(root.querySelectorAll('.badge, .delivery, .tag, .icon-service, .pdd-text, .delivery-text, [class*="badge"], [class*="delivery"]'));
|
|
const hrefNode = root.querySelector('a[href*="/vp/products/"]');
|
|
items.push(normalize({
|
|
productId,
|
|
title,
|
|
price,
|
|
originalPrice,
|
|
unitPrice,
|
|
rating,
|
|
reviewCount,
|
|
seller,
|
|
badges: [...badgeIds, ...badgeNodes.map((node) => node.textContent || '').filter(Boolean)],
|
|
rocket: badgeSrcText + ' ' + badgeNodes.map((node) => node.textContent || '').join(' '),
|
|
deliveryType: badgeNodes.map((node) => node.textContent || '').join(' ') + ' ' + text,
|
|
deliveryPromise: badgeNodes.map((node) => node.textContent || '').join(' ') + ' ' + text,
|
|
category,
|
|
text,
|
|
url: hrefNode?.getAttribute('href') || '',
|
|
}));
|
|
}
|
|
return items.slice(0, domScanLimit);
|
|
};
|
|
|
|
let items = await byApi();
|
|
if (!items.length) items = byJsonLd();
|
|
if (!items.length) items = byBootstrap();
|
|
const domItems = byDom();
|
|
if (!items.length) items = domItems;
|
|
|
|
return {
|
|
loginHints: {
|
|
hasLoginLink: Boolean(document.querySelector('a[href*="login"], a[title*="로그인"]')),
|
|
hasMyCoupang: /마이쿠팡/.test(document.body.innerText),
|
|
},
|
|
items,
|
|
domItems,
|
|
};
|
|
})()
|
|
`;
|
|
}
|
|
cli({
|
|
site: 'coupang',
|
|
name: 'search',
|
|
access: 'read',
|
|
description: 'Search Coupang products with logged-in browser session',
|
|
domain: 'www.coupang.com',
|
|
strategy: Strategy.COOKIE,
|
|
browser: true,
|
|
args: [
|
|
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
|
|
{ name: 'page', type: 'int', default: 1, help: 'Search result page number' },
|
|
{ name: 'limit', type: 'int', default: 20, help: 'Max results (max 50)' },
|
|
{ name: 'filter', required: false, help: 'Optional search filter (currently supports: rocket)' },
|
|
],
|
|
columns: ['rank', 'product_id', 'title', 'price', 'unit_price', 'rating', 'review_count', 'rocket', 'delivery_type', 'delivery_promise', 'url'],
|
|
func: async (page, kwargs) => {
|
|
const query = String(kwargs.query || '').trim();
|
|
if (!query) {
|
|
throw new ArgumentError('query cannot be empty');
|
|
}
|
|
const pageNumber = parsePageArg(kwargs.page, 1);
|
|
const limit = parseLimitArg(kwargs.limit, 20, 50);
|
|
const filter = String(kwargs.filter || '').trim().toLowerCase();
|
|
if (filter && filter !== 'rocket') {
|
|
throw new ArgumentError(`Unsupported --filter "${filter}" (supported: rocket)`);
|
|
}
|
|
const initialPage = filter ? 1 : pageNumber;
|
|
const url = `https://www.coupang.com/np/search?q=${encodeURIComponent(query)}&channel=user&page=${initialPage}`;
|
|
await page.goto(url).catch((error) => {
|
|
throw new CommandExecutionError(`coupang search navigation failed: ${error?.message || error}`);
|
|
});
|
|
if (filter) {
|
|
const filterResult = await page.evaluate(buildApplyFilterEvaluate(filter)).catch((error) => {
|
|
throw new CommandExecutionError(`coupang search filter evaluation failed: ${error?.message || error}`);
|
|
});
|
|
if (!filterResult?.ok) {
|
|
throw new EmptyResultError('coupang search', `Filter "${filter}" was not available on the current page; try without --filter or wait for Coupang to render the filter bar.`);
|
|
}
|
|
await page.wait(3).catch((error) => {
|
|
throw new CommandExecutionError(`coupang search wait failed: ${error?.message || error}`);
|
|
});
|
|
if (pageNumber > 1) {
|
|
const locationInfo = await page.evaluate(buildCurrentLocationEvaluate()).catch((error) => {
|
|
throw new CommandExecutionError(`coupang search location evaluation failed: ${error?.message || error}`);
|
|
});
|
|
const filteredUrl = new URL(locationInfo?.href || url);
|
|
filteredUrl.searchParams.set('page', String(pageNumber));
|
|
await page.goto(filteredUrl.toString()).catch((error) => {
|
|
throw new CommandExecutionError(`coupang search filtered navigation failed: ${error?.message || error}`);
|
|
});
|
|
}
|
|
}
|
|
await page.autoScroll({ times: filter ? 3 : 2, delayMs: 1500 }).catch((error) => {
|
|
throw new CommandExecutionError(`coupang search scroll failed: ${error?.message || error}`);
|
|
});
|
|
const raw = await page.evaluate(buildSearchEvaluate(query, limit, pageNumber)).catch((error) => {
|
|
throw new CommandExecutionError(`coupang search extraction failed: ${error?.message || error}`);
|
|
});
|
|
const loginHints = raw?.loginHints ?? {};
|
|
const items = Array.isArray(raw?.items) ? raw.items : [];
|
|
const domItems = Array.isArray(raw?.domItems) ? raw.domItems : [];
|
|
const normalizedBase = sanitizeSearchItems(items.map((item, index) => normalizeSearchItem(item, index)), limit);
|
|
const normalizedDom = sanitizeSearchItems(domItems.map((item, index) => normalizeSearchItem(item, index)), Math.max(limit * 6, 60));
|
|
const normalized = filter
|
|
? sanitizeSearchItems(normalizedDom, limit)
|
|
: mergeSearchItems(normalizedBase, normalizedDom, limit);
|
|
if (!normalized.length) {
|
|
if (loginHints.hasLoginLink && !loginHints.hasMyCoupang) {
|
|
throw new AuthRequiredError('coupang.com', 'Please log into Coupang in Chrome and retry.');
|
|
}
|
|
throw new EmptyResultError('coupang search', `No products matched "${query}". Try a more specific keyword or remove --filter.`);
|
|
}
|
|
return normalized;
|
|
},
|
|
});
|